//! 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>>` /// 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 + 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) -> 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 { 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) -> 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 `

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