Add Printer trait + PrinterHandle enum domain model with polymorphism example

This commit is contained in:
2026-08-28 17:03:21 +00:00
parent 2807b2b067
commit 03bd07fbd7
8 changed files with 302 additions and 1 deletions
+6
View File
@@ -0,0 +1,6 @@
//! Library surface for `continuum-proxy`. The daemon itself is a binary
//! (`src/main.rs`); this crate root exists so standalone examples (see
//! `examples/`) and future integration tests can `use continuum_proxy::...`
//! without duplicating module declarations.
pub mod printer;
+54
View File
@@ -0,0 +1,54 @@
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 {
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(())
}
}
impl Printer for BambuPrinter {
fn base(&self) -> &PrinterBase {
&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.
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.
Ok(())
}
}
+37
View File
@@ -0,0 +1,37 @@
use tracing::info;
use super::{Printer, PrinterBase, PrinterError};
pub struct KlipperPrinter {
base: PrinterBase,
}
impl KlipperPrinter {
pub fn new(id: impl Into<String>, name: impl Into<String>, host: impl Into<String>) -> Self {
Self {
base: PrinterBase { id: id.into(), name: name.into(), host: host.into() },
}
}
}
impl Printer for KlipperPrinter {
fn base(&self) -> &PrinterBase {
&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".
async fn connect(&mut self) -> Result<(), PrinterError> {
info!(printer = %self.base.id, host = %self.base.host, "probing Moonraker over HTTP");
reqwest::get(format!("http://{}/printer/info", self.base.host))
.await
.and_then(|resp| resp.error_for_status())
.map_err(|err| PrinterError::Connection(self.base.id.clone(), err.to_string()))?;
Ok(())
}
}
+98
View File
@@ -0,0 +1,98 @@
//! Printer hierarchy — the Rust answer to
//! `GenericPrinter -> GenericBambuPrinter -> BambuPrinterV1/V2`-style
//! inheritance.
//!
//! Rust structs can't `extend` each other, so instead of an inheritance
//! chain we use three separate, composable mechanisms:
//!
//! 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.
//!
//! See `examples/printer_polymorphism.rs` for this in action.
mod bambu;
mod klipper;
mod prusa;
pub use bambu::BambuPrinter;
pub use klipper::KlipperPrinter;
pub use prusa::PrusaPrinter;
use thiserror::Error;
#[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.
#[derive(Debug, Clone)]
pub struct PrinterBase {
pub id: String,
pub name: String,
pub host: String,
}
/// 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).
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".
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.
fn display_name(&self) -> String {
format!("{} ({}) @ {}", self.base().name, self.base().id, self.base().host)
}
}
/// 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.
pub enum PrinterHandle {
Bambu(BambuPrinter),
Prusa(PrusaPrinter),
Klipper(KlipperPrinter),
}
impl Printer for PrinterHandle {
fn base(&self) -> &PrinterBase {
match self {
PrinterHandle::Bambu(p) => p.base(),
PrinterHandle::Prusa(p) => p.base(),
PrinterHandle::Klipper(p) => p.base(),
}
}
async fn connect(&mut self) -> Result<(), PrinterError> {
match self {
PrinterHandle::Bambu(p) => p.connect().await,
PrinterHandle::Prusa(p) => p.connect().await,
PrinterHandle::Klipper(p) => p.connect().await,
}
}
}
+40
View File
@@ -0,0 +1,40 @@
use tracing::info;
use super::{Printer, PrinterBase, PrinterError};
pub struct PrusaPrinter {
base: PrinterBase,
pub api_key: String,
}
impl PrusaPrinter {
pub fn new(id: impl Into<String>, name: impl Into<String>, host: impl Into<String>, api_key: impl Into<String>) -> Self {
Self {
base: PrinterBase { id: id.into(), name: name.into(), host: host.into() },
api_key: api_key.into(),
}
}
}
impl Printer for PrusaPrinter {
fn base(&self) -> &PrinterBase {
&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.
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)
.send()
.await
.and_then(|resp| resp.error_for_status())
.map_err(|err| PrinterError::Connection(self.base.id.clone(), err.to_string()))?;
Ok(())
}
}