Simplify printer/ module for someone learning Rust

Drop reqwest, RPITIT+Send signature, and multi-variant error enum from the
teaching example so the only new idea per file is the trait override itself.
Real networking stays in adapters/, unaffected.
This commit is contained in:
2026-08-28 18:04:13 +00:00
parent 9b3815fd25
commit 5705f36c01
5 changed files with 79 additions and 129 deletions
+11 -15
View File
@@ -4,7 +4,7 @@
//! - `connect()` is called the same way regardless of vendor (polymorphism), //! - `connect()` is called the same way regardless of vendor (polymorphism),
//! but each vendor's `impl Printer` runs completely different code //! but each vendor's `impl Printer` runs completely different code
//! (override). //! (override).
//! - `dispatch_gcode_ftp()` only exists on `BambuPrinter` (extension) — you //! - `dispatch_gcode()` only exists on `BambuPrinter` (extension) — you
//! have to `match` the enum back down to the concrete type to reach it, //! have to `match` the enum back down to the concrete type to reach it,
//! which is the trade-off for not having implicit downcasting. //! which is the trade-off for not having implicit downcasting.
@@ -12,33 +12,29 @@ use continuum_proxy::printer::{BambuPrinter, KlipperPrinter, Printer, PrinterHan
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
tracing_subscriber::fmt::init();
let mut fleet: Vec<PrinterHandle> = vec![ let mut fleet: Vec<PrinterHandle> = vec![
PrinterHandle::Bambu(BambuPrinter::new("p1", "X1 Carbon", "192.168.1.50:8883", "12345678")), PrinterHandle::Bambu(BambuPrinter::new("p1", "X1 Carbon", "192.168.1.50", "12345678")),
PrinterHandle::Prusa(PrusaPrinter::new("p2", "MK4", "192.168.1.51", "prusa-api-key")), PrinterHandle::Prusa(PrusaPrinter::new("p2", "MK4", "192.168.1.51", "prusa-api-key")),
PrinterHandle::Klipper(KlipperPrinter::new("p3", "Voron 2.4", "192.168.1.52")), PrinterHandle::Klipper(KlipperPrinter::new("p3", "Voron 2.4", "192.168.1.52")),
// No host configured — this one will hit the Err path.
PrinterHandle::Klipper(KlipperPrinter::new("p4", "Broken Voron", "")),
]; ];
// Uniform call site: every printer is connected the same way from the // Uniform call site: every printer connects the same way from the
// caller's point of view, even though BambuPrinter::connect, // caller's point of view, even though the three `connect()` bodies
// PrusaPrinter::connect and KlipperPrinter::connect are three unrelated // are unrelated implementations.
// implementations.
for printer in &mut fleet { for printer in &mut fleet {
match printer.connect().await { match printer.connect().await {
Ok(()) => println!("connected: {}", printer.display_name()), Ok(()) => println!(" -> connected: {}\n", printer.display_name()),
Err(err) => println!("connect failed: {err}"), Err(err) => println!(" -> failed: {err}\n"),
} }
} }
// Vendor-only extension: reachable only after matching the concrete // Vendor-only extension: reachable only after matching the concrete
// variant back out of the enum. // variant back out of the enum.
for printer in &mut fleet { for printer in &fleet {
if let PrinterHandle::Bambu(bambu) = printer { if let PrinterHandle::Bambu(bambu) = printer {
bambu bambu.dispatch_gcode("part.gcode.3mf");
.dispatch_gcode_ftp(std::path::Path::new("./part.gcode.3mf"), "part.gcode.3mf")
.await
.ok();
} }
} }
} }
+11 -28
View File
@@ -1,38 +1,24 @@
use std::path::Path;
use tracing::info;
use super::{Printer, PrinterBase, PrinterError}; use super::{Printer, PrinterBase, PrinterError};
/// Bambu-specific fields — this is the "extends GenericBambuPrinter" part of
/// your original design, expressed as extra struct fields instead of a
/// second inheritance layer.
pub struct BambuPrinter { pub struct BambuPrinter {
base: PrinterBase, base: PrinterBase,
pub access_code: String, pub access_code: String,
} }
impl BambuPrinter { impl BambuPrinter {
pub fn new(id: impl Into<String>, name: impl Into<String>, host: impl Into<String>, access_code: impl Into<String>) -> Self { pub fn new(id: &str, name: &str, host: &str, access_code: &str) -> Self {
Self { Self {
base: PrinterBase { id: id.into(), name: name.into(), host: host.into() }, base: PrinterBase { id: id.into(), name: name.into(), host: host.into() },
access_code: access_code.into(), access_code: access_code.into(),
} }
} }
/// A method that ONLY exists on `BambuPrinter` — not part of the /// Only `BambuPrinter` has this — it's not on the `Printer` trait at
/// `Printer` trait at all. This is what "adding a printer-specific /// all, so `PrusaPrinter`/`KlipperPrinter` simply don't have it. This
/// method" looks like: just define it in this struct's own `impl` /// is what "adding a printer-specific method" looks like: just define
/// block. `PrusaPrinter` and `KlipperPrinter` have no equivalent, and /// it in this struct's own `impl` block.
/// calling this requires a `PrinterHandle::Bambu(..)` match first (see pub fn dispatch_gcode(&self, file_name: &str) {
/// the example), which is the price for not doing runtime downcasting. println!("[{}] uploading {file_name} over FTPS", self.base.name);
///
/// Real implementation lives in `adapters::bambu::dispatch_file` (FTPS
/// on port 990); this is the domain-model-facing wrapper around it.
pub async fn dispatch_gcode_ftp(&mut self, local_path: &Path, remote_name: &str) -> Result<(), PrinterError> {
info!(printer = %self.base.id, remote_name = %remote_name, path = ?local_path, "dispatching gcode over FTPS");
// See adapters::bambu::dispatch_file for the full FTPS upload.
Ok(())
} }
} }
@@ -41,14 +27,11 @@ impl Printer for BambuPrinter {
&self.base &self.base
} }
/// OVERRIDE: Bambu speaks MQTTS on port 8883, authenticated with the /// OVERRIDE: Bambu's real connect logic opens an MQTTS session (see
/// printer's LAN access code. Completely different transport from the /// `adapters::bambu::run_telemetry` for that). Here we just print what
/// other two vendors below. /// it would do, so the focus stays on how dispatch works.
async fn connect(&mut self) -> Result<(), PrinterError> { async fn connect(&mut self) -> Result<(), PrinterError> {
info!(printer = %self.base.id, host = %self.base.host, "connecting to Bambu printer over MQTTS:8883"); println!("[{}] connecting over MQTT with access code {}", self.base.name, self.access_code);
// Real implementation: open the MQTTS session and wait for the
// first `report` message — see adapters::bambu::run_telemetry,
// which this would delegate to once wired into the daemon.
Ok(()) Ok(())
} }
} }
+9 -20
View File
@@ -1,7 +1,3 @@
use std::time::Duration;
use tracing::info;
use super::{Printer, PrinterBase, PrinterError}; use super::{Printer, PrinterBase, PrinterError};
pub struct KlipperPrinter { pub struct KlipperPrinter {
@@ -9,7 +5,7 @@ pub struct KlipperPrinter {
} }
impl KlipperPrinter { impl KlipperPrinter {
pub fn new(id: impl Into<String>, name: impl Into<String>, host: impl Into<String>) -> Self { pub fn new(id: &str, name: &str, host: &str) -> Self {
Self { Self {
base: PrinterBase { id: id.into(), name: name.into(), host: host.into() }, base: PrinterBase { id: id.into(), name: name.into(), host: host.into() },
} }
@@ -21,22 +17,15 @@ impl Printer for KlipperPrinter {
&self.base &self.base
} }
/// OVERRIDE: Moonraker's real-time feed is a WebSocket JSON-RPC channel /// OVERRIDE: a third, again-different body — Moonraker's real feed is a
/// (see adapters::moonraker::run for that), but it also exposes a plain /// WebSocket (see `adapters::moonraker`). This also shows the `Err`
/// REST endpoint. `connect()` here is a lightweight reachability probe /// path: an empty `host` fails immediately, same as any other vendor
/// against that REST endpoint before the daemon opens the WS stream — /// would if it couldn't reach its printer.
/// a third, again-completely-different notion of "connect".
async fn connect(&mut self) -> Result<(), PrinterError> { async fn connect(&mut self) -> Result<(), PrinterError> {
info!(printer = %self.base.id, host = %self.base.host, "probing Moonraker over HTTP"); if self.base.host.is_empty() {
return Err(PrinterError(format!("{} has no host configured", self.base.name)));
reqwest::Client::new() }
.get(format!("http://{}/printer/info", self.base.host)) println!("[{}] connecting over WebSocket (Moonraker)", self.base.name);
.timeout(Duration::from_secs(5))
.send()
.await
.and_then(|resp| resp.error_for_status())
.map_err(|err| PrinterError::Connection(self.base.id.clone(), err.to_string()))?;
Ok(()) Ok(())
} }
} }
+43 -47
View File
@@ -1,22 +1,25 @@
//! Printer hierarchy — the Rust answer to //! Printer hierarchy — the Rust answer to
//! `GenericPrinter -> GenericBambuPrinter -> BambuPrinterV1/V2`-style //! `GenericPrinter -> BambuPrinter/PrusaPrinter/KlipperPrinter`-style
//! inheritance. //! inheritance, since Rust structs can't `extend` each other.
//! //!
//! Rust structs can't `extend` each other, so instead of an inheritance //! Three pieces, each doing one job an OOP base class would normally do:
//! chain we use three separate, composable mechanisms:
//! //!
//! 1. **Composition** (`PrinterBase` as a field) for shared *data* — stands //! 1. `PrinterBase` — a plain struct holding the shared *fields*. Every
//! in for a base class's fields. //! printer struct below *has* one of these (composition) instead of
//! 2. **A trait** (`Printer`) for shared *behavior* — stands in for a base //! *extending* one.
//! class's methods. A method with no default body (like `connect`) must //! 2. `Printer` — a trait holding the shared *behavior*. `connect()` has no
//! be supplied by every implementor: that's what "overriding" looks like //! default body, so every printer type is forced to write its own —
//! here, since there's no inherited body to override in the first place. //! that's what "overriding" looks like when there's no inherited body to
//! 3. **An enum** (`PrinterHandle`) for the closed set of concrete printer //! override in the first place.
//! kinds — stands in for "any subclass of GenericPrinter". Matching on it //! 3. `PrinterHandle` — an enum listing the closed set of printer kinds.
//! is exhaustive, so the compiler forces every call site to handle a new //! Stands in for "any subclass of GenericPrinter".
//! vendor when one is added.
//! //!
//! See `examples/printer_polymorphism.rs` for this in action. //! Run `cargo run --example printer_polymorphism` to see it in action.
//!
//! Deliberately left out of this module for now, to keep it focused on one
//! idea at a time: real MQTT/HTTP/FTPS calls (that's `src/adapters/`) and
//! `Send`-bound futures (matters once this is wired into a multi-threaded
//! `tokio::spawn`, not yet).
mod bambu; mod bambu;
mod klipper; mod klipper;
@@ -26,18 +29,13 @@ pub use bambu::BambuPrinter;
pub use klipper::KlipperPrinter; pub use klipper::KlipperPrinter;
pub use prusa::PrusaPrinter; pub use prusa::PrusaPrinter;
use thiserror::Error; /// 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);
#[derive(Debug, Error)] /// Fields every printer has, regardless of vendor.
pub enum PrinterError {
#[error("connection to {0} failed: {1}")]
Connection(String, String),
#[error("{0} does not support this operation")]
Unsupported(&'static str),
}
/// Fields every printer has, regardless of vendor. Every concrete printer
/// struct holds one of these as a field rather than "inheriting" it.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct PrinterBase { pub struct PrinterBase {
pub id: String, pub id: String,
@@ -48,42 +46,40 @@ pub struct PrinterBase {
/// The shared interface — think `interface Printer` (Java/TS) or an ABC /// The shared interface — think `interface Printer` (Java/TS) or an ABC
/// (Python). Every vendor's struct implements this. /// (Python). Every vendor's struct implements this.
pub trait Printer { pub trait Printer {
/// Read-only access to the shared fields (the "inherited" data). /// Read-only access to the shared fields.
fn base(&self) -> &PrinterBase; fn base(&self) -> &PrinterBase;
/// REQUIRED, no default body: every printer vendor speaks a different /// REQUIRED, no default body: every vendor speaks a different protocol,
/// protocol, so there's nothing sensible to share here. Each `impl` /// so there's nothing sensible to share here. Each `impl` in
/// below provides a completely different body — that's the "override". /// `bambu.rs`/`prusa.rs`/`klipper.rs` provides a totally different
/// body — that's the override.
/// ///
/// Spelled as `-> impl Future<..> + Send` rather than plain `async fn` /// `cargo check` will warn about `async fn` in a public trait here.
/// so the resulting future is `Send` and can be `.await`ed inside a /// That's about a more advanced concern (whether the resulting work can
/// `tokio::spawn`ed task; each `impl` below still just writes a normal /// safely move across OS threads) that only matters once this is wired
/// `async fn` body, which satisfies this signature automatically. /// into the multi-threaded daemon — safe to ignore for now.
fn connect(&mut self) -> impl std::future::Future<Output = Result<(), PrinterError>> + Send; async fn connect(&mut self) -> Result<(), PrinterError>;
/// OPTIONAL: a default body every printer gets for free unless it /// OPTIONAL: a default body every printer gets for free unless it
/// chooses to provide its own. None of ours do, so all three vendors /// writes its own. None of ours do below, so all three vendors share
/// use this exact implementation — this is the trait-method equivalent /// this exact implementation — the trait-method equivalent of
/// of inheriting a base-class method unchanged. /// inheriting a base-class method unchanged.
fn display_name(&self) -> String { fn display_name(&self) -> String {
format!("{} ({}) @ {}", self.base().name, self.base().id, self.base().host) format!("{} ({})", self.base().name, self.base().id)
} }
} }
/// The closed set of printer kinds this fleet can talk to — stands in for /// The closed set of printer kinds this fleet can talk to.
/// "any subclass of GenericPrinter" without needing a `Box<dyn Printer>`.
///
/// The `impl Printer for PrinterHandle` below is hand-written delegation:
/// each trait method just matches on the variant and forwards to that
/// variant's own implementation. This is exactly the boilerplate a crate
/// like `enum_dispatch` would generate for you — written out by hand here
/// so the mechanism is visible rather than hidden behind a macro.
pub enum PrinterHandle { pub enum PrinterHandle {
Bambu(BambuPrinter), Bambu(BambuPrinter),
Prusa(PrusaPrinter), Prusa(PrusaPrinter),
Klipper(KlipperPrinter), 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 Printer for PrinterHandle { impl Printer for PrinterHandle {
fn base(&self) -> &PrinterBase { fn base(&self) -> &PrinterBase {
match self { match self {
+5 -19
View File
@@ -1,7 +1,3 @@
use std::time::Duration;
use tracing::info;
use super::{Printer, PrinterBase, PrinterError}; use super::{Printer, PrinterBase, PrinterError};
pub struct PrusaPrinter { pub struct PrusaPrinter {
@@ -10,7 +6,7 @@ pub struct PrusaPrinter {
} }
impl PrusaPrinter { impl PrusaPrinter {
pub fn new(id: impl Into<String>, name: impl Into<String>, host: impl Into<String>, api_key: impl Into<String>) -> Self { pub fn new(id: &str, name: &str, host: &str, api_key: &str) -> Self {
Self { Self {
base: PrinterBase { id: id.into(), name: name.into(), host: host.into() }, base: PrinterBase { id: id.into(), name: name.into(), host: host.into() },
api_key: api_key.into(), api_key: api_key.into(),
@@ -23,21 +19,11 @@ impl Printer for PrusaPrinter {
&self.base &self.base
} }
/// OVERRIDE: PrusaLink is a plain REST API. "Connecting" just means /// OVERRIDE: a completely different body from Bambu's — PrusaLink is a
/// confirming the printer answers `GET /api/v1/status` with the API /// plain REST API (real version: `GET /api/v1/status` with the API
/// key no persistent session to hold onto, unlike Bambu's MQTT link. /// key, see `adapters::prusalink`), no persistent session to hold.
async fn connect(&mut self) -> Result<(), PrinterError> { async fn connect(&mut self) -> Result<(), PrinterError> {
info!(printer = %self.base.id, host = %self.base.host, "connecting to PrusaLink over HTTP"); println!("[{}] connecting over HTTP (PrusaLink)", self.base.name);
reqwest::Client::new()
.get(format!("http://{}/api/v1/status", self.base.host))
.header("X-Api-Key", &self.api_key)
.timeout(Duration::from_secs(5))
.send()
.await
.and_then(|resp| resp.error_for_status())
.map_err(|err| PrinterError::Connection(self.base.id.clone(), err.to_string()))?;
Ok(()) Ok(())
} }
} }