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:
@@ -4,7 +4,7 @@
|
||||
//! - `connect()` is called the same way regardless of vendor (polymorphism),
|
||||
//! but each vendor's `impl Printer` runs completely different code
|
||||
//! (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,
|
||||
//! which is the trade-off for not having implicit downcasting.
|
||||
|
||||
@@ -12,33 +12,29 @@ use continuum_proxy::printer::{BambuPrinter, KlipperPrinter, Printer, PrinterHan
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
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::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
|
||||
// caller's point of view, even though BambuPrinter::connect,
|
||||
// PrusaPrinter::connect and KlipperPrinter::connect are three unrelated
|
||||
// implementations.
|
||||
// Uniform call site: every printer connects the same way from the
|
||||
// caller's point of view, even though the three `connect()` bodies
|
||||
// are unrelated implementations.
|
||||
for printer in &mut fleet {
|
||||
match printer.connect().await {
|
||||
Ok(()) => println!("connected: {}", printer.display_name()),
|
||||
Err(err) => println!("connect failed: {err}"),
|
||||
Ok(()) => println!(" -> connected: {}\n", printer.display_name()),
|
||||
Err(err) => println!(" -> failed: {err}\n"),
|
||||
}
|
||||
}
|
||||
|
||||
// Vendor-only extension: reachable only after matching the concrete
|
||||
// variant back out of the enum.
|
||||
for printer in &mut fleet {
|
||||
for printer in &fleet {
|
||||
if let PrinterHandle::Bambu(bambu) = printer {
|
||||
bambu
|
||||
.dispatch_gcode_ftp(std::path::Path::new("./part.gcode.3mf"), "part.gcode.3mf")
|
||||
.await
|
||||
.ok();
|
||||
bambu.dispatch_gcode("part.gcode.3mf");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-28
@@ -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
@@ -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
@@ -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
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user