Files
continuum-proxy/src/printer/mod.rs
T
octoturge 6a0d80a381 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.
2026-10-15 23:03:02 +00:00

337 lines
16 KiB
Rust

//! Printer hierarchy - the Rust answer to
//! `GenericPrinter -> BambuV1/BambuV2/PrusaLink/PrusaSerial/Klipper`-style
//! inheritance, since Rust structs can't `extend` each other.
//!
//! Bambu is a single `BambuPrinter` type, not a per-version split: which
//! MQTT/BambuMQTT protocol generation (v1 vs v2) a given printer uses is
//! something `connect()` discovers (or is told to force), not something
//! config picks a *type* for - see `bambu.rs`'s doc comment.
//!
//! Three pieces, each doing one job an OOP base class would normally do:
//!
//! 1. `PrinterBase` - a plain struct holding the shared *fields*. Every
//! printer struct below *has* one of these (composition) instead of
//! *extending* one.
//! 2. `GenericPrinter` - a trait holding the shared *behavior*. `connect()`
//! has no default body, so every printer type is forced to write its
//! own - that's what "overriding" looks like when there's no inherited
//! body to override in the first place.
//! 3. `PrinterHandle` - an enum listing the closed set of printer kinds.
//! Stands in for "any subclass of GenericPrinter".
//!
//! Run `cargo run --example printer_polymorphism` to see it in action.
//!
//! Real MQTT is built now - `bambu.rs`/`bambu_mqtt.rs` for Bambu's
//! protocol, `bambu_ftps.rs` for getting files onto a Bambu printer,
//! `marlin_serial.rs` for a real Marlin serial connection - see each
//! module's own doc comment. PrusaLink/Klipper's HTTP clients are still
//! stubs; that's the next piece to build past this module.
mod bambu;
mod bambu_commands;
mod bambu_ftps;
mod bambu_mqtt;
mod klipper;
mod marlin_serial;
mod prusa;
// Reading a `.3mf` well enough to know which filament a plate wants. Kept
// free of printer types so it is testable without a printer.
mod project_file;
pub use project_file::{
map_to_trays, normalise_color, plate_filaments, AvailableTray, ProjectFilament,
ProjectFileError,
};
pub use bambu::{
BambuPrinter, BambuProtocolPreference, BambuProtocolVersion, BambuTls, FilamentSource,
LightState, CHAMBER_LIGHT,
};
pub use bambu_commands::{
AmsState, AmsTray, AmsUnit, BambuReport, BambuRequest, CommandEnvelope, EmptyTray, GetVersionRequest,
GetVersionResponse, Ipcam, LightReport, LoadedTray, ModuleInfo, PrintReport, PushallRequest, VtTray,
XcamSettings,
};
pub use klipper::KlipperPrinter;
pub use prusa::{PrusaLinkPrinter, PrusaSerialPrinter};
/// A tiny error type: just a message. `String` would work too - this exists
/// mainly so `?` has something concrete to convert into everywhere.
#[derive(Debug, thiserror::Error)]
#[error("{0}")]
pub struct PrinterError(pub String);
/// Fields every printer has, regardless of vendor.
#[derive(Debug, Clone)]
pub struct PrinterBase {
pub id: String,
pub name: String,
pub host: String,
}
/// The shared interface - think `interface GenericPrinter` (Java/TS) or an
/// ABC (Python). Every vendor's struct implements this.
///
/// The `allow` is a considered decision, not a brush-off. `async fn` in a
/// public trait means callers cannot require the returned futures to be
/// `Send`, so such a future cannot be moved to another OS thread. That is
/// fine here, and checked: the daemon does run on tokio's multi-threaded
/// runtime, but every printer is reached through one `Arc<Mutex<Vec<..>>>`
/// from the uplink's own task (see `uplink::SharedFleet`), so these futures
/// are created and polled in the same place. Revisit if a printer ever gets
/// its own task - the fix is to desugar to
/// `fn connect(&mut self) -> impl Future<Output = ..> + Send`, which cannot
/// be done without changing every implementation, which is why it is not
/// done pre-emptively.
#[allow(async_fn_in_trait)]
pub trait GenericPrinter {
/// Read-only access to the shared fields.
fn base(&self) -> &PrinterBase;
/// REQUIRED, no default body: every vendor speaks a different protocol,
/// so there's nothing sensible to share here. Each `impl` in
/// `bambu.rs`/`prusa.rs`/`klipper.rs` provides a totally different
/// body - that's the override.
async fn connect(&mut self) -> Result<(), PrinterError>;
/// Sends one already-encoded message by whatever transport this vendor
/// uses. Lower level than the methods below and mostly a building block
/// for them - `bambu.rs` publishes MQTT with it; the stubs only log.
async fn publish(&mut self, payload: String) -> Result<(), PrinterError>;
/// REQUIRED, no default body: `percent` is 0-100, but how it actually
/// gets sent - an MQTT gcode-injection command, a REST call, raw bytes
/// on a serial port - is different for every vendor, same as
/// `connect()`.
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError>;
/// REQUIRED, no default body: takes the *whole file* already in memory
/// (`data`), not a path - whatever called this already had to download
/// it from the S3 URL the cloud handed over, so there'd be nothing on
/// disk to point a path at anyway. Each vendor does something quite
/// different with `data`: Bambu/PrusaLink/Klipper each do one bulk
/// transfer to storage the printer's own firmware owns; PrusaSerial
/// has no such storage at all - see that impl's doc comment.
async fn upload_project_file(&mut self, file_name: String, data: Vec<u8>) -> Result<(), PrinterError>;
/// REQUIRED, no default body: sends exactly *one* gcode line, using
/// whatever single-command mechanism this vendor has (an MQTT
/// `gcode_line` publish for Bambu, an HTTP command-injection endpoint
/// for PrusaLink/Klipper, a raw write over the port for PrusaSerial).
/// `upload_gcode` below is the generic driver that calls this once per
/// line for a whole file - this method is where each vendor's actual
/// difference lives, same division of labor as every other method here.
async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError>;
/// REQUIRED, no default body: pause, resume or cancel the print that is
/// already running. Each vendor has one mechanism for all three - an
/// MQTT `print` command, a PrusaLink job endpoint, a Klipper macro, a
/// Marlin gcode word - so they arrive here as one call taking which.
///
/// There is deliberately no "start a print" counterpart on this trait.
/// Starting one needs a file that is already on the printer, which is
/// `upload_project_file`'s job, and the two have very different
/// consequences; see `BambuPrinter::start_uploaded_file`.
async fn control_print(&mut self, action: PrintControl) -> Result<(), PrinterError>;
/// OPTIONAL: a default body every printer gets for free unless it
/// writes its own. None of ours do below, so all three vendors share
/// this exact implementation - the trait-method equivalent of
/// inheriting a base-class method unchanged.
fn display_name(&self) -> String {
format!("{} ({})", self.base().name, self.base().id)
}
}
/// M106's `S` parameter is a raw 0-255 PWM value, not a percentage. Shared
/// by anything that ultimately sends gcode (Bambu, PrusaLink,
/// PrusaSerial) - *not* a trait default, since Klipper's hypothetical
/// native fan API below takes a percent directly and has no use for this at
/// all. Private, but still visible to bambu.rs/prusa.rs: Rust's module
/// privacy reaches into child modules, not just the exact file it's defined
/// in.
fn percent_to_pwm(percent: u8) -> u8 {
((percent.min(100) as u16 * 255) / 100) as u8
}
/// The three things you can do to a print that is already running.
///
/// One enum and one trait method rather than three separate methods: every
/// vendor implements all three the same way it implements any of them (one
/// MQTT command for Bambu, one REST call for PrusaLink, one macro for
/// Klipper, one gcode word for Marlin), so splitting them would triple the
/// delegation boilerplate without separating anything that differs.
///
/// `Stop` is Bambu's own word for it and the one the schemas use. It
/// cancels the print - it is not a pause you can come back from.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PrintControl {
Pause,
Resume,
Stop,
}
impl PrintControl {
/// The vendor command name. Bambu's `print` category happens to use
/// exactly these three words, so this doubles as the MQTT command.
pub fn as_str(self) -> &'static str {
match self {
Self::Pause => "pause",
Self::Resume => "resume",
Self::Stop => "stop",
}
}
}
/// The closed set of printer kinds this fleet can talk to.
pub enum PrinterHandle {
Bambu(BambuPrinter),
PrusaLink(PrusaLinkPrinter),
PrusaSerial(PrusaSerialPrinter),
Klipper(KlipperPrinter),
}
impl PrinterHandle {
/// The latest status this process has for the printer, if it has one.
///
/// An inherent method rather than a `GenericPrinter` trait method,
/// because right now only Bambu has a real status path - the other three
/// return `None` not because they have nothing to say but because their
/// protocol clients are still stubs. Putting it on the trait would make
/// every vendor claim to implement something three of them don't. Move it
/// onto the trait once a second vendor can genuinely answer.
pub async fn print_status(&self) -> Option<PrintReport> {
match self {
PrinterHandle::Bambu(p) => p.print_status().await,
PrinterHandle::PrusaLink(_) | PrinterHandle::PrusaSerial(_) | PrinterHandle::Klipper(_) => None,
}
}
/// Asks the printer to report its full state, for the printers that only
/// report when asked.
///
/// This exists because of one specific vendor quirk that should not leak
/// any further than this: a Bambu on protocol v1 pushes only *partial*
/// `print.push_status` objects unprompted - often a single temperature -
/// and sends a complete one only in response to `pushing.pushall`. Without
/// a periodic call here, `print_status()` on a v1 printer stays stuck on
/// whatever fragments it happened to volunteer. Found exactly that way: a
/// live P1P reached the cloud as a row of nulls with one real bed
/// temperature in it, while a v2 H2C beside it was fully populated.
///
/// Nothing arrives by the time this returns - the refreshed status comes
/// back over MQTT as an ordinary push some time later, so a caller that
/// refreshes and immediately reads gets the *previous* refresh's data. On
/// a timer that is simply a one-tick lag, which is why this is
/// fire-and-forget rather than something to await a result from.
///
/// A no-op for everyone else: `BambuPrinter::pushall` already no-ops on
/// v2 (which pushes complete objects unprompted), and the other three
/// vendors are still stubs. Same reasoning as `print_status` for why this
/// is inherent rather than a `GenericPrinter` method.
pub async fn refresh_status(&self) -> Result<(), PrinterError> {
match self {
PrinterHandle::Bambu(p) => p.pushall().await,
PrinterHandle::PrusaLink(_) | PrinterHandle::PrusaSerial(_) | PrinterHandle::Klipper(_) => Ok(()),
}
}
}
// Hand-written delegation: each trait method just matches on the variant
// and forwards to that variant's own implementation. This one block is the
// only "boilerplate tax" for not having inheritance - everything else reads
// like normal code.
impl GenericPrinter for PrinterHandle {
async fn control_print(&mut self, action: PrintControl) -> Result<(), PrinterError> {
match self {
PrinterHandle::Bambu(p) => p.control_print(action).await,
PrinterHandle::PrusaLink(p) => p.control_print(action).await,
PrinterHandle::PrusaSerial(p) => p.control_print(action).await,
PrinterHandle::Klipper(p) => p.control_print(action).await,
}
}
fn base(&self) -> &PrinterBase {
match self {
PrinterHandle::Bambu(p) => p.base(),
PrinterHandle::PrusaLink(p) => p.base(),
PrinterHandle::PrusaSerial(p) => p.base(),
PrinterHandle::Klipper(p) => p.base(),
}
}
async fn connect(&mut self) -> Result<(), PrinterError> {
match self {
PrinterHandle::Bambu(p) => p.connect().await,
PrinterHandle::PrusaLink(p) => p.connect().await,
PrinterHandle::PrusaSerial(p) => p.connect().await,
PrinterHandle::Klipper(p) => p.connect().await,
}
}
async fn publish(&mut self, payload: String) -> Result<(), PrinterError> {
match self {
PrinterHandle::Bambu(p) => p.publish(payload).await,
PrinterHandle::PrusaLink(p) => p.publish(payload).await,
PrinterHandle::PrusaSerial(p) => p.publish(payload).await,
PrinterHandle::Klipper(p) => p.publish(payload).await,
}
}
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> {
match self {
PrinterHandle::Bambu(p) => p.set_fan_speed(percent).await,
PrinterHandle::PrusaLink(p) => p.set_fan_speed(percent).await,
PrinterHandle::PrusaSerial(p) => p.set_fan_speed(percent).await,
PrinterHandle::Klipper(p) => p.set_fan_speed(percent).await,
}
}
async fn upload_project_file(&mut self, file_name: String, data: Vec<u8>) -> Result<(), PrinterError> {
match self {
PrinterHandle::Bambu(p) => p.upload_project_file(file_name, data).await,
PrinterHandle::PrusaLink(p) => p.upload_project_file(file_name, data).await,
PrinterHandle::PrusaSerial(p) => p.upload_project_file(file_name, data).await,
PrinterHandle::Klipper(p) => p.upload_project_file(file_name, data).await,
}
}
async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError> {
match self {
PrinterHandle::Bambu(p) => p.send_gcode_line(gcode_line).await,
PrinterHandle::PrusaLink(p) => p.send_gcode_line(gcode_line).await,
PrinterHandle::PrusaSerial(p) => p.send_gcode_line(gcode_line).await,
PrinterHandle::Klipper(p) => p.send_gcode_line(gcode_line).await,
}
}
}
/// Splits `gcode` into lines and sends each one through `printer`'s own
/// `send_gcode_line` - works for *any* vendor, not just serial ones, since
/// Bambu/PrusaLink/Klipper each have their own single-line injection
/// mechanism too (see each `send_gcode_line` impl). They just don't use
/// this for `upload_project_file` - they have real file storage to bulk-
/// upload to instead, which is a different, faster path than sending a
/// whole file one line at a time.
///
/// `P: GenericPrinter` makes this a genuine Rust generic: one function
/// body, compiled separately for whichever concrete printer type you call
/// it with - like a TS function typed `<P extends GenericPrinter>`, except
/// Rust generates real, separate machine code per type instead of erasing
/// the type at compile time. Call it with any of the five printer structs,
/// or with `&mut PrinterHandle` itself (it implements `GenericPrinter` too,
/// via the delegation above).
pub async fn upload_gcode<P: GenericPrinter>(printer: &mut P, gcode: &str) -> Result<(), PrinterError> {
for line in gcode.lines() {
let line = line.trim();
// Blank lines and full-line comments have nothing to send.
if line.is_empty() || line.starts_with(';') {
continue;
}
printer.send_gcode_line(line.to_string()).await?;
}
Ok(())
}