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 -28
View File
@@ -1,38 +1,24 @@
use std::path::Path;
use tracing::info;
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 {
base: PrinterBase,
pub access_code: String,
}
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 {
base: PrinterBase { id: id.into(), name: name.into(), host: host.into() },
access_code: access_code.into(),
}
}
/// A method that ONLY exists on `BambuPrinter` — not part of the
/// `Printer` trait at all. This is what "adding a printer-specific
/// method" looks like: just define it in this struct's own `impl`
/// block. `PrusaPrinter` and `KlipperPrinter` have no equivalent, and
/// calling this requires a `PrinterHandle::Bambu(..)` match first (see
/// the example), which is the price for not doing runtime downcasting.
///
/// 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(())
/// Only `BambuPrinter` has this — it's not on the `Printer` trait at
/// all, so `PrusaPrinter`/`KlipperPrinter` simply don't have it. This
/// is what "adding a printer-specific method" looks like: just define
/// it in this struct's own `impl` block.
pub fn dispatch_gcode(&self, file_name: &str) {
println!("[{}] uploading {file_name} over FTPS", self.base.name);
}
}
@@ -41,14 +27,11 @@ impl Printer for BambuPrinter {
&self.base
}
/// OVERRIDE: Bambu speaks MQTTS on port 8883, authenticated with the
/// printer's LAN access code. Completely different transport from the
/// other two vendors below.
/// OVERRIDE: Bambu's real connect logic opens an MQTTS session (see
/// `adapters::bambu::run_telemetry` for that). Here we just print what
/// it would do, so the focus stays on how dispatch works.
async fn connect(&mut self) -> Result<(), PrinterError> {
info!(printer = %self.base.id, host = %self.base.host, "connecting to Bambu printer over MQTTS:8883");
// 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.
println!("[{}] connecting over MQTT with access code {}", self.base.name, self.access_code);
Ok(())
}
}
+9 -20
View File
@@ -1,7 +1,3 @@
use std::time::Duration;
use tracing::info;
use super::{Printer, PrinterBase, PrinterError};
pub struct KlipperPrinter {
@@ -9,7 +5,7 @@ pub struct 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 {
base: PrinterBase { id: id.into(), name: name.into(), host: host.into() },
}
@@ -21,22 +17,15 @@ impl Printer for KlipperPrinter {
&self.base
}
/// OVERRIDE: Moonraker's real-time feed is a WebSocket JSON-RPC channel
/// (see adapters::moonraker::run for that), but it also exposes a plain
/// REST endpoint. `connect()` here is a lightweight reachability probe
/// against that REST endpoint before the daemon opens the WS stream —
/// a third, again-completely-different notion of "connect".
/// OVERRIDE: a third, again-different body — Moonraker's real feed is a
/// WebSocket (see `adapters::moonraker`). This also shows the `Err`
/// path: an empty `host` fails immediately, same as any other vendor
/// would if it couldn't reach its printer.
async fn connect(&mut self) -> Result<(), PrinterError> {
info!(printer = %self.base.id, host = %self.base.host, "probing Moonraker over HTTP");
reqwest::Client::new()
.get(format!("http://{}/printer/info", self.base.host))
.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()))?;
if self.base.host.is_empty() {
return Err(PrinterError(format!("{} has no host configured", self.base.name)));
}
println!("[{}] connecting over WebSocket (Moonraker)", self.base.name);
Ok(())
}
}
+43 -47
View File
@@ -1,22 +1,25 @@
//! Printer hierarchy — the Rust answer to
//! `GenericPrinter -> GenericBambuPrinter -> BambuPrinterV1/V2`-style
//! inheritance.
//! `GenericPrinter -> BambuPrinter/PrusaPrinter/KlipperPrinter`-style
//! inheritance, since Rust structs can't `extend` each other.
//!
//! Rust structs can't `extend` each other, so instead of an inheritance
//! chain we use three separate, composable mechanisms:
//! Three pieces, each doing one job an OOP base class would normally do:
//!
//! 1. **Composition** (`PrinterBase` as a field) for shared *data* — stands
//! in for a base class's fields.
//! 2. **A trait** (`Printer`) for shared *behavior* — stands in for a base
//! class's methods. A method with no default body (like `connect`) must
//! be supplied by every implementor: that's what "overriding" looks like
//! here, since there's no inherited body to override in the first place.
//! 3. **An enum** (`PrinterHandle`) for the closed set of concrete printer
//! kinds — stands in for "any subclass of GenericPrinter". Matching on it
//! is exhaustive, so the compiler forces every call site to handle a new
//! vendor when one is added.
//! 1. `PrinterBase` — a plain struct holding the shared *fields*. Every
//! printer struct below *has* one of these (composition) instead of
//! *extending* one.
//! 2. `Printer` — 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".
//!
//! 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 klipper;
@@ -26,18 +29,13 @@ pub use bambu::BambuPrinter;
pub use klipper::KlipperPrinter;
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)]
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.
/// Fields every printer has, regardless of vendor.
#[derive(Debug, Clone)]
pub struct PrinterBase {
pub id: String,
@@ -48,42 +46,40 @@ pub struct PrinterBase {
/// The shared interface — think `interface Printer` (Java/TS) or an ABC
/// (Python). Every vendor's struct implements this.
pub trait Printer {
/// Read-only access to the shared fields (the "inherited" data).
/// Read-only access to the shared fields.
fn base(&self) -> &PrinterBase;
/// REQUIRED, no default body: every printer vendor speaks a different
/// protocol, so there's nothing sensible to share here. Each `impl`
/// below provides a completely different body — that's the "override".
/// 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.
///
/// Spelled as `-> impl Future<..> + Send` rather than plain `async fn`
/// so the resulting future is `Send` and can be `.await`ed inside a
/// `tokio::spawn`ed task; each `impl` below still just writes a normal
/// `async fn` body, which satisfies this signature automatically.
fn connect(&mut self) -> impl std::future::Future<Output = Result<(), PrinterError>> + Send;
/// `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>;
/// OPTIONAL: a default body every printer gets for free unless it
/// chooses to provide its own. None of ours do, so all three vendors
/// use this exact implementation — this is the trait-method equivalent
/// of inheriting a base-class method unchanged.
/// 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, self.base().host)
format!("{} ({})", self.base().name, self.base().id)
}
}
/// The closed set of printer kinds this fleet can talk to — stands in for
/// "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.
/// The closed set of printer kinds this fleet can talk to.
pub enum PrinterHandle {
Bambu(BambuPrinter),
Prusa(PrusaPrinter),
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 {
fn base(&self) -> &PrinterBase {
match self {
+5 -19
View File
@@ -1,7 +1,3 @@
use std::time::Duration;
use tracing::info;
use super::{Printer, PrinterBase, PrinterError};
pub struct PrusaPrinter {
@@ -10,7 +6,7 @@ pub struct 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 {
base: PrinterBase { id: id.into(), name: name.into(), host: host.into() },
api_key: api_key.into(),
@@ -23,21 +19,11 @@ impl Printer for PrusaPrinter {
&self.base
}
/// OVERRIDE: PrusaLink is a plain REST API. "Connecting" just means
/// confirming the printer answers `GET /api/v1/status` with the API
/// key no persistent session to hold onto, unlike Bambu's MQTT link.
/// OVERRIDE: a completely different body from Bambu's — PrusaLink is a
/// plain REST API (real version: `GET /api/v1/status` with the API
/// key, see `adapters::prusalink`), no persistent session to hold.
async fn connect(&mut self) -> Result<(), PrinterError> {
info!(printer = %self.base.id, host = %self.base.host, "connecting to PrusaLink over HTTP");
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()))?;
println!("[{}] connecting over HTTP (PrusaLink)", self.base.name);
Ok(())
}
}