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
+28 -10
View File
@@ -10,7 +10,7 @@ use tracing_subscriber::EnvFilter;
use continuum_proxy::config::Config;
use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
use continuum_proxy::{completion, fleet, uplink};
use continuum_proxy::{camera, completion, fleet, uplink};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
@@ -36,24 +36,42 @@ async fn main() -> anyhow::Result<()> {
// see `uplink::SharedFleet` for why that type and what it costs.
let fleet: uplink::SharedFleet = std::sync::Arc::new(tokio::sync::Mutex::new(fleet));
// Three long-running tasks: the cloud uplink, the go2rtc
// camera-restreaming watchdog, and the print-completion watcher that
// blinks a printer's lights when it finishes. Every printer's MQTT
// session is a fourth thing running concurrently, but implicitly - each
// one has its own background poll task spawned by `connect()`, kept
// alive by the fleet above. More will join these later (printer
// discovery, the plate changer).
// Four long-running tasks: the cloud uplink, the go2rtc subprocess
// watchdog, the camera-config builder that tells it what to serve, and
// the print-completion watcher that blinks a printer's lights when it
// finishes. Every printer's MQTT session is a fifth thing running
// concurrently, but implicitly - each one has its own background poll
// task spawned by `connect()`, kept alive by the fleet above. More will
// join these later (printer discovery, the plate changer).
//
// The completion watcher is deliberately its own task rather than part
// of the uplink: it must keep working with the cloud connection down,
// because the person it is for is standing next to the printer. See
// `completion::run`.
//
// `tokio::select!` stops as soon as any returns; all three loop forever,
// The camera builder and the watchdog are two tasks and not one because
// they answer different questions - what should be served, and keeping
// the thing that serves it alive. They meet at one `Notify`: the builder
// rings it after writing a new config, because go2rtc only reads its
// configuration at startup.
//
// `tokio::select!` stops as soon as any returns; all four loop forever,
// so in practice this runs until the process is killed.
let restart_go2rtc = std::sync::Arc::new(tokio::sync::Notify::new());
tokio::select! {
_ = uplink::run(config.clone(), fleet.clone()) => {}
_ = go2rtc::watchdog(config.go2rtc_bin.clone(), config.go2rtc_config.clone()) => {}
_ = go2rtc::watchdog(
config.go2rtc_bin.clone(),
config.go2rtc_config.clone(),
restart_go2rtc.clone(),
) => {}
_ = camera::run(
fleet.clone(),
config.go2rtc_config.clone(),
config.go2rtc_api_listen.clone(),
restart_go2rtc.clone(),
) => {}
_ = completion::run(fleet.clone()) => {}
}