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.
This commit is contained in:
2026-10-15 23:03:02 +00:00
parent 901d5e9748
commit 6a0d80a381
14 changed files with 542 additions and 27 deletions
+11
View File
@@ -79,6 +79,7 @@ async fn connect_and_serve(config: &Config, fleet: &SharedFleet) -> anyhow::Resu
gateway_id: config.gateway_id.clone(),
farm_id: config.farm_id.clone(),
token: config.gateway_token.clone(),
stream_base_url: config.stream_base_url.clone(),
};
write.send(Message::Text(serde_json::to_string(&hello)?)).await?;
@@ -209,6 +210,16 @@ async fn collect_telemetry(fleet: &SharedFleet) -> Vec<PrinterTelemetry> {
bed_target: report.bed_target_temper,
print_error: report.print_error,
active_alerts: report.hms.as_ref().map(|h| h.len()),
// Derived from the same reported URL `camera::sources` builds
// the go2rtc config from, so a printer is advertised as
// streamable here only if it is also actually being served
// there. Asking `camera` rather than keeping a second copy of
// the rule means the two cannot drift apart.
camera_stream: crate::camera::source_for(
printer,
report.ipcam.as_ref().and_then(|c| c.rtsp_url.as_deref()),
)
.map(|source| source.stream_name),
});
}
+26 -1
View File
@@ -6,7 +6,21 @@ use crate::printer::PrintControl;
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientMessage {
Hello { gateway_id: String, farm_id: String, token: String },
Hello {
gateway_id: String,
farm_id: String,
token: String,
/// Where a browser on this LAN can reach the gateway's camera
/// streams, if the operator has configured one. See
/// `Config::stream_base_url`.
///
/// Reported in the handshake rather than per-printer in telemetry
/// because it is a property of the gateway, and repeating it in
/// every snapshot of every printer would be the same string sent
/// hundreds of times an hour.
#[serde(skip_serializing_if = "Option::is_none")]
stream_base_url: Option<String>,
},
Heartbeat { seq: u64 },
/// One snapshot per printer, on a timer. Sent as a batch rather than a
/// message per printer: a farm has one uplink and many printers, and the
@@ -95,6 +109,17 @@ pub struct PrinterTelemetry {
/// into messages needs Bambu's HMS tables, which we don't have yet.
#[serde(skip_serializing_if = "Option::is_none")]
pub active_alerts: Option<usize>,
/// The go2rtc stream name for this printer's camera, if it has one that
/// can actually be played. Combined with the farm's `stream_base_url`
/// this is everything a browser needs.
///
/// Absent is the common case, not the exception: only the X1/H2 class
/// publishes an RTSP URL. A P1 or A1 reports that it *has* a camera
/// without giving anything to connect to, and this stays `None` for
/// those - a UI should offer a player only when this is present. See
/// `crate::camera`.
#[serde(skip_serializing_if = "Option::is_none")]
pub camera_stream: Option<String>,
}
/// What `continuum-backend` sends back over the same connection.