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:
parent
a08c8b3923
commit
f7de13a227
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -2394,6 +2394,7 @@ dependencies = [
|
||||
"gstreamer-app",
|
||||
"gstreamer-video",
|
||||
"gstreamer-webrtc",
|
||||
"robotd-params",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
99
mediad/src/config.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
|
||||
@ -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()?;
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@ -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**
|
||||
|
||||
@ -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",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user