Generate go2rtc's config from what the printers report

go2rtc has been supervised since before this commit, with nothing to
serve: `CONTINUUM_GO2RTC_CONFIG` pointed at `./go2rtc.yaml`, and that
file did not exist in the repository or anywhere else. A watchdog was
restarting a process that had no cameras configured.

`camera.rs` writes it, from the `rtsp_url` a printer publishes in its own
`print.push_status` `ipcam` block rather than from `printers.toml`. The
URL is already something the machine tells us, so asking an operator to
keep a second copy of it - and to update it by hand when a printer's
address changes - would be asking for drift.

Two tasks, not one: the builder decides what should be served, the
watchdog keeps the thing serving it alive. They meet at one `Notify`,
because go2rtc reads its configuration only at startup, so a changed
config needs a restart. The watchdog now selects the child's exit against
that notification and skips its backoff on a deliberate change.

What this does *not* cover is most printers, and that is the honest
limit. Only the X1/H2 class publishes an RTSP URL - confirmed against two
real machines: the H2C reports
`rtsps://192.168.0.21:322/streaming/live/1`, the P1P reports an `ipcam`
block with `ipcam_dev: "1"` and no URL at all. "A camera exists" and "a
camera you can connect to" are different facts. A P1/A1 needs Bambu's
proprietary port-6000 JPEG stream, which this crate does not speak, so
those printers report no `camera_stream` and a UI should offer a player
only when one is present.

Details worth keeping:

- The config is rendered sorted so an unchanged fleet is byte-identical,
  and on the first pass it is compared against the file on disk - without
  that, every daemon restart rewrites an identical file and drops every
  live viewer.
- Written through a `.tmp` and renamed. The watchdog can restart go2rtc at
  any moment, and a half-written config fails to parse, which takes every
  working stream down rather than one.
- Never logged, and gitignored: the credentials go in the URL because
  that is the only place go2rtc takes them for an RTSP source, so the file
  holds printer access codes.
- A reported URL with no scheme is refused rather than repaired. One bad
  entry makes go2rtc fail to load the whole file.
- No `rtsp:` or `webrtc:` block. The API listener is what serves WebRTC to
  a browser, which is the only consumer here; opening go2rtc's own RTSP
  server would publish every printer's camera on the LAN unauthenticated.
- `stream_base_url` is reported in `hello`, not derived from the listen
  address: `:1984` is a bind address and not something a browser can open.
  Unset means "advertise nothing", which is the right default, because a
  wrong URL makes the UI offer a player that cannot connect. It is a LAN
  address either way - there is no relay, so this works for an operator in
  the workshop and not for anyone outside it.

63 tests, clippy unchanged at the two deliberate variant-size warnings,
`cargo deny check licenses` ok. go2rtc itself stays operator-installed and
is not distributed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 23:03:02 +00:00
co-authored by Claude Opus 5
parent 48672b1f45
commit bda5e95b66
14 changed files with 542 additions and 27 deletions
+324
View File
@@ -0,0 +1,324 @@
//! Turns the printers' own camera URLs into a go2rtc configuration.
//!
//! `go2rtc.rs` has supervised a go2rtc process since before this module
//! existed, and go2rtc has never had anything to serve: nothing wrote its
//! config file. `CONTINUUM_GO2RTC_CONFIG` pointed at `./go2rtc.yaml`, which
//! did not exist in the repository or anywhere else. This writes it.
//!
//! ## Where the URL comes from
//!
//! Not from `printers.toml`. A printer reports its own camera URL in
//! `print.push_status`'s `ipcam` block, so asking an operator to copy it into
//! a config file would be asking them to keep a second copy of something the
//! machine already tells us - and to update it by hand when the printer's
//! address changes.
//!
//! ## What does not work, which is most printers
//!
//! Only the X1/H2 class publishes an `rtsp_url`. Confirmed against two real
//! machines: an H2C reports `rtsps://<ip>:322/streaming/live/1`, and a P1P
//! reports an `ipcam` block with no URL in it at all. A P1/A1 camera is
//! reachable only over Bambu's proprietary port-6000 JPEG stream, which this
//! crate does not speak, so those printers get no stream here and say so
//! rather than advertising one that will not play.
//!
//! That is also why this module reads the *reported* URL and never builds one
//! from the printer's IP: the fact that a printer has a camera and the fact
//! that the camera has an RTSP endpoint are different facts, and only the
//! second one is useful.
use std::collections::BTreeMap;
use std::time::Duration;
use tokio::sync::Notify;
use tracing::{info, warn};
use crate::printer::{GenericPrinter, PrinterHandle};
use crate::uplink::SharedFleet;
/// How often to rebuild the configuration from what the printers report.
///
/// A camera URL changes when a printer's address changes, which is a DHCP
/// lease event, not something that needs watching closely. The first useful
/// rebuild is the one a few seconds after startup, once the first reports
/// have arrived - before that the fleet has nothing to say.
const REBUILD_INTERVAL: Duration = Duration::from_secs(30);
/// The username every Bambu uses for its own services - MQTT, FTPS, and the
/// camera. Not a guess: it is what `bambu_mqtt` and `bambu_ftps` already
/// authenticate with.
const CAMERA_USER: &str = "bblp";
/// One printer's playable stream.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CameraSource {
/// Our own printer id, which doubles as the go2rtc stream name. Using it
/// for both means the cloud can name a stream without the gateway having
/// to report a mapping: `camera_stream` in telemetry is this id.
pub stream_name: String,
/// The full source URL, credentials included.
pub url: String,
}
/// Builds the source URL for one printer, or `None` if it has no stream.
///
/// The credentials go in the URL because that is the only place go2rtc takes
/// them for an RTSP source. The consequence is that the printer's access code
/// ends up in a file on disk - which is not a new exposure (`printers.toml`
/// already holds it, and that file is gitignored for this reason) but it does
/// mean the generated config deserves the same treatment, and must never be
/// logged.
pub fn source_for(printer: &PrinterHandle, report_url: Option<&str>) -> Option<CameraSource> {
let PrinterHandle::Bambu(bambu) = printer else {
// Nothing else has a camera path here yet. A Prusa's is a separate
// product, and a Klipper camera is whatever the user configured.
return None;
};
let url = report_url?;
if url.is_empty() {
return None;
}
// `rtsps://1.2.3.4:322/streaming/live/1` -> `rtsps://bblp:code@1.2.3.4:322/...`
let with_credentials = match url.split_once("://") {
Some((scheme, rest)) => {
// A URL that already carries credentials is left alone rather
// than having a second set spliced in.
if rest.contains('@') {
url.to_string()
} else {
format!("{scheme}://{CAMERA_USER}:{}@{rest}", bambu.access_code)
}
}
// Not a URL shape this understands. Refused rather than guessed at:
// a malformed source makes go2rtc fail to load the whole file, which
// would take every *working* stream down with it.
None => {
warn!(
printer = %bambu.display_name(),
"the printer reported a camera URL with no scheme; ignoring it"
);
return None;
}
};
Some(CameraSource {
stream_name: printer.base().id.clone(),
url: with_credentials,
})
}
/// Renders a go2rtc configuration.
///
/// Hand-written YAML rather than a serializer, for the same reason the
/// `.3mf` reader is hand-rolled: it is a fixed, tiny shape, and a dependency
/// for ten lines of output is a dependency to license and audit. The values
/// are quoted and the keys are our own printer ids, which `fleet.rs`
/// constrains.
///
/// `listen` is explicit rather than left to go2rtc's defaults, because the
/// cloud is told this address and a default that changes between go2rtc
/// versions would change where the stream lives without anything saying so.
pub fn render_config(api_listen: &str, sources: &[CameraSource]) -> String {
let mut out = String::from(
"# Generated by continuum-proxy. Do not edit: it is rewritten from\n\
# what the printers themselves report, so an edit here is lost on\n\
# the next rebuild. Contains printer access codes - treat it as a\n\
# secret and keep it out of version control.\n",
);
// Sorted, so an unchanged fleet renders byte-identical output and the
// change detection below does not restart go2rtc on every rebuild.
let ordered: BTreeMap<&str, &str> = sources
.iter()
.map(|s| (s.stream_name.as_str(), s.url.as_str()))
.collect();
out.push_str("streams:\n");
if ordered.is_empty() {
// An empty mapping, written explicitly. `streams:` with nothing
// under it is a null in YAML, and go2rtc is entitled to treat that
// differently from an empty map.
out.push_str(" {}\n");
}
for (name, url) in &ordered {
out.push_str(&format!(" {name}: \"{url}\"\n"));
}
out.push_str(&format!("api:\n listen: \"{api_listen}\"\n"));
// No `rtsp:` or `webrtc:` block: the API listener is what serves WebRTC
// to a browser, which is the only consumer here. Opening go2rtc's own
// RTSP server would publish every printer's camera on the LAN
// unauthenticated, which is not something to do by default.
out
}
/// Collects every printer that currently has a playable stream.
pub async fn sources(fleet: &SharedFleet) -> Vec<CameraSource> {
let fleet = fleet.lock().await;
let mut found = Vec::new();
for printer in fleet.iter() {
let report_url = printer
.print_status()
.await
.and_then(|report| report.ipcam)
.and_then(|ipcam| ipcam.rtsp_url);
if let Some(source) = source_for(printer, report_url.as_deref()) {
found.push(source);
}
}
found
}
/// Rewrites the config whenever what the printers report changes, and asks
/// the watchdog to restart go2rtc when it does.
///
/// Restart rather than reload: go2rtc can reload some of its configuration
/// over its own API, but a restart is the behaviour we can be sure of, and
/// the watchdog was already built to restart it. A stream interruption at the
/// moment a printer's camera URL changes is acceptable; a stream that is
/// silently serving the old address is not.
pub async fn run(
fleet: SharedFleet,
config_path: String,
api_listen: String,
restart: std::sync::Arc<Notify>,
) {
let mut ticker = tokio::time::interval(REBUILD_INTERVAL);
let mut last_written: Option<String> = None;
loop {
ticker.tick().await;
let desired = render_config(&api_listen, &sources(&fleet).await);
if last_written.as_deref() == Some(desired.as_str()) {
continue;
}
// Compare against the file too, not just our own memory of it: on
// startup `last_written` is `None`, and rewriting an identical file
// would restart go2rtc - and therefore drop every live viewer - for
// no reason, every time the daemon restarts.
if last_written.is_none()
&& let Ok(existing) = tokio::fs::read_to_string(&config_path).await
&& existing == desired
{
last_written = Some(desired);
continue;
}
match write_atomically(&config_path, &desired).await {
Ok(()) => {
let count = desired.matches("\n ").count().saturating_sub(1);
// The content is never logged: it holds access codes.
info!(path = %config_path, streams = count, "wrote go2rtc config, restarting it");
last_written = Some(desired);
restart.notify_one();
}
Err(err) => warn!(path = %config_path, ?err, "could not write the go2rtc config"),
}
}
}
/// Writes through a temporary file and renames.
///
/// go2rtc may be reading this file at any moment - the watchdog restarts it
/// on a five-second loop - and a partially written config is a config that
/// fails to parse, which takes every working stream down. A rename is atomic
/// on the same filesystem, so a reader sees either the old file or the new
/// one.
async fn write_atomically(path: &str, contents: &str) -> std::io::Result<()> {
let temp = format!("{path}.tmp");
tokio::fs::write(&temp, contents).await?;
tokio::fs::rename(&temp, path).await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::printer::{BambuPrinter, BambuProtocolPreference, BambuTls};
fn printer(id: &str) -> PrinterHandle {
PrinterHandle::Bambu(BambuPrinter::new(
id,
"Test Printer",
"192.168.0.21",
"12345678",
"SN123",
BambuTls::Insecure,
BambuProtocolPreference::Auto,
))
}
/// The URL shape confirmed from a real H2C's `ipcam` block.
#[test]
fn a_reported_rtsps_url_gets_the_printer_credentials_spliced_in() {
let source = source_for(&printer("h2c"), Some("rtsps://192.168.0.21:322/streaming/live/1"))
.expect("an rtsps URL is playable");
assert_eq!(source.stream_name, "h2c");
assert_eq!(source.url, "rtsps://bblp:12345678@192.168.0.21:322/streaming/live/1");
}
/// What a P1P reports: an `ipcam` block that says a camera exists without
/// giving anything to connect to. Advertising a stream here would make a
/// UI offer a player that can never load.
#[test]
fn a_printer_that_reports_no_url_has_no_stream() {
assert_eq!(source_for(&printer("p1p"), None), None);
assert_eq!(source_for(&printer("p1p"), Some("")), None);
}
/// Splicing a second set of credentials into a URL that already has some
/// would produce `bblp:code@user:pass@host`, which is not a host.
#[test]
fn a_url_that_already_carries_credentials_is_left_alone() {
let source = source_for(&printer("h2c"), Some("rtsps://someone:else@10.0.0.5:322/live"))
.expect("still playable");
assert_eq!(source.url, "rtsps://someone:else@10.0.0.5:322/live");
}
/// Refused rather than guessed at, because one bad entry makes go2rtc
/// fail to load the whole file and takes every working stream with it.
#[test]
fn a_url_with_no_scheme_is_refused_rather_than_repaired() {
assert_eq!(source_for(&printer("h2c"), Some("192.168.0.21:322/streaming/live/1")), None);
}
/// `streams:` with nothing under it is YAML for null, not for an empty
/// map, and a gateway with no streamable camera is the normal state of a
/// P1-only farm - so this is the output that must be valid, not a corner.
#[test]
fn an_empty_fleet_renders_an_explicit_empty_map() {
let config = render_config(":1984", &[]);
assert!(config.contains("streams:\n {}\n"), "got:\n{config}");
assert!(config.contains("listen: \":1984\""), "got:\n{config}");
}
/// The change detection in `run` compares rendered output, so an
/// unchanged fleet in a different order must render identically or
/// go2rtc gets restarted - dropping every live viewer - every 30s.
#[test]
fn the_same_fleet_in_a_different_order_renders_identically() {
let a = CameraSource { stream_name: "aaa".into(), url: "rtsps://one".into() };
let b = CameraSource { stream_name: "zzz".into(), url: "rtsps://two".into() };
assert_eq!(
render_config(":1984", &[a.clone(), b.clone()]),
render_config(":1984", &[b, a]),
);
}
/// Opening go2rtc's own RTSP or WebRTC listener would publish every
/// printer's camera on the LAN unauthenticated. The absence is the
/// decision, so it is worth a test that fails if someone adds one.
#[test]
fn the_generated_config_opens_no_server_but_the_api() {
let source = CameraSource { stream_name: "h2c".into(), url: "rtsps://x".into() };
let config = render_config(":1984", &[source]);
assert!(!config.contains("rtsp:"), "got:\n{config}");
assert!(!config.contains("webrtc:"), "got:\n{config}");
}
}