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>
102 lines
4.5 KiB
Rust
102 lines
4.5 KiB
Rust
// Everything but go2rtc comes from the library crate (src/lib.rs) rather than
|
|
// being re-declared here. Declaring `mod printer;` in both places compiles the
|
|
// same files twice, once per crate, and the binary copy warned about every
|
|
// `pub use` re-export and every method only examples or tests call - "unused"
|
|
// is true inside a binary, where nothing is externally reachable, and wrong
|
|
// about the library's actual public API. One copy, no false warnings.
|
|
mod go2rtc;
|
|
|
|
use tracing_subscriber::EnvFilter;
|
|
|
|
use continuum_proxy::config::Config;
|
|
use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
|
|
use continuum_proxy::{camera, completion, fleet, uplink};
|
|
|
|
#[tokio::main]
|
|
async fn main() -> anyhow::Result<()> {
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
|
|
.init();
|
|
|
|
dotenvy::dotenv().ok();
|
|
let config = Config::from_env()?;
|
|
|
|
tracing::info!(gateway_id = %config.gateway_id, farm_id = %config.farm_id, "starting continuum-proxy");
|
|
|
|
// Loaded and connected here, once, at startup - nothing yet re-reads
|
|
// printers.toml on a running daemon, so adding or removing a printer
|
|
// still means a restart.
|
|
let mut fleet = fleet::load(std::path::Path::new(&config.printers_path))?;
|
|
connect_fleet(&mut fleet).await;
|
|
|
|
// The fleet is now shared with the uplink rather than just parked here
|
|
// keeping connections alive: the uplink reads each printer's status to
|
|
// send telemetry up, and looks printers up by id to dispatch commands
|
|
// down. `Arc<Mutex<..>>` is how two tasks share one mutable thing -
|
|
// see `uplink::SharedFleet` for why that type and what it costs.
|
|
let fleet: uplink::SharedFleet = std::sync::Arc::new(tokio::sync::Mutex::new(fleet));
|
|
|
|
// 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`.
|
|
//
|
|
// 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(),
|
|
restart_go2rtc.clone(),
|
|
) => {}
|
|
_ = camera::run(
|
|
fleet.clone(),
|
|
config.go2rtc_config.clone(),
|
|
config.go2rtc_api_listen.clone(),
|
|
restart_go2rtc.clone(),
|
|
) => {}
|
|
_ = completion::run(fleet.clone()) => {}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Connects every configured printer concurrently (not one at a time -
|
|
/// Bambu's auto-detect fallback alone can take several seconds per
|
|
/// printer if it has to time out on MQTT v5 before trying v3.1.1;
|
|
/// serially, a fleet of twenty would take twenty times as long to start).
|
|
/// Logs success/failure per printer instead of failing startup over one -
|
|
/// a farm shouldn't refuse to start because one printer is unplugged.
|
|
async fn connect_fleet(fleet: &mut [PrinterHandle]) {
|
|
let results =
|
|
futures_util::future::join_all(fleet.iter_mut().map(|printer| async {
|
|
let name = printer.display_name();
|
|
(name, printer.connect().await)
|
|
}))
|
|
.await;
|
|
|
|
for (name, result) in results {
|
|
match result {
|
|
Ok(()) => tracing::info!(printer = %name, "connected"),
|
|
Err(err) => tracing::warn!(printer = %name, %err, "failed to connect"),
|
|
}
|
|
}
|
|
}
|