From 03bd07fbd7b03b2ba22dce3aa8669b8a1b4f824a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iwo=20Strzebo=C5=84ski?= Date: Fri, 28 Aug 2026 17:03:21 +0000 Subject: [PATCH] Add Printer trait + PrinterHandle enum domain model with polymorphism example --- Cargo.toml | 8 +++ README.md | 16 +++++- examples/printer_polymorphism.rs | 44 ++++++++++++++ src/lib.rs | 6 ++ src/printer/bambu.rs | 54 ++++++++++++++++++ src/printer/klipper.rs | 37 ++++++++++++ src/printer/mod.rs | 98 ++++++++++++++++++++++++++++++++ src/printer/prusa.rs | 40 +++++++++++++ 8 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 examples/printer_polymorphism.rs create mode 100644 src/lib.rs create mode 100644 src/printer/bambu.rs create mode 100644 src/printer/klipper.rs create mode 100644 src/printer/mod.rs create mode 100644 src/printer/prusa.rs diff --git a/Cargo.toml b/Cargo.toml index 5f20b2c..b42d275 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,10 +5,18 @@ edition = "2024" description = "Continuum edge gateway daemon — runs on-site, bridges printers to the cloud control plane" license = "UNLICENSED" +[lib] +name = "continuum_proxy" +path = "src/lib.rs" + [[bin]] name = "continuum-proxy" path = "src/main.rs" +[[example]] +name = "printer_polymorphism" +path = "examples/printer_polymorphism.rs" + [dependencies] tokio = { version = "1.40", features = ["full"] } tokio-tungstenite = { version = "0.24", features = ["native-tls"] } diff --git a/README.md b/README.md index d55b107..eea74ae 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,21 @@ cargo run src/ main.rs Multi-threaded async engine: uplink, discovery, go2rtc watchdog uplink/ Cloud WebSocket client (reconnect, heartbeat, dispatch) - adapters/ bambu.rs, prusalink.rs, moonraker.rs printer clients + adapters/ bambu.rs, prusalink.rs, moonraker.rs — wire-protocol clients + printer/ Domain model: Printer trait + PrinterBase + PrinterHandle enum plate_changer/ Mechanical cycle control + sensor validation discovery/ LAN printer discovery (SSDP / mDNS / static config) ``` + +`src/adapters/` talks the raw wire protocols (MQTT, FTPS, HTTP, WS). +`src/printer/` is the vendor-agnostic domain model layered on top: a +`Printer` trait plus one struct per vendor (`BambuPrinter`, `PrusaPrinter`, +`KlipperPrinter`) wrapped in a closed `PrinterHandle` enum. Rust has no class +inheritance, so this trait+enum combo is what stands in for a +`GenericPrinter -> BambuPrinter` hierarchy — composition for shared fields, +a trait for shared/overridable behavior, an enum for the closed set of +vendors. See `examples/printer_polymorphism.rs`: + +```bash +cargo run --example printer_polymorphism +``` diff --git a/examples/printer_polymorphism.rs b/examples/printer_polymorphism.rs new file mode 100644 index 0000000..8581614 --- /dev/null +++ b/examples/printer_polymorphism.rs @@ -0,0 +1,44 @@ +//! Run with: cargo run --example printer_polymorphism +//! +//! Demonstrates the trait+enum pattern that stands in for inheritance: +//! - `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 +//! have to `match` the enum back down to the concrete type to reach it, +//! which is the trade-off for not having implicit downcasting. + +use continuum_proxy::printer::{BambuPrinter, KlipperPrinter, Printer, PrinterHandle, PrusaPrinter}; + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt::init(); + + let mut fleet: Vec = vec![ + PrinterHandle::Bambu(BambuPrinter::new("p1", "X1 Carbon", "192.168.1.50:8883", "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")), + ]; + + // 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. + for printer in &mut fleet { + match printer.connect().await { + Ok(()) => println!("connected: {}", printer.display_name()), + Err(err) => println!("connect failed: {err}"), + } + } + + // Vendor-only extension: reachable only after matching the concrete + // variant back out of the enum. + for printer in &mut fleet { + if let PrinterHandle::Bambu(bambu) = printer { + bambu + .dispatch_gcode_ftp(std::path::Path::new("./part.gcode.3mf"), "part.gcode.3mf") + .await + .ok(); + } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..5f6f319 --- /dev/null +++ b/src/lib.rs @@ -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; diff --git a/src/printer/bambu.rs b/src/printer/bambu.rs new file mode 100644 index 0000000..93e2f17 --- /dev/null +++ b/src/printer/bambu.rs @@ -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, name: impl Into, host: impl Into, access_code: impl Into) -> 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(()) + } +} diff --git a/src/printer/klipper.rs b/src/printer/klipper.rs new file mode 100644 index 0000000..9d2f77f --- /dev/null +++ b/src/printer/klipper.rs @@ -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, name: impl Into, host: impl Into) -> 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(()) + } +} diff --git a/src/printer/mod.rs b/src/printer/mod.rs new file mode 100644 index 0000000..777f325 --- /dev/null +++ b/src/printer/mod.rs @@ -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`. +/// +/// 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, + } + } +} diff --git a/src/printer/prusa.rs b/src/printer/prusa.rs new file mode 100644 index 0000000..b5f34dd --- /dev/null +++ b/src/printer/prusa.rs @@ -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, name: impl Into, host: impl Into, api_key: impl Into) -> 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(()) + } +}