Add Printer trait + PrinterHandle enum domain model with polymorphism example
This commit is contained in:
@@ -5,10 +5,18 @@ edition = "2024"
|
|||||||
description = "Continuum edge gateway daemon — runs on-site, bridges printers to the cloud control plane"
|
description = "Continuum edge gateway daemon — runs on-site, bridges printers to the cloud control plane"
|
||||||
license = "UNLICENSED"
|
license = "UNLICENSED"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "continuum_proxy"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "continuum-proxy"
|
name = "continuum-proxy"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "printer_polymorphism"
|
||||||
|
path = "examples/printer_polymorphism.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tokio = { version = "1.40", features = ["full"] }
|
tokio = { version = "1.40", features = ["full"] }
|
||||||
tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
|
tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
|
||||||
|
|||||||
@@ -31,7 +31,21 @@ cargo run
|
|||||||
src/
|
src/
|
||||||
main.rs Multi-threaded async engine: uplink, discovery, go2rtc watchdog
|
main.rs Multi-threaded async engine: uplink, discovery, go2rtc watchdog
|
||||||
uplink/ Cloud WebSocket client (reconnect, heartbeat, dispatch)
|
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
|
plate_changer/ Mechanical cycle control + sensor validation
|
||||||
discovery/ LAN printer discovery (SSDP / mDNS / static config)
|
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
|
||||||
|
```
|
||||||
|
|||||||
@@ -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<PrinterHandle> = 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user