Files
continuum-proxy/src/printer/mod.rs
T
octoturgeandClaude Opus 5 6766ebbe80 Clear the clippy warnings, except the two that are not cosmetic
Down from 29 warnings to 2, in the spirit of 4ab95ff.

The doc warnings were all one mistake made repeatedly: a paragraph wrapped so
that a line *began* with `- ` or `+ `, which rustdoc reads as a markdown list
marker and the following lines as unindented continuations. The prose always
meant an em-dash. Fixed by moving the dash to the end of the previous line and
re-wrapping; not one word changed, which is the point - these comments record
things confirmed against real printers and are worth more than the warning.
The genuine lists just had their continuations de-indented and stayed lists.

The code ones are `match x { V(v) => .., _ => {} }` to `if let`, and nested
`if let Some(x) = .. { if cond { .. } }` to a let-chain. None had an `else`,
so each is the same control flow written once instead of twice.

Two warnings are left deliberately, because fixing them is not cosmetic:
`AmsTray` (544 bytes against 32) and `BambuReport` (984 against 120) both have
one variant far larger than the rest. The fix clippy wants is a `Box`, which
changes how every reader of those enums is written and puts an allocation on
the push_status path that currently has none. Worth measuring before doing,
not worth doing to silence a warning.

51 tests still pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 21:43:06 +00:00

336 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, 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(())
}