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>
336 lines
16 KiB
Rust
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(())
|
|
}
|