Files
continuum-proxy/src/printer/mod.rs
T
octoturge 305c41993d Update docs and prune examples superseded by real functionality
README/certs/README/.env.example/printers.example.toml were all still
describing this as a learning-stage stub with no real printer protocol
clients - rewritten to reflect the real Bambu MQTT client, cert
auto-pinning, and marlin serial connection that now exist.

- Remove examples/fetch_bambu_cert.rs: its manual fetch-and-save workflow
  is now what BambuPrinter::connect()'s ensure_trusted does automatically
  on every connect, so it has no remaining use case.
- Fix examples/printer_polymorphism.rs, which no longer compiled -
  dispatch_gcode was replaced by real methods earlier this session;
  swapped in print_status() as the vendor-only-extension example.
- Remove Config::bambu_ca_cert_overrides/bambu_require_valid_cert and the
  .env vars documenting them - confirmed dead (nothing read them),
  fully superseded by printers.toml's per-printer ca_cert_path/
  require_valid_cert plus automatic trust-on-first-connect pinning.
- Fix stale doc comments in printer/mod.rs and printer/bambu.rs that
  described certificate trust and MQTT as not built yet, or referencing
  the now-removed example.

No behavior change in the daemon itself - verified with cargo build
--examples and a live run of printer_polymorphism and auto_connect_bambu
against the real P1P/H2C printers.
2026-09-07 16:17:15 +00:00

216 lines
9.8 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, `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_mqtt;
mod klipper;
mod marlin_serial;
mod prusa;
pub use bambu::{BambuPrinter, BambuProtocolPreference, BambuProtocolVersion, BambuTls};
pub use bambu_commands::{
AmsState, AmsTray, AmsUnit, BambuReport, BambuRequest, CommandEnvelope, EmptyTray, GetVersionRequest,
GetVersionResponse, LoadedTray, PrintReport, PushallRequest, 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.
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.
///
/// `cargo check` will warn about `async fn` in a public trait here.
/// That's about a more advanced concern (whether the resulting work can
/// safely move across OS threads) that only matters once this is wired
/// into the multi-threaded daemon - safe to ignore for now.
async fn connect(&mut self) -> Result<(), PrinterError>;
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>;
/// 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 closed set of printer kinds this fleet can talk to.
pub enum PrinterHandle {
Bambu(BambuPrinter),
PrusaLink(PrusaLinkPrinter),
PrusaSerial(PrusaSerialPrinter),
Klipper(KlipperPrinter),
}
// 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 {
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(())
}