Configure the video stream from robotctl configure

What `mediad` streams — camera or test pattern, frame size, rate, bitrate —
was four clap flags, of which the unit file set exactly one. The release
installer rewrites `mediad.service`, so changing any of them meant a systemd
drop-in: a mechanism for a board that is wired differently, not for someone
asking why the picture is soft. In practice every robot ran the defaults.

They are now `[media]` in `/etc/robot/robotd.toml` — the per-board config file
that already survives an update and a rollback, and that `robotctl configure`
already edits. `mediad` reads it through `robotd-params`, the same crate
`robotd` parses it with and the same one the editor writes through, so the
schema, the defaults, the validation and the editor cannot drift apart.

One `quality` key naming a rung (`1080p30`, `720p30`, `720p15`, `360p30`)
rather than a width, a height and an fps: those three do not vary
independently, and a combination the capture path cannot produce is a pipeline
that does not start — which costs the WebRTC *control* channel along with the
video, since the datachannel is bundled with the video track. Every rung is
16:9, so "smaller" never quietly means "cropped". `bitrate` stays settable on
its own and follows the quality when unset. 720p30 with a 2 Mb/s start is
exactly what the flags defaulted to, so a board with no `[media]` section
streams what it streamed before.

The restart offer is now derived from the keys that changed rather than
assumed: `[media]` is `mediad`, everything else is `robotd`, both in the
`After=` order. Offering a `robotd` restart for a video setting is an edit
that reads as having done nothing at all.

`media.camera` replaces the documented `--camera` drop-in, so a board with no
camera is fixed the same way everything else is.

Verified: `cargo test --workspace --exclude tof` green, and
`RUSTFLAGS=-D warnings cargo clippy --target aarch64-unknown-linux-gnu` clean
for the three crates touched — which is what caught `session::Video`, the
second consumer of the geometry, still reading the removed flags.

Assisted-by: Claude:claude-opus-5
This commit is contained in:
Pierre Rouanet 2026-08-27 08:44:18 +02:00
parent a08c8b3923
commit f7de13a227
15 changed files with 683 additions and 90 deletions

1
Cargo.lock generated
View File

@ -2394,6 +2394,7 @@ dependencies = [
"gstreamer-app",
"gstreamer-video",
"gstreamer-webrtc",
"robotd-params",
"serde_json",
"tempfile",
"tokio",

View File

@ -324,3 +324,36 @@ mode = "walk"
#
# Needs [audio] on, since a duck with no voice has nothing to sing with.
# accept = false
[media]
# What mediad streams: the camera, and at what quality. Read by mediad at startup, so a change
# here needs `systemctl restart mediad` — not robotd. `robotctl configure` offers the right one.
#
# These were flags on mediad.service's ExecStart line, which the release installer rewrites: the
# only supported way to change one was a systemd drop-in, and nobody reaches for a drop-in to
# answer "why is the video soft?".
# Stream the head camera. Off streams a test pattern instead, which is what a board with no
# camera wants — the pipeline still starts, so the WebRTC *control* channel still exists. It is
# bundled with the video track, so a pipeline that will not start costs both.
# camera = true
# Frame size and rate, as one name: 1080p30, 720p30, 720p15 or 360p30.
#
# One key rather than a width, a height and an fps, because they do not vary independently — a
# rung the capture path cannot produce is a mediad that will not start, and every rung here is
# 16:9 so that "smaller" never means "cropped".
#
# 720p30 is the rung every measurement in mediad was taken at: 29.3 fps off the ISP main path,
# with a capture format and a buffer depth that took three bench sessions to find. The sensor is
# pinned to a 1920x1080 mode that runs at 30 and the ISP scales down from it, so 1080p30 asks for
# no scaling at all — what is unmeasured is whether the capture path and the encoder hold 30 fps
# at 2.25x the pixels. A rung that does not hold runs slower rather than failing.
# quality = "720p30"
# Starting video bitrate, bits per second. Unset follows the quality — 4 Mb/s at 1080p30,
# 2 Mb/s at 720p30, 1 Mb/s at 720p15, 800 kb/s at 360p30 — which is what almost every robot
# wants. webrtcsink's congestion control moves from here, so it is a starting point rather than
# a ceiling. The unit is bits: 2 Mb/s is 2000000, and a value below 100000 is refused as
# somebody who meant kilobits.
# bitrate = 2000000

View File

@ -92,7 +92,7 @@ Where the state lives, and what survives an update:
| | |
|---|---|
| `/etc/robot/robotd.toml`, `updater.toml` | per-board configuration; the installer writes it once and never overwrites it |
| `/etc/robot/robotd.toml`, `updater.toml` | per-board configuration; the installer writes it once and never overwrites it. `robotd.toml` is read by `robotd` and — for `[media]` alone, what the camera streams — by `mediad`, so a change there restarts `mediad` rather than `robotd` |
| `/var/lib/robot/config/config.json` | robot name and pairing PIN — a file plus `flock`, owned by `configd` (§3.1) |
| NetworkManager profiles | wifi credentials; we never store them (§3) |
| `/opt/robot/daemon/releases/<ver>/` | binaries, policies and shipped defaults — replaced atomically |

View File

@ -86,6 +86,30 @@ which is why several of these questions were answered the slow way.
What is still untested: anything at all through a bridge.
### Picking a quality, and why it is one setting rather than four
What the stream is — camera or test pattern, frame size, rate, bitrate — is `[media]` in
`/etc/robot/robotd.toml`, the per-board config file `robotd` already reads. `sudo robotctl
configure` edits it and offers the `systemctl restart mediad` it needs; `mediad` reads it once at
startup, like every other daemon here reads its config.
**One `quality` key naming a rung — `1080p30`, `720p30`, `720p15`, `360p30` — rather than a width,
a height and an fps.** Those three do not vary independently: a combination the capture path
cannot produce is a pipeline that does not start, and that costs the *control* channel along with
the video, because the datachannel is bundled with the video track (§2). Every rung is 16:9, the
sensor's own aspect, so "smaller" never quietly means "cropped". `bitrate` is the one number that
can still be set on its own, and left unset it follows the rung — 2 Mb/s at 720p30, the rate every
measurement above was taken at.
The numbers were `ExecStart` flags in `mediad.service` until the section existed. The release
installer rewrites that unit file, so changing one meant a systemd drop-in — a mechanism for a
board that is *wired* differently, not for someone asking why the picture is soft.
**720p30 is the only measured rung.** The sensor is pinned to a 1920x1080 mode that runs at 30 and
the ISP scales down from it, so 1080p30 asks for no scaling at all; what nobody has measured is
whether the capture path and the encoder hold 30 fps at 2.25x the pixels of the table above. A
rung that does not hold runs slower — it is not a failure to start.
## 1. What this is not
`webrtcbin` is not used. `mediad` uses **`webrtcsink`** from `gst-plugins-rs`, and the difference

View File

@ -99,15 +99,15 @@ behave, and those numbers stay radians whatever the screen is set to. The joint
robotctl monitor --json --hz 50 > run.jsonl
```
### Configuring `robotd`
### Configuring the robot
```
sudo robotctl configure
```
An interactive editor over `/etc/robot/robotd.toml`: every key the daemon knows, the feature
An interactive editor over `/etc/robot/robotd.toml`: every key the daemons know, the feature
switches first (policy on/off, walk/roller, limp-fall, audio, pet detection, battery
shutdown…), current value against default, one line of doc. SPACE toggles, ENTER types a
shutdown, camera and video quality…), current value against default, one line of doc. SPACE toggles, ENTER types a
value, `u` reverts a key to its default. Values in yellow (marked `•`) are the keys where
this robot diverges from the defaults; everything else is the built-in default, and `unset`
optionals show what they resolve to `(auto)`.
@ -124,11 +124,31 @@ Three properties worth trusting:
- **It cannot write a file robotd refuses to start on.** Every save is validated through the
daemon's own loader first, atomically (temp file + rename), and rejected with the reason.
`robotd` reads the file once at startup, so saving offers a restart. `sudo`, because the file
The daemons read the file once at startup, so saving offers a restart — of the ones that read
what you changed: `[media]` is `mediad`, everything else is `robotd`. `sudo`, because the file
is root-owned — without it the editor opens read-only and says so on the first write.
`--file` points it elsewhere for a bench copy. The shipped `deploy/robotd.toml` stays the
reference for *why* each knob exists; this is for flipping them.
#### Video quality
```
sudo robotctl configure
```
Set `media.quality``1080p30`, `720p30`, `720p15` or `360p30` — and take the restart it
offers. `media.camera` off streams a test pattern instead, which is what a board with no camera
wants: the WebRTC *control* channel rides on the video track, so a pipeline that cannot start
costs both. `media.bitrate` follows the quality unless you set it; the unit is bits per second.
720p30 is the rung the pipeline was measured at; a rung that does not hold runs slower rather
than failing. `robotctl monitor` reports the achieved rate on the bottom border, in yellow with
`of <target>` beside it when it is under 90% of what was asked for. What was applied:
```
journalctl -u mediad -b | grep streaming
```
#### Your own policy
You do not need to cut a release to try a network. Point `robotd` at your own `.onnx` on the

View File

@ -17,6 +17,10 @@ description = "Camera, mic, WebRTC — and the remote gateway"
[dependencies]
duck-ipc-proto = { path = "../duck-ipc-proto" }
# `[media]` in /etc/robot/robotd.toml — what this daemon streams. 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" }
anyhow = "1"
# The console's one route. Already in `Cargo.lock` — `updater` uses it for its test mirror — so it
# is known-good against this toolchain and the cross build. The alternative was a hand-rolled
@ -56,6 +60,8 @@ gstreamer-video = { version = "0.24", features = ["v1_22"] }
glib = "0.21"
[dev-dependencies]
# `[media]` from a real file on disk, which is how `config::load`'s fall-back-to-defaults rule is
# tested without a board.
# `every_call()`, so this crate's exhaustive-match tests use the same list every other
# transport's do rather than growing a third copy of it.
duck-ipc-proto = { path = "../duck-ipc-proto", features = ["test-support"] }

99
mediad/src/config.rs Normal file
View File

@ -0,0 +1,99 @@
//! What this daemon streams, out of the config file `robotd` already reads.
//!
//! `[media]` in `/etc/robot/robotd.toml` — camera or test pattern, frame size, rate, bitrate. 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.
//!
//! Its own module rather than four lines in `main`, for one reason: `main` is Linux-only, so
//! anything living there is not compiled — let alone tested — on the machine it is written on.
use std::path::{Path, PathBuf};
use robotd_params::{MediaParams, Params};
/// The file, when `--config` said nothing.
pub fn default_path() -> PathBuf {
PathBuf::from(robotd_params::DEFAULT_PATH)
}
/// Read `[media]`, or fall back to the built-in defaults.
///
/// **A file this daemon cannot read is not a reason to have no video.** `robotd` refuses to start
/// on a broken params file — that is the loud signal, and it is the daemon whose control loop the
/// file configures. A robot in that state is already down, and its camera is how somebody looks at
/// it. So this warns, names the file, and streams the defaults rather than joining the outage.
///
/// A *missing* file is not even a warning at the default path: an unprovisioned board has none and
/// streams its camera at 720p30 like every other. A path named on the command line must exist,
/// which is `Params::load`'s own rule and the reason `explicit` is passed through.
pub fn load(path: &Path, explicit: bool) -> MediaParams {
match Params::load(path, explicit) {
Ok(params) => params.media,
Err(e) => {
tracing::warn!(
error = %e,
path = %path.display(),
"unusable params file; streaming the built-in defaults"
);
MediaParams::default()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn write(dir: &Path, text: &str) -> PathBuf {
let path = dir.join("robotd.toml");
std::fs::write(&path, text).expect("writes");
path
}
/// The section is read, and one key set does not disturb the rest.
#[test]
fn a_quality_in_the_file_is_what_gets_streamed() {
let dir = tempfile::tempdir().unwrap();
let path = write(dir.path(), "[media]\nquality = \"360p30\"\n");
let media = load(&path, true);
assert_eq!(media.quality.size(), (640, 360));
assert_eq!(media.quality.fps(), 30);
assert_eq!(media.bitrate_resolved(), media.quality.default_bitrate());
assert!(media.camera, "untouched keys keep their defaults");
}
/// A robot with no file at the default path streams its camera, and says nothing about it.
#[test]
fn a_missing_file_streams_the_defaults() {
let dir = tempfile::tempdir().unwrap();
let media = load(&dir.path().join("absent.toml"), false);
assert_eq!(media.quality, MediaParams::default().quality);
assert!(media.camera);
}
/// The claim the doc comment makes, pinned: a params file `robotd` will not start on still
/// leaves a camera to look at the robot with.
#[test]
fn a_broken_file_still_streams() {
let dir = tempfile::tempdir().unwrap();
let path = write(dir.path(), "[media\nquality = ");
let media = load(&path, true);
assert_eq!(media.quality, MediaParams::default().quality);
assert!(media.camera);
}
/// A `[media]` section from a build that had a key this one does not is ignored key by key,
/// not section by section — the same rule that stopped a `[chorale]` from a branch keeping a
/// robot down. What this build *does* understand still applies.
#[test]
fn a_key_from_another_build_does_not_cost_the_ones_this_build_has() {
let dir = tempfile::tempdir().unwrap();
let path = write(
dir.path(),
"[media]\nquality = \"720p15\"\nchroma_subsampling = \"4:4:4\"\n",
);
let media = load(&path, true);
assert_eq!(media.quality.fps(), 15);
}
}

View File

@ -9,6 +9,7 @@
//! [`session::run`] is transport-agnostic on purpose: it takes lines and gives lines, so it is
//! testable without a WebRTC peer and would serve a WebSocket surface (§11) unchanged.
//!
//! - [`config`] — `[media]` in `robotd.toml`: what the stream is, edited with `robotctl configure`.
//! - [`web`] — the console page, served by the daemon it drives. `webrtc-console.md` §1.
//! - [`producer`] — who this robot says it is, before a peer negotiates anything. §5.
//!
@ -16,6 +17,7 @@
//! signalling server in this process, `mpph264enc` in front of it, and a `control` datachannel per
//! peer wired to [`session::run`].
pub mod config;
pub mod producer;
pub mod route;
pub mod session;

View File

@ -16,6 +16,7 @@
//! takes an update, and is still reachable over Bluetooth. That is why it may depend on a plugin
//! from a release asset and a device node's group while `updaterd` may not.
use std::path::PathBuf;
use std::process::ExitCode;
use clap::Parser;
@ -45,17 +46,16 @@ struct Args {
#[arg(long, default_value_t = 8080)]
web_port: u16,
/// Target video bitrate, bits per second.
/// Params file. Defaults to `/etc/robot/robotd.toml`, which may be absent — a board with no
/// file streams its camera at the built-in defaults. A path given here must exist.
///
/// Explicit rather than the encoder's "auto calculate": `rc-mode` is already constant-bitrate,
/// which is what a lossy link wants, and leaving the rate unset is how a stream comes out
/// fifty times under what anyone expected.
#[arg(long, default_value_t = 2_000_000)]
bitrate: u32,
/// Stream the head camera instead of a test pattern.
/// **The same file `robotd` reads, and `[media]` is this daemon's section of it.** What the
/// stream looks like — camera or test pattern, frame size, rate, bitrate — used to be flags
/// on this unit's `ExecStart` line, which the release installer rewrites: changing one meant
/// a systemd drop-in, and nobody reaches for a drop-in to answer "why is the video soft?".
/// `robotctl configure` edits that file, so it now edits this.
#[arg(long)]
camera: bool,
config: Option<PathBuf>,
/// Which capture node. rkisp exposes several; `video0` is the main path.
#[arg(long, default_value = "/dev/video0")]
@ -73,20 +73,6 @@ struct Args {
#[arg(long, default_value_t = 1024)]
analogue_gain: u32,
/// Frame size and rate, pinned rather than negotiated.
///
/// Both branches of the tee depend on the answer — the encoder and whatever reads raw NV12 —
/// so a consumer that had to guess would get it wrong the first time the source changed.
/// 1280x720 at 30 is what the hardware encoder was measured at.
#[arg(long, default_value_t = 1280)]
width: u32,
#[arg(long, default_value_t = 720)]
height: u32,
#[arg(long, default_value_t = 30)]
fps: u32,
/// How far the camera is mounted from upright, clockwise: 0, 90, 180 or 270.
///
/// **90, because the head camera is mounted a quarter turn off**, and this is the one place that
@ -140,6 +126,23 @@ fn main() -> ExitCode {
return ExitCode::FAILURE;
}
};
// What the stream is, from `[media]` — see `--config` and `mediad::config`.
let explicit = args.config.is_some();
let config = args
.config
.clone()
.unwrap_or_else(mediad::config::default_path);
let media = mediad::config::load(&config, explicit);
tracing::info!(
camera = media.camera,
quality = media.quality.label(),
width = media.quality.width(),
height = media.quality.height(),
fps = media.quality.fps(),
bitrate = media.bitrate_resolved(),
"streaming"
);
let rotation = if args.flip_in_pipeline {
tracing::warn!(
degrees = args.rotate,
@ -183,7 +186,7 @@ fn main() -> ExitCode {
"producing as"
);
let source = if args.camera {
let source = if media.camera {
mediad::pipeline::Source::Camera(mediad::pipeline::Camera {
device: args.camera_device.clone(),
exposure: args.exposure,
@ -193,13 +196,18 @@ fn main() -> ExitCode {
mediad::pipeline::Source::Test
};
// Frame size and rate are still pinned rather than negotiated — both branches of the tee
// depend on the answer, so a consumer that had to guess would get it wrong the first time
// the source changed. What changed is only where the numbers come from: one named quality
// in the config file rather than three flags nobody could set. `robotd_params::Quality`
// says why the three move together.
let settings = mediad::pipeline::Settings {
host: args.host.clone(),
port: args.port,
bitrate: args.bitrate,
width: args.width,
height: args.height,
fps: args.fps,
bitrate: media.bitrate_resolved(),
width: media.quality.width(),
height: media.quality.height(),
fps: media.quality.fps(),
rotation,
};
@ -222,8 +230,8 @@ fn main() -> ExitCode {
// What every peer is told about the picture. The geometry is the *encoded* frame — the
// pipeline does not rotate, so it is the capture geometry — and the rotation is the mount.
let video = mediad::session::Video {
width: args.width,
height: args.height,
width: media.quality.width(),
height: media.quality.height(),
rotate: args.rotate,
};

View File

@ -906,7 +906,7 @@ fn raise_capture_buffers(src: &gst::Element) -> Result<()> {
/// This shells out to `media-ctl` once at startup, because the switch is a subdev ioctl on an
/// entity whose name embeds its I2C bus and address (`m00_b_imx219 2-0010`) and therefore has to
/// be discovered from the topology rather than named. Doing it here rather than in the unit means
/// a `--camera`-less run needs no camera at all.
/// a run with `[media] camera` off needs no camera at all.
fn pin_sensor_mode(fps: u32) -> Result<()> {
let (media, entity) = find_sensor()?;

View File

@ -25,22 +25,22 @@ Wants=robotd.service
[Service]
Type=exec
# `--camera`, so a robot streams its camera rather than a test pattern nobody asked for.
# **No video flags here, on purpose.** What this daemon streams — camera or test pattern, frame
# size, rate, bitrate — is `[media]` in /etc/robot/robotd.toml, edited with `robotctl configure`
# and applied by `systemctl restart mediad`.
#
# **The consequence, stated because it is not obvious:** the control datachannel is bundled with
# the video track, so a robot whose camera is absent — unplugged, or the device-tree overlay not
# enabled — fails to start this service and loses its WebRTC control surface along with its video.
# BLE and the local pad are unaffected. `--camera` can be dropped through a drop-in for a board
# with no camera:
# They were flags on this line. The release installer rewrites this file, so changing one meant a
# systemd drop-in — `systemctl edit --full` puts the edit exactly where it will be overwritten —
# and nobody reaches for a drop-in to answer "why is the video soft?". The config file is the one
# place per-board settings survive an update *and* a rollback (architecture.md §3).
#
# /etc/systemd/system/mediad.service.d/test-pattern.conf
# [Service]
# ExecStart=
# ExecStart=/opt/robot/daemon/current/bin/mediad
#
# A drop-in rather than editing this file, because the release installer rewrites this file and
# `systemctl edit --full` puts the edit exactly where it will be overwritten.
ExecStart=/opt/robot/daemon/current/bin/mediad --camera
# **The consequence of the camera being on by default, stated because it is not obvious:** the
# control datachannel is bundled with the video track, so a robot whose camera is absent —
# unplugged, or the device-tree overlay not enabled — fails to start this service and loses its
# WebRTC control surface along with its video. BLE and the local pad are unaffected. The fix on
# such a board is `camera = false` in `[media]`, which streams a test pattern: the pipeline starts,
# so the control channel exists.
ExecStart=/opt/robot/daemon/current/bin/mediad
# Unprivileged, with two supplementary groups and nothing else.
#
@ -126,8 +126,8 @@ RuntimeDirectory=mediad
# board reaching this state has had both fail, most likely for want of network. The pipeline
# cannot start, `Restart=always` retries every five seconds, and re-running it by hand is
# `sudo /usr/local/sbin/robot-setup-gstreamer`.
# * **A board with no camera**, because `ExecStart` carries `--camera`. Same shape, and the
# drop-in above is the fix.
# * **A board with no camera**, because `[media] camera` defaults to on. Same shape, and
# `sudo robotctl configure` → `media.camera` off is the fix.
#
# `WantedBy=multi-user.target` rather than a `.wants` symlink somebody adds: the release owns which
# units exist and systemd owns whether they are enabled, and a robot whose enablement was done by

View File

@ -33,9 +33,13 @@
//!
//! ## Restart
//!
//! `robotd` reads the file **once at startup** (`robotd-params` docs) — so every change
//! requires a restart, and the exit flow offers one whenever anything was written. The file is
//! root-owned; run as `sudo robotctl configure` to actually write.
//! The daemons read the file **once at startup** (`robotd-params` docs) — so every change
//! requires a restart, and the exit flow offers one whenever anything was written. *Which*
//! daemon is derived from the keys that changed, not assumed: `[media]` is `mediad` reading the
//! same file, and a "restart robotd" offer over a video setting is an edit that reads as having
//! done nothing at all. [`unit_for`] is that mapping and [`units_for`] applies it.
//!
//! The file is root-owned; run as `sudo robotctl configure` to actually write.
use std::collections::BTreeMap;
use std::io::Write as _;
@ -186,6 +190,7 @@ impl Model {
"policy.legs_lowpass" => policy.legs_lowpass.and_then(float),
"policy.ground_pick_period" => float(policy.ground_pick_period),
"policy.ground_pick_action_scale" => float(policy.ground_pick_action_scale),
"media.bitrate" => Some(params.media.bitrate_resolved().to_string()),
"audio.pet_detect" => Some(
params
.audio
@ -206,7 +211,7 @@ impl Model {
let input = input.trim();
let optional = matches!(
entry.kind,
Kind::TriBool | Kind::OptionalFloat | Kind::OptionalPath
Kind::TriBool | Kind::OptionalFloat | Kind::OptionalInteger | Kind::OptionalPath
);
if input == self.default_for(entry.key)
|| (optional && (input == "unset" || input.is_empty()))
@ -220,7 +225,7 @@ impl Model {
"false" | "off" | "no" => false.into(),
_ => return Err(format!("{input:?} is not on/off")),
},
Kind::Integer => input
Kind::Integer | Kind::OptionalInteger => input
.parse::<i64>()
.map(Into::into)
.map_err(|_| format!("{input:?} is not a whole number"))?,
@ -379,16 +384,59 @@ fn writable_hint(path: &Path, e: &std::io::Error) -> String {
}
}
/// Restart `robotd`, reporting rather than hiding the outcome.
pub fn restart_robotd() -> Result<(), String> {
/// Which daemon reads a section, and so which unit a change to it needs restarted.
///
/// `robotd` parses this file for itself; `[media]` is `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. Being wrong here is an edit that appears to do nothing until the next
/// reboot — which is exactly what the restart offer exists to prevent, so it is derived from the
/// keys that changed rather than assumed.
fn unit_for(section: &str) -> &'static str {
match section {
"media" => "mediad",
_ => "robotd",
}
}
/// The units the pending edits require restarting, in start order, without duplicates.
///
/// Empty is a real answer — no edits, nothing to restart — and the caller must not offer a
/// restart for it.
pub fn units_for(model: &Model) -> Vec<&'static str> {
// `robotd` first, because `mediad.service` is `After=robotd.service`: restarting in the
// other order means mediad reconnects to a robotd that is about to go away.
let mut units: Vec<&'static str> = Vec::new();
for key in model.pending.keys() {
let (section, _) = key.split_once('.').expect("registry keys are section.key");
let unit = unit_for(section);
if !units.contains(&unit) {
units.push(unit);
}
}
units.sort_unstable_by_key(|unit| *unit != "robotd");
units
}
/// Restart units, reporting rather than hiding the outcome.
///
/// One `systemctl` invocation for all of them: it starts them in the units' own declared order,
/// which is what `After=` is for, and it means one password prompt rather than one per daemon.
pub fn restart_units(units: &[&str]) -> Result<(), String> {
if units.is_empty() {
return Ok(());
}
let status = std::process::Command::new("systemctl")
.args(["restart", "robotd"])
.arg("restart")
.args(units)
.status()
.map_err(|e| format!("cannot run systemctl: {e}"))?;
if status.success() {
Ok(())
} else {
Err("systemctl restart robotd failed — run it with sudo".to_owned())
Err(format!(
"systemctl restart {} failed — run it with sudo",
units.join(" ")
))
}
}
@ -438,8 +486,9 @@ enum Focus {
},
/// Deciding what to do with the pending edits on the way out.
Confirm,
/// Everything written; offering the restart every change requires.
Restart,
/// Everything written; offering the restart every change requires — of the daemons that
/// actually read what changed, which is not always `robotd`.
Restart { units: Vec<&'static str> },
}
/// Run the editor. Returns once the user has left, with everything saved or discarded.
@ -550,21 +599,26 @@ pub fn run(path: &Path) -> Result<(), String> {
_ => {}
},
Focus::Confirm => match key.code {
KeyCode::Char('y') | KeyCode::Enter => match model.save() {
Ok(()) => {
saved = true;
focus = Focus::Restart;
KeyCode::Char('y') | KeyCode::Enter => {
// Read before the save, which clears `pending` — after it there is nothing
// left to say which daemons were affected.
let units = units_for(&model);
match model.save() {
Ok(()) => {
saved = true;
focus = Focus::Restart { units };
}
Err(e) => {
status = Some(e);
focus = Focus::List;
}
}
Err(e) => {
status = Some(e);
focus = Focus::List;
}
},
}
KeyCode::Char('n') => break Ok(saved),
KeyCode::Esc => focus = Focus::List,
_ => {}
},
Focus::Restart => match key.code {
Focus::Restart { .. } => match key.code {
// The restart itself happens after `ratatui::restore`, outside the alternate
// screen, so systemctl's output is visible.
KeyCode::Char('y') | KeyCode::Enter => break Ok(true),
@ -576,17 +630,21 @@ pub fn run(path: &Path) -> Result<(), String> {
},
}
};
let restart_wanted = matches!(&focus, Focus::Restart);
let restart_wanted = match &focus {
Focus::Restart { units } => units.clone(),
_ => Vec::new(),
};
ratatui::restore();
let saved = outcome?;
if restart_wanted {
println!("restarting robotd…");
restart_robotd()?;
println!("robotd restarted");
if !restart_wanted.is_empty() {
let names = restart_wanted.join(" and ");
println!("restarting {names}");
restart_units(&restart_wanted)?;
println!("{names} restarted");
} else if saved {
println!(
"written to {} — changes apply on the next `systemctl restart robotd`",
"written to {} — changes apply on the next `systemctl restart `",
path.display()
);
}
@ -750,10 +808,18 @@ fn draw(
Line::from("y save · n discard · ESC back"),
]
}
Focus::Restart => vec![
Line::from("written. robotd reads its config once at startup —"),
Line::from("restart it now? y restart · n later"),
],
// Which daemons, by name: `[media]` is read by `mediad`, and "restart it" over a
// change that needs the *other* daemon is how an edit reads as having done nothing.
Focus::Restart { units } => {
let names = units.join(" and ");
let reads = if units.len() == 1 { "reads" } else { "read" };
vec![
Line::from(format!(
"written. {names} {reads} the config once at startup —"
)),
Line::from(format!("restart {names} now? y restart · n later")),
]
}
Focus::List => {
let doc = match items.get(cursor) {
Some(Item::Key(index)) => rows[*index].entry.doc,
@ -958,6 +1024,72 @@ mod tests {
}
}
/// The restart offer names the daemon that reads what changed. `[media]` is read by
/// `mediad`, and offering a `robotd` restart for it is an edit that reads as having done
/// nothing at all until somebody reboots.
#[test]
fn the_restart_offer_names_the_daemon_that_reads_the_change() {
let mut m = model("");
m.edit(entry("media.quality"), "360p30").expect("valid");
assert_eq!(units_for(&m), vec!["mediad"]);
let mut m = model("");
m.edit(entry("control.hz"), "60").expect("valid");
assert_eq!(units_for(&m), vec!["robotd"]);
// Both, and robotd first: mediad.service is After=robotd.service, so the other order
// reconnects mediad to a robotd that is about to go away.
let mut m = model("");
m.edit(entry("media.camera"), "false").expect("valid");
m.edit(entry("audio.enabled"), "false").expect("valid");
assert_eq!(units_for(&m), vec!["robotd", "mediad"]);
// Nothing pending is nothing to restart, and the caller must not offer one.
assert!(units_for(&model("")).is_empty());
}
/// An unset bitrate shows what it will actually stream at, and follows the quality as it
/// is cycled — the reason it is optional rather than a number to keep in step by hand.
#[test]
fn an_unset_bitrate_shows_what_the_quality_resolves_to() {
let mut m = model("");
let bitrate = |m: &Model| {
m.rows()
.into_iter()
.find(|row| row.entry.key == "media.bitrate")
.expect("known")
};
let row = bitrate(&m);
assert_eq!(row.set, None);
assert_eq!(row.resolved.as_deref(), Some("2000000"));
m.edit(entry("media.quality"), "1080p30").expect("valid");
assert_eq!(bitrate(&m).resolved.as_deref(), Some("4000000"));
// Set explicitly, it is a value like any other and no longer a hint.
m.edit(entry("media.bitrate"), "3000000").expect("valid");
let row = bitrate(&m);
assert_eq!(row.set.as_deref(), Some("3000000"));
assert_eq!(row.resolved, None);
// And `unset` puts it back to following the quality rather than pinning the default.
m.edit(entry("media.bitrate"), "unset").expect("valid");
assert_eq!(bitrate(&m).resolved.as_deref(), Some("4000000"));
}
/// The editor's own gate is `Params::load`, so a bitrate in the wrong unit never reaches
/// the disk — the mistake is caught while the file is still the one that works.
#[test]
fn a_bitrate_in_kilobits_is_not_written() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("robotd.toml");
let mut m = Model::load(&path).expect("empty is a model");
m.edit(entry("media.bitrate"), "2000")
.expect("parses as a number");
assert!(m.save().is_err(), "mediad would stream nothing at 2 kb/s");
assert!(!path.exists(), "and nothing was written");
}
/// An inline comment is decor, not data: `hz = 50 # do not touch` is the value 50. This
/// once rode into the value cell and made an at-default key look overridden and annotated.
#[test]
@ -1104,7 +1236,8 @@ mod tests {
"safety",
"chorale",
"theremin",
"audio"
"audio",
"media"
]
);
}

View File

@ -40,6 +40,152 @@ pub struct Params {
pub audio: AudioParams,
pub theremin: ThereminParams,
pub chorale: ChoraleParams,
pub media: MediaParams,
}
/// The one video mode a robot streams in, as a name rather than four numbers.
///
/// **Frame size, rate and a matching bitrate move together or not at all.** They are not
/// independent settings: 1080p at the 2 Mb/s that suits 720p is a smear, and 720p at 6 Mb/s
/// spends a link's headroom on nothing. Offering `width`, `height`, `fps` and `bitrate` as four
/// keys would make every wrong combination of them expressible — including the ones the capture
/// path cannot produce at all, and a pipeline that will not start costs the WebRTC *control*
/// channel along with the video, because the two are bundled (`remote-webrtc.md`).
///
/// So the ladder is fixed, and every rung is 16:9 — the sensor's own aspect. A mode that changed
/// the shape of the picture would be cropping or squashing rather than lowering quality, which is
/// not what anybody picking "smaller" is asking for.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum Quality {
/// The sensor's full frame. The most detail, and the rung least likely to hold 30 fps on
/// this ISP path — [`MediaParams`] says what is measured and what is not.
#[serde(rename = "1080p30")]
Q1080p30,
/// What every measurement in `mediad` was taken at, and the default.
#[default]
#[serde(rename = "720p30")]
Q720p30,
/// Same picture, half the frames: the rung for a link that cannot carry 30.
#[serde(rename = "720p15")]
Q720p15,
/// Small and cheap, for a bad link or a busy CPU.
#[serde(rename = "360p30")]
Q360p30,
}
/// Every mode, in the order an editor cycles them — and the strings the file uses.
///
/// One list, so the registry's choices, the file's values and [`Quality`] itself cannot disagree;
/// [`tests::every_quality_label_round_trips`] pins it to the enum in both directions.
pub const QUALITY_LABELS: &[&str] = &["1080p30", "720p30", "720p15", "360p30"];
impl Quality {
/// The modes, in [`QUALITY_LABELS`] order.
pub const ALL: [Quality; 4] = [
Quality::Q1080p30,
Quality::Q720p30,
Quality::Q720p15,
Quality::Q360p30,
];
/// The name this mode has in the file.
pub fn label(self) -> &'static str {
match self {
Quality::Q1080p30 => "1080p30",
Quality::Q720p30 => "720p30",
Quality::Q720p15 => "720p15",
Quality::Q360p30 => "360p30",
}
}
/// Frame size in pixels. Every rung is 16:9 and every dimension is a multiple of 8, which
/// is what the ISP's scaler and the encoder's macroblocks both want.
pub fn size(self) -> (u32, u32) {
match self {
Quality::Q1080p30 => (1920, 1080),
Quality::Q720p30 | Quality::Q720p15 => (1280, 720),
Quality::Q360p30 => (640, 360),
}
}
pub fn width(self) -> u32 {
self.size().0
}
pub fn height(self) -> u32 {
self.size().1
}
pub fn fps(self) -> u32 {
match self {
Quality::Q720p15 => 15,
_ => 30,
}
}
/// What this mode streams at when `[media] bitrate` is unset — bits per second.
///
/// Scaled with the pixel rate rather than picked per rung: 720p30 is the measured 2 Mb/s
/// `mediad` has always used, and the others are that number times their share of the pixels
/// per second, rounded to something a human can read. Congestion control moves from here, so
/// this is a starting point rather than a cap.
pub fn default_bitrate(self) -> u32 {
match self {
Quality::Q1080p30 => 4_000_000,
Quality::Q720p30 => 2_000_000,
Quality::Q720p15 => 1_000_000,
Quality::Q360p30 => 800_000,
}
}
}
/// `[media]` — what `mediad` streams.
///
/// **These were command-line flags in `mediad.service`, and that is why this section exists.**
/// The release installer rewrites that unit file, so the only supported way to change a flag was
/// a systemd drop-in — a mechanism nobody reaches for to answer "why is the video soft?". Here
/// they are three keys in the file `robotctl configure` already edits.
///
/// `mediad` reads this file at startup and nothing else does anything to it, so a change needs
/// `systemctl restart mediad` — not `robotd`. The editor offers the right one.
///
/// **What is measured and what is not.** 720p30 is the rung every number in `mediad::pipeline`
/// comes from: 29.3 fps off the ISP main path, with the capture format and buffer depth that took
/// three bench sessions to find. The sensor is pinned to a 1920x1080 mode that runs at 30 and the
/// ISP scales down from it, so 1080p30 asks for no scaling at all — what is unmeasured there is
/// whether the capture path and the encoder hold 30 fps at 2.25x the pixels. A rung that does not
/// hold runs slower; it is not a pipeline that fails to start.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct MediaParams {
/// Stream the head camera. `false` streams a test pattern instead, which is what a board
/// with no camera wants: the pipeline starts, so the WebRTC control channel exists.
pub camera: bool,
/// Frame size and rate, as one name. [`Quality`] says why it is one key and not four.
pub quality: Quality,
/// Starting video bitrate, bits per second. Unset follows the quality —
/// [`Quality::default_bitrate`] — which is what almost every robot wants.
pub bitrate: Option<u32>,
}
impl Default for MediaParams {
fn default() -> Self {
Self {
// On, because a robot with a camera is the case, and a board without one shows a
// test pattern rather than nothing only if somebody turns this off.
camera: true,
quality: Quality::default(),
bitrate: None,
}
}
}
impl MediaParams {
/// The bitrate the daemon will actually start at.
pub fn bitrate_resolved(&self) -> u32 {
self.bitrate
.unwrap_or_else(|| self.quality.default_bitrate())
}
}
/// `[chorale]` — several ducks singing one piece.
@ -609,8 +755,27 @@ pub enum ParamsError {
},
#[error("{path}: control.hz must be between 1 and 1000, got {got}")]
Rate { path: String, got: u32 },
#[error(
"{path}: media.bitrate must be between {min} and {max} bits per second, got {got} — \
the unit is bits, so 2 Mb/s is 2000000"
)]
Bitrate {
path: String,
got: u32,
min: u32,
max: u32,
},
}
/// The band `media.bitrate` is accepted in, bits per second.
///
/// The floor is not taste: it is where a typo lands. `bitrate = 2000` is somebody who meant
/// kilobits, and 2 kb/s is a stream that never produces a picture — far better refused at the
/// editor than debugged off a board. The ceiling is what the link and the VPU are for; above it
/// the encoder is being asked for something no robot's wifi will carry.
pub const BITRATE_MIN: u32 = 100_000;
pub const BITRATE_MAX: u32 = 20_000_000;
impl Params {
/// Load from `path`. A missing file at the *default* location is not an error — an
/// unprovisioned board should still come up on defaults rather than refuse to start,
@ -676,6 +841,18 @@ impl Params {
got: self.control.hz,
});
}
// Checked here rather than in `mediad`, so `robotctl configure` refuses to write it:
// the daemon that would choke on this one is not the daemon whose gate the editor runs.
if let Some(bitrate) = self.media.bitrate
&& !(BITRATE_MIN..=BITRATE_MAX).contains(&bitrate)
{
return Err(ParamsError::Bitrate {
path: path.display().to_string(),
got: bitrate,
min: BITRATE_MIN,
max: BITRATE_MAX,
});
}
Ok(())
}
@ -807,6 +984,59 @@ mod tests {
assert_eq!(p.update_gate.stall_periods, 25);
}
/// [`QUALITY_LABELS`] is what the registry offers and what the file may contain, and
/// [`Quality::ALL`] is what the daemon can do — a rung in one and not the other is either a
/// choice the editor writes and `mediad` cannot read, or a mode nobody can select.
#[test]
fn every_quality_label_round_trips() {
assert_eq!(QUALITY_LABELS.len(), Quality::ALL.len());
for (label, quality) in QUALITY_LABELS.iter().zip(Quality::ALL) {
assert_eq!(*label, quality.label());
let parsed: Params =
toml::from_str(&format!("[media]\nquality = \"{label}\"\n")).expect("parses");
assert_eq!(parsed.media.quality, quality);
}
}
/// The starting bitrate follows the picture unless somebody says otherwise — the whole
/// reason `bitrate` is optional rather than a number to keep in step by hand.
#[test]
fn an_unset_bitrate_follows_the_quality() {
let mut media = MediaParams::default();
for quality in Quality::ALL {
media.quality = quality;
assert_eq!(media.bitrate_resolved(), quality.default_bitrate());
}
media.bitrate = Some(3_000_000);
assert_eq!(media.bitrate_resolved(), 3_000_000);
}
/// A bitrate in the wrong unit is the mistake this band exists to catch: `2000` is somebody
/// who meant kilobits, and it would produce a stream with no picture in it.
#[test]
fn a_bitrate_in_kilobits_is_refused() {
let dir = tempfile::tempdir().unwrap();
let path = write(dir.path(), "[media]\nbitrate = 2000\n");
assert!(Params::load(&path, true).is_err());
let path = write(dir.path(), "[media]\nbitrate = 2000000\n");
assert_eq!(
Params::load(&path, true).unwrap().media.bitrate_resolved(),
2_000_000
);
}
/// Today's shipped behaviour, pinned: a robot with no `[media]` section streams its camera
/// at exactly what `mediad`'s flags used to default to. This section changed where those
/// numbers live and must not have changed the numbers.
#[test]
fn the_defaults_are_what_mediad_streamed_before_the_section_existed() {
let media = Params::default().media;
assert!(media.camera, "mediad.service carried --camera");
assert_eq!(media.quality.size(), (1280, 720));
assert_eq!(media.quality.fps(), 30);
assert_eq!(media.bitrate_resolved(), 2_000_000);
}
/// The shipped example must agree with the built-in defaults, or the file documents a
/// robot that does not exist — and an operator reading it would draw wrong conclusions
/// about what their board is actually doing.
@ -838,6 +1068,12 @@ mod tests {
from_file.update_gate.max_consecutive_errors,
built_in.update_gate.max_consecutive_errors
);
assert_eq!(from_file.media.camera, built_in.media.camera);
assert_eq!(from_file.media.quality, built_in.media.quality);
assert_eq!(
from_file.media.bitrate_resolved(),
built_in.media.bitrate_resolved()
);
}
/// The resolved walk-mode defaults are the prototype's **current alpha configuration**

View File

@ -29,6 +29,9 @@ pub enum Kind {
Float,
/// A fractional number, or absent meaning "resolved per mode / measured / default".
OptionalFloat,
/// A whole number, or absent meaning "follows something else" — `media.bitrate` follows
/// the quality. Editors show what it resolves to.
OptionalInteger,
/// One of a fixed set of names.
Choice(&'static [&'static str]),
/// Free text (an ALSA device, a socket path...).
@ -356,6 +359,22 @@ pub const REGISTRY: &[Entry] = &[
Kind::Float,
"…and ends below this one (hysteresis)",
),
// ── [media] ──────────────────────────────────────────────────────────────
feature(
"media.camera",
Kind::Bool,
"Stream the head camera — off is a test pattern, for a board with no camera",
),
feature(
"media.quality",
Kind::Choice(crate::QUALITY_LABELS),
"Video frame size and rate; 720p30 is the rung mediad was measured at",
),
entry(
"media.bitrate",
Kind::OptionalInteger,
"Starting video bitrate, bits/s — unset follows the quality",
),
];
/// The registry entry for a key, if it is one.
@ -424,7 +443,15 @@ mod tests {
.collect();
// A sanity anchor so a serde message change cannot pass vacuously: the sections this
// build certainly has must all be found.
for known in ["bus", "control", "update_gate", "policy", "safety", "audio"] {
for known in [
"bus",
"control",
"update_gate",
"policy",
"safety",
"audio",
"media",
] {
assert!(sections.contains(&known.to_owned()), "{top}");
}
@ -452,7 +479,7 @@ mod tests {
let probe = match entry.kind {
Kind::Bool => format!("[{section}]\n{key} = true\n"),
Kind::TriBool => format!("[{section}]\n{key} = true\n"),
Kind::Integer => format!("[{section}]\n{key} = 1\n"),
Kind::Integer | Kind::OptionalInteger => format!("[{section}]\n{key} = 1\n"),
Kind::Float | Kind::OptionalFloat => format!("[{section}]\n{key} = 0.5\n"),
Kind::Choice(choices) => {
format!("[{section}]\n{key} = \"{}\"\n", choices[0])
@ -525,6 +552,8 @@ mod tests {
"audio.enabled",
"audio.greet",
"audio.pet_detect",
"media.camera",
"media.quality",
]
);
}

View File

@ -763,14 +763,16 @@ install_units() {
# says `After=` them. It is safe to have running with nobody connected — `webrtcsink` listens
# and the pipeline sits at PLAYING.
#
# Allowed to fail like btd and padd, and for a sharper reason than either: `ExecStart` carries
# `--camera`, and it needs the GStreamer stack `setup-gstreamer.sh` installs. A board missing
# either has no WebRTC gateway and is still a robot that updates, walks and pairs.
# Allowed to fail like btd and padd, and for a sharper reason than either: it streams the
# camera by default (`[media] camera` in robotd.toml) and it needs the GStreamer stack
# `setup-gstreamer.sh` installs. A board missing either has no WebRTC gateway and is still a
# robot that updates, walks and pairs.
if [ -f "${UNIT_DIR}/mediad.service" ]; then
enable_unit mediad.service || warn "mediad did not start; check:
journalctl -u mediad -b
A board with no camera, or provisioned before the GStreamer stack existed, is the usual cause:
sudo /usr/local/sbin/robot-setup-gstreamer
sudo /usr/local/sbin/robot-setup-gstreamer # the stack
sudo robotctl configure # [media] camera off, for no camera
The robot works without it — only the camera and the WebRTC console are unavailable."
fi