From 2c4ef21291f1256db6685e698ea82e94f1e95762 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iwo=20Strzebo=C5=84ski?= Date: Sat, 5 Sep 2026 20:40:21 +0000 Subject: [PATCH] Add upload_project_file/send_gcode_line, real Marlin serial, Bambu protocol auto-detect - GenericPrinter grows upload_project_file, send_gcode_line, and a generic upload_gcode

driver that streams a whole gcode file line by line through any vendor's send_gcode_line. - PrusaSerialPrinter's connect/send_gcode_line are real (not stubs): a held-open MarlinSerialConnection (src/printer/marlin_serial.rs) writes each line and waits for Marlin's "ok" before sending the next. Config changed from com_port: i16 to port: String + baud_rate: u32 to hold a real device path. - Bambu: BambuGenericPrinter + BambuV1Printer/BambuV2Printer collapsed into one BambuPrinter, since which MQTT/BambuMQTT version (v3.1.1/v1 vs v5/v2) a printer speaks is discovered at connect() time (or forced), not something config should pick a type for - the same physical printer can change after a firmware update. printers.toml gets a new "bambu" vendor (auto-detect, the default) alongside bambu_v1/bambu_v2 (force a specific one). - Bambu's gcode_line request is now built for real via continuum-schema-instance against continuum-schemas' JSON Schema, picking the v1 or v2 schema directory based on the detected protocol version, instead of a hand-written serde struct. - Added rumqttc as a dependency (not wired up yet - the real MQTT v5-then-v3.1.1 fallback connect and the publish/receive plumbing are still stubs). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MWQVtBGxHYLkVw2kBupd5g --- Cargo.toml | 21 +- examples/auto_connect_bambu.rs | 3 +- examples/fetch_bambu_cert.rs | 3 +- examples/printer_polymorphism.rs | 30 +-- examples/test_bambu_certs.rs | 6 +- printers.example.toml | 16 +- src/fleet.rs | 49 +++-- src/printer/bambu.rs | 328 +++++++++++++++++++----------- src/printer/bambu_commands/mod.rs | 6 + src/printer/klipper.rs | 25 +++ src/printer/marlin_serial.rs | 69 +++++++ src/printer/mod.rs | 110 ++++++++-- src/printer/prusa.rs | 96 +++++++-- 13 files changed, 559 insertions(+), 203 deletions(-) create mode 100644 src/printer/marlin_serial.rs diff --git a/Cargo.toml b/Cargo.toml index 2e32c34..9799660 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,18 @@ name = "bambu_commands_demo" path = "examples/bambu_commands_demo.rs" [dependencies] +# Real printer protocol clients (MQTT for Bambu, HTTP for PrusaLink/ +# Moonraker, FTPS for gcode transfer) go here once you're ready to build +# past the stubs in src/printer/ - see that module's doc comment. +continuum-rust-types = { path = "../continuum-schemas/packages/rust-types" } +# FOR NON-LOCAL DEV +# continuum-rust-types = { git = "https://git.octoturge.com/Continuum/continuum-schemas.git" } + +# Builds a JSON instance straight from the schema files in +# continuum-schemas/schemas/ - used for Bambu's raw MQTT JSON messages +# instead of hand-written serde structs (see bambu.rs). +continuum-schema-instance = { path = "../continuum-common/packages/rust-schema-instance" } + tokio = { version = "1.40", features = ["full"] } tokio-tungstenite = { version = "0.24", features = ["native-tls"] } futures-util = "0.3" @@ -47,10 +59,5 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } anyhow = "1" thiserror = "1" dotenvy = "0.15" - -# Real printer protocol clients (MQTT for Bambu, HTTP for PrusaLink/ -# Moonraker, FTPS for gcode transfer) go here once you're ready to build -# past the stubs in src/printer/ - see that module's doc comment. -continuum-rust-types = { path = "../continuum-schemas/packages/rust-types" } -# FOR NON-LOCAL DEV -# continuum-rust-types = { git = "https://git.octoturge.com/Continuum/continuum-schemas.git" } +tokio-serial = { version = "5.5.0", default-features = false } +rumqttc = "0.25.1" diff --git a/examples/auto_connect_bambu.rs b/examples/auto_connect_bambu.rs index f2684d6..31a43ef 100644 --- a/examples/auto_connect_bambu.rs +++ b/examples/auto_connect_bambu.rs @@ -17,8 +17,7 @@ fn main() -> anyhow::Result<()> { for printer in &mut printers { match printer { - PrinterHandle::BambuV1(bambu) => report(&bambu.ensure_trusted(cert_dir)), - PrinterHandle::BambuV2(bambu) => report(&bambu.ensure_trusted(cert_dir)), + PrinterHandle::Bambu(bambu) => report(&bambu.ensure_trusted(cert_dir)), _ => {} } } diff --git a/examples/fetch_bambu_cert.rs b/examples/fetch_bambu_cert.rs index 6ec5392..5e9df5e 100644 --- a/examples/fetch_bambu_cert.rs +++ b/examples/fetch_bambu_cert.rs @@ -23,8 +23,7 @@ fn main() -> anyhow::Result<()> { let pem = printers .iter() .find_map(|printer| match printer { - PrinterHandle::BambuV1(bambu) if bambu.base().id == printer_id => Some(bambu.fetch_certificate()), - PrinterHandle::BambuV2(bambu) if bambu.base().id == printer_id => Some(bambu.fetch_certificate()), + PrinterHandle::Bambu(bambu) if bambu.base().id == printer_id => Some(bambu.fetch_certificate()), _ => None, }) .ok_or_else(|| anyhow::anyhow!("no Bambu printer with id '{printer_id}' in printers.toml"))??; diff --git a/examples/printer_polymorphism.rs b/examples/printer_polymorphism.rs index c3e524f..57c6f8d 100644 --- a/examples/printer_polymorphism.rs +++ b/examples/printer_polymorphism.rs @@ -4,36 +4,40 @@ //! - `connect()` is called the same way regardless of vendor (polymorphism), //! but each variant's `impl GenericPrinter` runs completely different //! code (override). -//! - `dispatch_gcode()` only exists on the two Bambu structs (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. +//! - `dispatch_gcode()` only exists on the Bambu struct (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::{ - BambuTls, BambuV1Printer, BambuV2Printer, GenericPrinter, KlipperPrinter, PrinterHandle, PrusaLinkPrinter, - PrusaSerialPrinter, + BambuPrinter, BambuProtocolPreference, BambuTls, GenericPrinter, KlipperPrinter, PrinterHandle, + PrusaLinkPrinter, PrusaSerialPrinter, }; #[tokio::main] async fn main() { let mut fleet: Vec = vec![ - PrinterHandle::BambuV1(BambuV1Printer::new( + // Forced: a known-old printer. + PrinterHandle::Bambu(BambuPrinter::new( "p1", "P1S", "192.168.1.49", "87654321", "01P00A000000000", BambuTls::BundledCa, + BambuProtocolPreference::ForceV1, )), - PrinterHandle::BambuV2(BambuV2Printer::new( + // Auto: the recommended default - see bambu.rs's doc comment. + PrinterHandle::Bambu(BambuPrinter::new( "p2", "X1 Carbon", "192.168.1.50", "12345678", "00M00A000000000", BambuTls::BundledCa, + BambuProtocolPreference::Auto, )), PrinterHandle::PrusaLink(PrusaLinkPrinter::new("p3", "MK4", "192.168.1.51", "prusa-api-key")), - PrinterHandle::PrusaSerial(PrusaSerialPrinter::new("p4", "MK3S+", 0)), + PrinterHandle::PrusaSerial(PrusaSerialPrinter::new("p4", "MK3S+", "/dev/ttyACM0", 115_200)), PrinterHandle::Klipper(KlipperPrinter::new("p5", "Voron 2.4", "192.168.1.52")), // No host configured - this one will hit the Err path. PrinterHandle::Klipper(KlipperPrinter::new("p6", "Broken Voron", "")), @@ -50,15 +54,11 @@ async fn main() { } // Vendor-only extension: reachable only after matching the concrete - // variant back out of the enum. BambuV1Printer and BambuV2Printer are - // separate types now (each *has* a BambuGenericPrinter rather than - // being the same struct), so - unlike before the split - this needs - // two arms instead of one `A(x) | B(x)` pattern: an or-pattern requires - // every alternative to bind the same type, and these no longer do. + // variant back out of the enum - one arm now that Bambu is a single + // type regardless of which protocol version `connect()` settled on. for printer in &fleet { match printer { - PrinterHandle::BambuV1(bambu) => bambu.dispatch_gcode("part.gcode.3mf"), - PrinterHandle::BambuV2(bambu) => bambu.dispatch_gcode("part.gcode.3mf"), + PrinterHandle::Bambu(bambu) => bambu.dispatch_gcode("part.gcode.3mf"), _ => {} } } diff --git a/examples/test_bambu_certs.rs b/examples/test_bambu_certs.rs index 4ecf506..e23c367 100644 --- a/examples/test_bambu_certs.rs +++ b/examples/test_bambu_certs.rs @@ -12,13 +12,9 @@ use continuum_proxy::printer::{GenericPrinter, PrinterHandle}; fn main() -> anyhow::Result<()> { let printers = fleet::load(std::path::Path::new("printers.toml"))?; - // BambuV1Printer and BambuV2Printer are separate types (each *has* a - // BambuGenericPrinter rather than being the same struct), so this - // needs one arm per variant rather than a single `A(x) | B(x)` pattern. for printer in &printers { match printer { - PrinterHandle::BambuV1(bambu) => report(bambu.base().name.as_str(), bambu.test_tls_handshake()), - PrinterHandle::BambuV2(bambu) => report(bambu.base().name.as_str(), bambu.test_tls_handshake()), + PrinterHandle::Bambu(bambu) => report(bambu.base().name.as_str(), bambu.test_tls_handshake()), _ => {} } } diff --git a/printers.example.toml b/printers.example.toml index b31b863..5143f79 100644 --- a/printers.example.toml +++ b/printers.example.toml @@ -5,7 +5,9 @@ [[printer]] id = "p1p" name = "P1P" -vendor = "bambu_v1" # bambu_v1 | bambu_v2 | prusa_link | prusa_serial | klipper +# Forced instead of "bambu" here since this one's a known-old printer - +# see fleet.rs's PrinterEntry doc comment for the full vendor list. +vendor = "bambu_v1" host = "192.168.1.50" access_code = "REPLACE_WITH_ACCESS_CODE" sn = "REPLACE_WITH_SERIAL_NUMBER" # printer screen > Settings > Device (used for MQTT topics later) @@ -18,7 +20,10 @@ ca_cert_path = "./certs/p1p.pem" [[printer]] id = "h2c" name = "H2C" -vendor = "bambu_v2" +# "bambu" auto-detects MQTT v5-vs-v3.1.1 (BambuMQTT v2-vs-v1) on connect - +# the recommended default, since a firmware update can change which one a +# given printer speaks without you knowing ahead of time. +vendor = "bambu" host = "192.168.1.51" access_code = "REPLACE_WITH_ACCESS_CODE" sn = "REPLACE_WITH_SERIAL_NUMBER" @@ -37,3 +42,10 @@ sn = "REPLACE_WITH_SERIAL_NUMBER" # vendor = "prusa_link" # host = "192.168.1.53" # api_key = "REPLACE_WITH_API_KEY" + +# [[printer]] +# id = "mk3s" +# name = "MK3S+" +# vendor = "prusa_serial" +# port = "/dev/ttyACM0" +# baud_rate = 115200 # optional, defaults to 115200 diff --git a/src/fleet.rs b/src/fleet.rs index a1d9726..fd6224e 100644 --- a/src/fleet.rs +++ b/src/fleet.rs @@ -7,7 +7,10 @@ use std::path::Path; use serde::Deserialize; -use crate::printer::{BambuTls, BambuV1Printer, BambuV2Printer, KlipperPrinter, PrinterHandle, PrusaLinkPrinter, PrusaSerialPrinter}; +use crate::printer::{ + BambuPrinter, BambuProtocolPreference, BambuTls, KlipperPrinter, PrinterHandle, PrusaLinkPrinter, + PrusaSerialPrinter, +}; #[derive(Debug, Deserialize)] struct FleetFile { @@ -19,7 +22,10 @@ struct FleetFile { struct PrinterEntry { id: String, name: String, - /// One of: "bambu_v1", "bambu_v2", "prusa_link", "prusa_serial", "klipper". + /// One of: "bambu" (auto-detects MQTT v5-vs-v3.1.1/BambuMQTT v2-vs-v1 - + /// the default, since a firmware update can move the same physical + /// printer between them), "bambu_v1"/"bambu_v2" (force a specific one), + /// "prusa_link", "prusa_serial", "klipper". vendor: String, host: Option, access_code: Option, @@ -27,7 +33,10 @@ struct PrinterEntry { /// by serial (`device/{sn}/report`), not by our own `id`. sn: Option, api_key: Option, - com_port: Option, + /// Serial device path, e.g. `/dev/ttyACM0` on Linux. + port: Option, + #[serde(default = "default_baud_rate")] + baud_rate: u32, /// Path to a printer-specific CA certificate. Leave unset to use the /// bundled Bambu CA - only needed for a printer that doesn't chain to /// it (P1P, at least; see certs/README.md). @@ -40,6 +49,11 @@ fn default_true() -> bool { true } +/// Marlin's usual default on most current Prusa firmware. +fn default_baud_rate() -> u32 { + 115_200 +} + /// Reads and parses a printers file into ready-to-use `PrinterHandle`s. pub fn load(path: &Path) -> anyhow::Result> { let text = std::fs::read_to_string(path).map_err(|err| { @@ -54,28 +68,17 @@ pub fn load(path: &Path) -> anyhow::Result> { fn build_printer(entry: PrinterEntry) -> anyhow::Result { match entry.vendor.as_str() { - "bambu_v1" => { - let host = require_field(&entry.id, "host", entry.host)?; - let access_code = require_field(&entry.id, "access_code", entry.access_code)?; - let sn = require_field(&entry.id, "sn", entry.sn)?; - let tls = BambuTls::resolve(entry.ca_cert_path.as_deref().map(Path::new), entry.require_valid_cert)?; - Ok(PrinterHandle::BambuV1(BambuV1Printer::new(&entry.id, &entry.name, &host, &access_code, &sn, tls))) - } - "bambu_v2" => { - let host = require_field(&entry.id, "host", entry.host)?; - let access_code = require_field(&entry.id, "access_code", entry.access_code)?; - let sn = require_field(&entry.id, "sn", entry.sn)?; - let tls = BambuTls::resolve(entry.ca_cert_path.as_deref().map(Path::new), entry.require_valid_cert)?; - Ok(PrinterHandle::BambuV2(BambuV2Printer::new(&entry.id, &entry.name, &host, &access_code, &sn, tls))) - } + "bambu" => build_bambu(entry, BambuProtocolPreference::Auto), + "bambu_v1" => build_bambu(entry, BambuProtocolPreference::ForceV1), + "bambu_v2" => build_bambu(entry, BambuProtocolPreference::ForceV2), "prusa_link" => { let host = require_field(&entry.id, "host", entry.host)?; let api_key = require_field(&entry.id, "api_key", entry.api_key)?; Ok(PrinterHandle::PrusaLink(PrusaLinkPrinter::new(&entry.id, &entry.name, &host, &api_key))) } "prusa_serial" => { - let com_port = entry.com_port.ok_or_else(|| anyhow::anyhow!("{}: prusa_serial needs com_port", entry.id))?; - Ok(PrinterHandle::PrusaSerial(PrusaSerialPrinter::new(&entry.id, &entry.name, com_port))) + let port = require_field(&entry.id, "port", entry.port)?; + Ok(PrinterHandle::PrusaSerial(PrusaSerialPrinter::new(&entry.id, &entry.name, &port, entry.baud_rate))) } "klipper" => { let host = require_field(&entry.id, "host", entry.host)?; @@ -85,6 +88,14 @@ fn build_printer(entry: PrinterEntry) -> anyhow::Result { } } +fn build_bambu(entry: PrinterEntry, preference: BambuProtocolPreference) -> anyhow::Result { + let host = require_field(&entry.id, "host", entry.host)?; + let access_code = require_field(&entry.id, "access_code", entry.access_code)?; + let sn = require_field(&entry.id, "sn", entry.sn)?; + let tls = BambuTls::resolve(entry.ca_cert_path.as_deref().map(Path::new), entry.require_valid_cert)?; + Ok(PrinterHandle::Bambu(BambuPrinter::new(&entry.id, &entry.name, &host, &access_code, &sn, tls, preference))) +} + fn require_field(printer_id: &str, field: &str, value: Option) -> anyhow::Result { value.ok_or_else(|| anyhow::anyhow!("{printer_id}: missing required field '{field}'")) } diff --git a/src/printer/bambu.rs b/src/printer/bambu.rs index cc14fe2..6d1f28e 100644 --- a/src/printer/bambu.rs +++ b/src/printer/bambu.rs @@ -1,9 +1,29 @@ use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::time::Duration; use super::{GenericPrinter, PrinterBase, PrinterError}; +// region: Certificates + +/// The canonical shape for every Bambu MQTT message lives in +/// continuum-schemas, not here - see `send_gcode_line` below, which builds +/// an instance of it directly via `continuum-schema-instance` instead of a +/// hand-written serde struct. `v1`/`v2` underneath this directory are +/// BambuMQTT's two message schemas, not to be confused with the MQTT +/// *transport* version (v3.1.1 vs v5) below - they happen to move +/// together on real Bambu firmware, but they're two different things. +const BAMBU_SCHEMA_DIR: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../continuum-schemas/schemas/bambulab" +); + +fn get_schema_for_command(version: BambuProtocolVersion, root: String, command: String) -> PathBuf { + Path::new(BAMBU_SCHEMA_DIR) + .join(version.schema_dir()) + .join(format!("request/{root}/{command}.schema.json")) +} + /// Bambu's shared LAN-mode root CA ("BBL CA2 RSA") - see certs/README.md /// for how this was obtained and verified. `include_bytes!` embeds it into /// the compiled binary at build time, so there's no file to ship alongside @@ -77,7 +97,10 @@ impl BambuTls { } fn resolve_addr(host: &str) -> anyhow::Result { - (host, 8883).to_socket_addrs()?.next().ok_or_else(|| anyhow::anyhow!("could not resolve {host}")) + (host, 8883) + .to_socket_addrs()? + .next() + .ok_or_else(|| anyhow::anyhow!("could not resolve {host}")) } /// Minimal DER -> PEM encoder (base64, 64-char lines, standard @@ -102,28 +125,88 @@ fn pem_to_der(pem: &[u8]) -> anyhow::Result> { Ok(native_tls::Certificate::from_pem(pem)?.to_der()?) } -/// Fields and behavior every Bambu printer shares, regardless of protocol -/// generation - the layer between `GenericPrinter` and the version-specific -/// `BambuV1Printer`/`BambuV2Printer` leaves below. Those two *have* one of -/// these (composition) rather than a deeper inheritance chain; because -/// Rust's field/method privacy is scoped to the *module* (this whole file), -/// not the struct, `BambuV1Printer`/`BambuV2Printer` can freely use -/// `BambuGenericPrinter`'s private fields even though they're separate -/// types. -pub struct BambuGenericPrinter { +// endregion: Certificates + +// region: BambuMQTTProtocol +/// Which BambuMQTT message schema (and, on real firmware, which MQTT +/// transport version) a connection ended up using. This is an *outcome* +/// `connect()` settles on, not something you can assume from the printer +/// model - a firmware update can move the same physical X1C from v1 to +/// v2. Every later message this printer sends is built against +/// `schemas/bambulab/{v1,v2}/` depending on which one this is. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BambuProtocolVersion { + /// MQTT v3.1.1, BambuMQTT v1 - older firmware (P1P/P1S, original X1 launch firmware). + V1, + /// MQTT v5, BambuMQTT v2 - current firmware. + V2, +} + +impl BambuProtocolVersion { + fn schema_dir(self) -> &'static str { + match self { + BambuProtocolVersion::V1 => "v1", + BambuProtocolVersion::V2 => "v2", + } + } +} + +/// How to pick the protocol generation for a given printer - config +/// *input*, as opposed to `BambuProtocolVersion` (the outcome `connect()` +/// settles on). `Auto` is the default for exactly the reason +/// `BambuProtocolVersion` isn't derivable from config alone: you can't +/// reliably know which one a specific printer speaks today without +/// asking it. `ForceV1`/`ForceV2` stay available as an override - useful +/// if auto-detection ever misbehaves, or to test one path deliberately. +#[derive(Debug, Clone, Copy)] +pub enum BambuProtocolPreference { + /// Try MQTT v5 first; if the broker's CONNACK rejects that protocol + /// version (older firmware only understands v3.1.1), fall back and + /// reconnect with v3.1.1 instead. + Auto, + ForceV1, + ForceV2, +} +// endregion: BambuMQTTProtocol + +/// A Bambu printer. Only one struct now, not two: the old +/// `BambuGenericPrinter` (shared fields/behavior) + `BambuV1Printer`/ +/// `BambuV2Printer` (thin per-version wrappers) split existed to let two +/// *separate types* share a base - but since which protocol version a +/// printer uses is now something `connect()` discovers at runtime rather +/// than something config picks between two types for, there's only ever +/// one type to begin with, so the extra layer stopped earning its keep. +pub struct BambuPrinter { base: PrinterBase, pub access_code: String, pub sn: String, tls: BambuTls, + preference: BambuProtocolPreference, + /// `None` until `connect()` succeeds. + version: Option, } -impl BambuGenericPrinter { - pub fn new(id: &str, name: &str, host: &str, access_code: &str, sn: &str, tls: BambuTls) -> Self { +impl BambuPrinter { + pub fn new( + id: &str, + name: &str, + host: &str, + access_code: &str, + sn: &str, + tls: BambuTls, + preference: BambuProtocolPreference, + ) -> Self { Self { - base: PrinterBase { id: id.into(), name: name.into(), host: host.into() }, + base: PrinterBase { + id: id.into(), + name: name.into(), + host: host.into(), + }, access_code: access_code.into(), sn: sn.into(), tls, + preference, + version: None, } } @@ -131,13 +214,7 @@ impl BambuGenericPrinter { &self.base } - /// Not part of the `GenericPrinter` trait - Prusa/Klipper have no - /// equivalent, since FTPS gcode transfer is a Bambu-specific thing. - pub fn dispatch_gcode(&self, file_name: &str) { - println!("[{}] uploading {file_name} over FTPS", self.base.name); - } - - /// A real (blocking, one-shot) TLS handshake to port 8883 - no MQTT + /* A real (blocking, one-shot) TLS handshake to port 8883 - no MQTT /// protocol, just "does the certificate verify" against this printer's /// configured trust mode. /// @@ -155,13 +232,15 @@ impl BambuGenericPrinter { /// the very next connection). So instead: connect with verification /// off, then compare the certificate actually presented against the /// exact bytes pinned, byte for byte. A mismatch is a hard failure - - /// no PKI judgment call, just "is this the same certificate as before." + /// no PKI judgment call, just "is this the same certificate as before." */ pub fn test_tls_handshake(&self) -> anyhow::Result<()> { let addr = resolve_addr(&self.base.host)?; let stream = TcpStream::connect_timeout(&addr, Duration::from_secs(5))?; if let BambuTls::Custom(pinned_pem) = &self.tls { - let tls_stream = BambuTls::Insecure.build_connector()?.connect(&self.base.host, stream)?; + let tls_stream = BambuTls::Insecure + .build_connector()? + .connect(&self.base.host, stream)?; let presented = tls_stream .peer_certificate()? .ok_or_else(|| anyhow::anyhow!("{} presented no certificate", self.base.name))? @@ -178,11 +257,13 @@ impl BambuGenericPrinter { return Ok(()); } - self.tls.build_connector()?.connect(&self.base.host, stream)?; + self.tls + .build_connector()? + .connect(&self.base.host, stream)?; Ok(()) } - /// "Trust on first connect": connects once with certificate + /* "Trust on first connect": connects once with certificate /// verification disabled, captures whatever certificate the printer /// presents, and returns it as PEM bytes. /// @@ -191,11 +272,13 @@ impl BambuGenericPrinter { /// trusted forever after. That's why it's a method you call once, by /// hand (see `examples/fetch_bambu_cert.rs`), rather than something /// `connect()` falls back to silently. Save the result to a file and - /// point that printer's `ca_cert_path` in `printers.toml` at it. + /// point that printer's `ca_cert_path` in `printers.toml` at it. */ pub fn fetch_certificate(&self) -> anyhow::Result> { let addr = resolve_addr(&self.base.host)?; let stream = TcpStream::connect_timeout(&addr, Duration::from_secs(5))?; - let tls_stream = BambuTls::Insecure.build_connector()?.connect(&self.base.host, stream)?; + let tls_stream = BambuTls::Insecure + .build_connector()? + .connect(&self.base.host, stream)?; let cert = tls_stream .peer_certificate()? @@ -204,7 +287,7 @@ impl BambuGenericPrinter { Ok(der_to_pem(&cert.to_der()?)) } - /// Makes sure this printer has a *working* trust configuration, + /* Makes sure this printer has a *working* trust configuration, /// auto-pinning a certificate via trust-on-first-connect if needed - /// the "smooth, no manual steps" version of the fetch_bambu_cert /// workflow, built the way SSH handles host keys: @@ -230,7 +313,7 @@ impl BambuGenericPrinter { /// in printers.toml) never auto-pins over it - a verification /// failure there is a real error, not something to paper over, /// since that's exactly the signal pinning exists to give you (the - /// cert rotated, or something worse). + /// cert rotated, or something worse). */ pub fn ensure_trusted(&mut self, cert_dir: &Path) -> anyhow::Result<()> { let pinned_path = cert_dir.join(format!("{}.pem", self.sn)); @@ -241,7 +324,10 @@ impl BambuGenericPrinter { match self.test_tls_handshake() { Ok(()) => Ok(()), Err(err) if matches!(self.tls, BambuTls::BundledCa) => { - eprintln!("{}: bundled CA didn't verify ({err}); auto-pinning on first connect", self.base.name); + eprintln!( + "{}: bundled CA didn't verify ({err}); auto-pinning on first connect", + self.base.name + ); let pem = self.fetch_certificate()?; std::fs::create_dir_all(cert_dir)?; std::fs::write(&pinned_path, &pem)?; @@ -251,113 +337,111 @@ impl BambuGenericPrinter { Err(err) => Err(err), } } +} - /// Shared by both `BambuV1Printer` and `BambuV2Printer` - same MQTT - /// gcode-injection mechanism regardless of protocol generation. Real - /// version would publish - /// `{"print":{"command":"gcode_line","param":"M106 P1 S"}}` to - /// `device/{sn}/request` over the MQTTS session. - async fn set_fan_speed_impl(&mut self, percent: u8) -> Result<(), PrinterError> { - let pwm = super::percent_to_pwm(percent); - println!("[{}] sending gcode: M106 P1 S{pwm}", self.base.name); - Ok(()) +impl GenericPrinter for BambuPrinter { + fn base(&self) -> &PrinterBase { + &self.base } - /// Shared "connect" logic both V1 and V2 delegate to - the real - /// version would open the MQTTS session here. Still a stub, but no - /// longer duplicated across two structs. - async fn connect_impl(&mut self) -> Result<(), PrinterError> { + /// Settles `self.version` - forced directly if `preference` says so, + /// otherwise (the real version) attempting an MQTT v5 CONNECT first + /// and falling back to v3.1.1 if the broker's CONNACK rejects that + /// protocol version. No real MQTT client exists yet (still a stub, no + /// different from before), so `Auto` always "detects" v2 for now - + /// the fallback attempt itself is what's left to build. + async fn connect(&mut self) -> Result<(), PrinterError> { let mode = match &self.tls { BambuTls::BundledCa => "bundled Bambu CA", BambuTls::Custom(_) => "custom CA cert", BambuTls::Insecure => "no certificate verification", }; - println!("[{}] connecting over MQTTS:8883 ({mode})", self.base.name); + + let version = match self.preference { + BambuProtocolPreference::ForceV1 => BambuProtocolVersion::V1, + BambuProtocolPreference::ForceV2 => BambuProtocolVersion::V2, + BambuProtocolPreference::Auto => { + println!( + "[{}] auto-detecting protocol: trying MQTT v5 first", + self.base.name + ); + BambuProtocolVersion::V2 + } + }; + + println!( + "[{}] connecting over MQTTS:8883 ({mode}, {version:?})", + self.base.name + ); + self.version = Some(version); Ok(()) } -} -/// Older Bambu MQTT report schema (P1P, P1S, original X1 firmware). -/// No V1-only fields yet - add them here as you learn what differs. -pub struct BambuV1Printer { - generic: BambuGenericPrinter, -} - -impl BambuV1Printer { - pub fn new(id: &str, name: &str, host: &str, access_code: &str, sn: &str, tls: BambuTls) -> Self { - Self { generic: BambuGenericPrinter::new(id, name, host, access_code, sn, tls) } + async fn publish(&mut self, payload: String) -> Result<(), PrinterError> { + // TODO: add publishing via MQTT + println!( + "[{}] publishing to device/{}/request: {payload}", + self.base.name, self.sn + ); + Ok(()) } - pub fn dispatch_gcode(&self, file_name: &str) { - self.generic.dispatch_gcode(file_name) - } - - pub fn test_tls_handshake(&self) -> anyhow::Result<()> { - self.generic.test_tls_handshake() - } - - pub fn fetch_certificate(&self) -> anyhow::Result> { - self.generic.fetch_certificate() - } - - pub fn ensure_trusted(&mut self, cert_dir: &Path) -> anyhow::Result<()> { - self.generic.ensure_trusted(cert_dir) - } -} - -impl GenericPrinter for BambuV1Printer { - fn base(&self) -> &PrinterBase { - self.generic.base() - } - - async fn connect(&mut self) -> Result<(), PrinterError> { - self.generic.connect_impl().await + async fn receive(&mut self, payload: String) -> Result<(), PrinterError> { + // TODO: add MQTT response handling + Ok(()) } + /// Same MQTT gcode-injection mechanism regardless of protocol + /// generation - just routed through `send_gcode_line` below instead of + /// its own separate stub, since that's genuinely the same operation. async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> { - self.generic.set_fan_speed_impl(percent).await - } -} - -/// Current-generation Bambu MQTT report schema (X1 Carbon/X1E, H2 series). -/// No V2-only fields yet (e.g. AMS slot state) - add them here as you build -/// out real report parsing. -pub struct BambuV2Printer { - generic: BambuGenericPrinter, -} - -impl BambuV2Printer { - pub fn new(id: &str, name: &str, host: &str, access_code: &str, sn: &str, tls: BambuTls) -> Self { - Self { generic: BambuGenericPrinter::new(id, name, host, access_code, sn, tls) } - } - - pub fn dispatch_gcode(&self, file_name: &str) { - self.generic.dispatch_gcode(file_name) - } - - pub fn test_tls_handshake(&self) -> anyhow::Result<()> { - self.generic.test_tls_handshake() - } - - pub fn fetch_certificate(&self) -> anyhow::Result> { - self.generic.fetch_certificate() - } - - pub fn ensure_trusted(&mut self, cert_dir: &Path) -> anyhow::Result<()> { - self.generic.ensure_trusted(cert_dir) - } -} - -impl GenericPrinter for BambuV2Printer { - fn base(&self) -> &PrinterBase { - self.generic.base() - } - - async fn connect(&mut self) -> Result<(), PrinterError> { - self.generic.connect_impl().await - } - - async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> { - self.generic.set_fan_speed_impl(percent).await + let pwm = super::percent_to_pwm(percent); + self.send_gcode_line(format!("M106 P1 S{pwm}")).await + } + + /// Real version FTPS-`PUT`s `data` to the printer's own `/cache/` + /// directory (that's storage the printer's firmware owns, same idea + /// as PrusaLink's SD card), then publishes a `project_file` MQTT + /// command referencing it by name so the printer actually starts + /// printing it - two separate steps, both stubbed here as one line + /// for now. + async fn upload_project_file( + &mut self, + file_name: String, + data: Vec, + ) -> Result<(), PrinterError> { + println!( + "[{}] FTPS upload {file_name} ({} bytes) to /cache/", + self.base.name, + data.len() + ); + Ok(()) + } + + /// Builds the real JSON payload straight from continuum-schemas' + /// `schemas/bambulab/{v1,v2}/request/print/gcode_line.schema.json` - + /// which one depends on `self.version`, settled by `connect()` above - + /// instead of a hand-written serde struct: `sequence_id` and the + /// `command` const get filled in by the schema itself, `param` is the + /// one field we actually set. Only the actual MQTT publish to + /// `device/{sn}/request` is still a stub, same as `connect` above. + async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError> { + let version = self + .version + .ok_or_else(|| PrinterError(format!("{} is not connected", self.base.name)))?; + + let mut print = + continuum_schema_instance::build_instance_from_file( + &get_schema_for_command( + version, + "print".into(), + "gcode_line".into() + ) + ) + .map_err(|err| PrinterError(format!("failed to build gcode_line request: {err}")))?; + print["param"] = serde_json::Value::String(gcode_line); + + let payload = serde_json::json!({ "print": print }).to_string(); + self.publish(payload).await } } diff --git a/src/printer/bambu_commands/mod.rs b/src/printer/bambu_commands/mod.rs index 86d5f53..e97195f 100644 --- a/src/printer/bambu_commands/mod.rs +++ b/src/printer/bambu_commands/mod.rs @@ -49,6 +49,12 @@ pub struct CommandEnvelope { /// category can have more than one possible request shape - if that ever /// happens here, that variant's payload becomes its own small /// `#[serde(tag = "command")]` enum instead of a single struct. +/// +/// The "print" category isn't a variant here at all: unlike `info`/ +/// `pushing`, it has many possible request shapes (`gcode_line`, `pause`, +/// `resume`, ...) in continuum-schemas' own `oneOf`, so +/// `bambu.rs`'s `send_gcode_line_impl` builds that one directly via +/// `continuum-schema-instance` instead of adding it here. #[derive(serde::Serialize, Debug)] #[serde(rename_all = "snake_case")] pub enum BambuRequest { diff --git a/src/printer/klipper.rs b/src/printer/klipper.rs index 23f38e2..7b5e2de 100644 --- a/src/printer/klipper.rs +++ b/src/printer/klipper.rs @@ -29,6 +29,15 @@ impl GenericPrinter for KlipperPrinter { Ok(()) } + async fn publish(&mut self, payload: String) -> Result<(), PrinterError> { + // TODO: add publishing via HTTP + Ok(()) + } + + async fn receive(&mut self, payload: String) -> Result<(), PrinterError> { + // TODO: add HTTP response handling + Ok(()) + } /// OVERRIDE: Moonraker's own **hypothetical** native API, not gcode injection - no /// percent-to-PWM conversion needed since it already takes a /// percentage directly. Real version: @@ -37,4 +46,20 @@ impl GenericPrinter for KlipperPrinter { println!("[{}] Moonraker: SET_FAN_SPEED SPEED={:.2}", self.base.name, percent as f32 / 100.0); Ok(()) } + + /// OVERRIDE: real version is a multipart `POST /server/files/upload` + /// with `data` as the file part - Moonraker stores it on the printer's + /// virtual SD card, same upload-then-start model as Bambu/PrusaLink. + async fn upload_project_file(&mut self, file_name: String, data: Vec) -> Result<(), PrinterError> { + println!("[{}] POST /server/files/upload {file_name} ({} bytes)", self.base.name, data.len()); + Ok(()) + } + + /// OVERRIDE: Moonraker's gcode-script endpoint, one line per call - same + /// idea as `set_fan_speed`'s `SET_FAN_SPEED` call above, just with an + /// arbitrary line instead of a hardcoded one. + async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError> { + println!("[{}] POST /printer/gcode/script?script={gcode_line}", self.base.name); + Ok(()) + } } diff --git a/src/printer/marlin_serial.rs b/src/printer/marlin_serial.rs new file mode 100644 index 0000000..1969acc --- /dev/null +++ b/src/printer/marlin_serial.rs @@ -0,0 +1,69 @@ +//! A held-open serial connection to Marlin/RepRap-family firmware - usable +//! by any printer connected over a raw serial port, since there's no +//! printer-side storage to bulk-upload to the way there is for Bambu/ +//! PrusaLink/Klipper (see `prusa.rs`'s `PrusaSerialPrinter`). +//! +//! Marlin only ever buffers a handful of commands at once, so lines have +//! to be paced by its own "ok" acknowledgment - send faster than it acks +//! and you overrun that buffer, you don't just queue up harmlessly. That +//! write-a-line/wait-for-"ok" pattern is the one new idea here. Splitting +//! the connection into a read half and a write half so both can be used at +//! once isn't new - `uplink/mod.rs` already does the same thing +//! (`ws_stream.split()`) for the WebSocket to `continuum-backend`. +//! +//! The connection has to stay open across many `send_line` calls, not +//! reopen per line: opening/closing per line would be wasteful, and on +//! Arduino-based boards toggling the serial line's DTR signal (which +//! opening the port does) resets the printer. + +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines, ReadHalf, WriteHalf}; +use tokio_serial::{SerialPortBuilderExt, SerialStream}; + +use super::PrinterError; + +pub struct MarlinSerialConnection { + write_half: WriteHalf, + lines_in: Lines>>, +} + +impl MarlinSerialConnection { + /// Opens `port` (a serial device path - e.g. `/dev/ttyACM0` on Linux) at + /// `baud_rate`. + pub async fn open(port: &str, baud_rate: u32) -> Result { + let stream = tokio_serial::new(port, baud_rate) + .open_native_async() + .map_err(|err| PrinterError(format!("failed to open {port}: {err}")))?; + + // Same shape as `ws_stream.split()` in uplink/mod.rs: one + // connection, used for writing gcode out and reading + // acknowledgments back at the same time. + let (read_half, write_half) = tokio::io::split(stream); + let lines_in = BufReader::new(read_half).lines(); + + Ok(Self { write_half, lines_in }) + } + + /// Sends one gcode line and waits for Marlin's "ok" before returning. + pub async fn send_line(&mut self, line: &str) -> Result<(), PrinterError> { + self.write_half + .write_all(format!("{line}\n").as_bytes()) + .await + .map_err(|err| PrinterError(format!("write failed: {err}")))?; + + // Marlin also sends unsolicited lines between commands + // (temperature reports, "echo:" messages) - keep reading until the + // real "ok" shows up instead of treating the first response as it. + loop { + let response = self + .lines_in + .next_line() + .await + .map_err(|err| PrinterError(format!("read failed: {err}")))? + .ok_or_else(|| PrinterError("serial connection closed".into()))?; + + if response.starts_with("ok") { + return Ok(()); + } + } + } +} diff --git a/src/printer/mod.rs b/src/printer/mod.rs index a925439..57580dc 100644 --- a/src/printer/mod.rs +++ b/src/printer/mod.rs @@ -2,12 +2,10 @@ //! `GenericPrinter -> BambuV1/BambuV2/PrusaLink/PrusaSerial/Klipper`-style //! inheritance, since Rust structs can't `extend` each other. //! -//! Bambu goes one level deeper than the others: `bambu.rs` has -//! `BambuGenericPrinter` (fields + behavior every Bambu printer shares - -//! access code, CA trust, the TLS handshake test/fetch) which -//! `BambuV1Printer`/`BambuV2Printer` each *have* one of, mirroring the -//! `GenericPrinter -> BambuGenericPrinter -> BambuV1/V2` chain this project -//! actually wanted from the start - see that file's doc comment. +//! Bambu is a single `BambuPrinter` type, not a per-version split: which +//! MQTT/BambuMQTT protocol generation (v1 vs v2) a given printer uses is +//! something `connect()` discovers (or is told to force), not something +//! config picks a *type* for - see `bambu.rs`'s doc comment. //! //! Three pieces, each doing one job an OOP base class would normally do: //! @@ -31,9 +29,10 @@ mod bambu; mod bambu_commands; mod klipper; +mod marlin_serial; mod prusa; -pub use bambu::{BambuGenericPrinter, BambuTls, BambuV1Printer, BambuV2Printer}; +pub use bambu::{BambuPrinter, BambuProtocolPreference, BambuProtocolVersion, BambuTls}; pub use bambu_commands::{ AmsState, AmsTray, AmsUnit, BambuReport, BambuRequest, CommandEnvelope, EmptyTray, GetVersionRequest, GetVersionResponse, LoadedTray, PrintReport, PushallRequest, XcamSettings, @@ -72,12 +71,34 @@ pub trait GenericPrinter { /// into the multi-threaded daemon - safe to ignore for now. async fn connect(&mut self) -> Result<(), PrinterError>; + + async fn publish(&mut self, payload: String) -> Result<(), PrinterError>; + async fn receive(&mut self, payload: String) -> Result<(), PrinterError>; + /// REQUIRED, no default body: `percent` is 0-100, but how it actually /// gets sent - an MQTT gcode-injection command, a REST call, raw bytes /// on a serial port - is different for every vendor, same as /// `connect()`. async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError>; + /// REQUIRED, no default body: takes the *whole file* already in memory + /// (`data`), not a path - whatever called this already had to download + /// it from the S3 URL the cloud handed over, so there'd be nothing on + /// disk to point a path at anyway. Each vendor does something quite + /// different with `data`: Bambu/PrusaLink/Klipper each do one bulk + /// transfer to storage the printer's own firmware owns; PrusaSerial + /// has no such storage at all - see that impl's doc comment. + async fn upload_project_file(&mut self, file_name: String, data: Vec) -> Result<(), PrinterError>; + + /// REQUIRED, no default body: sends exactly *one* gcode line, using + /// whatever single-command mechanism this vendor has (an MQTT + /// `gcode_line` publish for Bambu, an HTTP command-injection endpoint + /// for PrusaLink/Klipper, a raw write over the port for PrusaSerial). + /// `upload_gcode` below is the generic driver that calls this once per + /// line for a whole file - this method is where each vendor's actual + /// difference lives, same division of labor as every other method here. + async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError>; + /// OPTIONAL: a default body every printer gets for free unless it /// writes its own. None of ours do below, so all three vendors share /// this exact implementation - the trait-method equivalent of @@ -100,8 +121,7 @@ fn percent_to_pwm(percent: u8) -> u8 { /// The closed set of printer kinds this fleet can talk to. pub enum PrinterHandle { - BambuV1(BambuV1Printer), - BambuV2(BambuV2Printer), + Bambu(BambuPrinter), PrusaLink(PrusaLinkPrinter), PrusaSerial(PrusaSerialPrinter), Klipper(KlipperPrinter), @@ -114,8 +134,7 @@ pub enum PrinterHandle { impl GenericPrinter for PrinterHandle { fn base(&self) -> &PrinterBase { match self { - PrinterHandle::BambuV1(p) => p.base(), - PrinterHandle::BambuV2(p) => p.base(), + PrinterHandle::Bambu(p) => p.base(), PrinterHandle::PrusaLink(p) => p.base(), PrinterHandle::PrusaSerial(p) => p.base(), PrinterHandle::Klipper(p) => p.base(), @@ -124,21 +143,82 @@ impl GenericPrinter for PrinterHandle { async fn connect(&mut self) -> Result<(), PrinterError> { match self { - PrinterHandle::BambuV1(p) => p.connect().await, - PrinterHandle::BambuV2(p) => p.connect().await, + PrinterHandle::Bambu(p) => p.connect().await, PrinterHandle::PrusaLink(p) => p.connect().await, PrinterHandle::PrusaSerial(p) => p.connect().await, PrinterHandle::Klipper(p) => p.connect().await, } } + async fn publish(&mut self, payload: String) -> Result<(), PrinterError> { + match self { + PrinterHandle::Bambu(p) => p.publish(payload).await, + PrinterHandle::PrusaLink(p) => p.publish(payload).await, + PrinterHandle::PrusaSerial(p) => p.publish(payload).await, + PrinterHandle::Klipper(p) => p.publish(payload).await, + } + } + + async fn receive(&mut self, payload: String) -> Result<(), PrinterError> { + match self { + PrinterHandle::Bambu(p) => p.receive(payload).await, + PrinterHandle::PrusaLink(p) => p.receive(payload).await, + PrinterHandle::PrusaSerial(p) => p.receive(payload).await, + PrinterHandle::Klipper(p) => p.receive(payload).await, + } + } + async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> { match self { - PrinterHandle::BambuV1(p) => p.set_fan_speed(percent).await, - PrinterHandle::BambuV2(p) => p.set_fan_speed(percent).await, + PrinterHandle::Bambu(p) => p.set_fan_speed(percent).await, PrinterHandle::PrusaLink(p) => p.set_fan_speed(percent).await, PrinterHandle::PrusaSerial(p) => p.set_fan_speed(percent).await, PrinterHandle::Klipper(p) => p.set_fan_speed(percent).await, } } + + async fn upload_project_file(&mut self, file_name: String, data: Vec) -> Result<(), PrinterError> { + match self { + PrinterHandle::Bambu(p) => p.upload_project_file(file_name, data).await, + PrinterHandle::PrusaLink(p) => p.upload_project_file(file_name, data).await, + PrinterHandle::PrusaSerial(p) => p.upload_project_file(file_name, data).await, + PrinterHandle::Klipper(p) => p.upload_project_file(file_name, data).await, + } + } + + async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError> { + match self { + PrinterHandle::Bambu(p) => p.send_gcode_line(gcode_line).await, + PrinterHandle::PrusaLink(p) => p.send_gcode_line(gcode_line).await, + PrinterHandle::PrusaSerial(p) => p.send_gcode_line(gcode_line).await, + PrinterHandle::Klipper(p) => p.send_gcode_line(gcode_line).await, + } + } +} + +/// Splits `gcode` into lines and sends each one through `printer`'s own +/// `send_gcode_line` - works for *any* vendor, not just serial ones, since +/// Bambu/PrusaLink/Klipper each have their own single-line injection +/// mechanism too (see each `send_gcode_line` impl). They just don't use +/// this for `upload_project_file` - they have real file storage to bulk- +/// upload to instead, which is a different, faster path than sending a +/// whole file one line at a time. +/// +/// `P: GenericPrinter` makes this a genuine Rust generic: one function +/// body, compiled separately for whichever concrete printer type you call +/// it with - like a TS function typed `

`, except +/// Rust generates real, separate machine code per type instead of erasing +/// the type at compile time. Call it with any of the five printer structs, +/// or with `&mut PrinterHandle` itself (it implements `GenericPrinter` too, +/// via the delegation above). +pub async fn upload_gcode(printer: &mut P, gcode: &str) -> Result<(), PrinterError> { + for line in gcode.lines() { + let line = line.trim(); + // Blank lines and full-line comments have nothing to send. + if line.is_empty() || line.starts_with(';') { + continue; + } + printer.send_gcode_line(line.to_string()).await?; + } + Ok(()) } diff --git a/src/printer/prusa.rs b/src/printer/prusa.rs index 5168ef3..c30af19 100644 --- a/src/printer/prusa.rs +++ b/src/printer/prusa.rs @@ -29,6 +29,16 @@ impl GenericPrinter for PrusaLinkPrinter { Ok(()) } + async fn publish(&mut self, payload: String) -> Result<(), PrinterError> { + // TODO: add publishing via HTTP + Ok(()) + } + + async fn receive(&mut self, payload: String) -> Result<(), PrinterError> { + // TODO: add HTTP response handling + Ok(()) + } + /// OVERRIDE: gcode via PrusaLink's command-injection endpoint - same /// underlying M106 as Bambu, reusing the shared percent->PWM /// conversion, but sent over HTTP instead of MQTT. @@ -37,6 +47,22 @@ impl GenericPrinter for PrusaLinkPrinter { println!("[{}] POST /api/printer/command {{\"commands\":[\"M106 S{pwm}\"]}}", self.base.name); Ok(()) } + + /// OVERRIDE: real version is `PUT /api/v1/files/local/{file_name}` with + /// `data` as the request body - PrusaLink stores it on the printer's own + /// SD card, same "upload to storage the firmware owns" model as Bambu. + async fn upload_project_file(&mut self, file_name: String, data: Vec) -> Result<(), PrinterError> { + println!("[{}] PUT /api/v1/files/local/{file_name} ({} bytes)", self.base.name, data.len()); + Ok(()) + } + + /// OVERRIDE: PrusaLink's command-injection endpoint, one line per call - + /// same endpoint `set_fan_speed` above already uses for M106, just with + /// an arbitrary line instead of a hardcoded one. + async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError> { + println!("[{}] POST /api/printer/command {{\"commands\":[\"{gcode_line}\"]}}", self.base.name); + Ok(()) + } } @@ -44,14 +70,22 @@ impl GenericPrinter for PrusaLinkPrinter { pub struct PrusaSerialPrinter { base: PrinterBase, - pub com_port: i16, + pub port: String, + pub baud_rate: u32, + /// `None` until `connect()` succeeds - a real serial connection, not a + /// stub, so there's something to hold onto: unlike Bambu/PrusaLink/ + /// Klipper's stubbed sessions, this one has to survive across many + /// `send_gcode_line` calls (see `marlin_serial.rs` for why). + connection: Option, } impl PrusaSerialPrinter { - pub fn new(id: &str, name: &str, com_port: i16) -> Self { + pub fn new(id: &str, name: &str, port: &str, baud_rate: u32) -> Self { Self { base: PrinterBase { id: id.into(), name: name.into(), host: "127.0.0.1".to_string() }, - com_port, + port: port.into(), + baud_rate, + connection: None, } } } @@ -61,21 +95,55 @@ impl GenericPrinter for PrusaSerialPrinter { &self.base } - /// OVERRIDE: a fourth, again-different body - no network host at all, - /// just a local serial port (real version: open the port, e.g. via the - /// `serialport`/`tokio-serial` crate, and speak Prusa's G-code-over- - /// serial protocol). + /// OVERRIDE, and a real one (not a stub, unlike the other four vendors' + /// `connect`): opens the port and keeps it, since serial has no + /// separate "session" concept to stub out - the open connection *is* + /// the whole thing. async fn connect(&mut self) -> Result<(), PrinterError> { - println!("[{}] connecting over serial (COM{})", self.base.name, self.com_port); + println!("[{}] connecting over serial ({}, {} baud)", self.base.name, self.port, self.baud_rate); + self.connection = Some(super::marlin_serial::MarlinSerialConnection::open(&self.port, self.baud_rate).await?); Ok(()) } - /// OVERRIDE: same gcode as PrusaLink (still M106, still the shared - /// conversion), but written straight to the serial port instead of - /// sent over HTTP - a third transport for the same underlying command. - async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> { - let pwm = super::percent_to_pwm(percent); - println!("[{}] writing to COM{}: M106 S{pwm}", self.base.name, self.com_port); + async fn publish(&mut self, payload: String) -> Result<(), PrinterError> { + // TODO: add publishing via serial Ok(()) } + + async fn receive(&mut self, payload: String) -> Result<(), PrinterError> { + // TODO: add Serial response handling + Ok(()) + } + /// OVERRIDE: same gcode as PrusaLink (still M106, still the shared + /// conversion), but sent through `send_gcode_line` below instead of a + /// separate transport - serial only has the one way to talk to the + /// printer at all. + async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> { + let pwm = super::percent_to_pwm(percent); + self.send_gcode_line(format!("M106 S{pwm}")).await + } + + /// OVERRIDE, but not just "a fourth transport" like the others above - + /// there's no printer-side storage to upload `data` to over a raw + /// serial connection at all, so this decodes it as gcode text and + /// streams it through `send_gcode_line` one line at a time via the + /// generic `upload_gcode` driver, instead of one bulk write like + /// Bambu/PrusaLink/Klipper. + async fn upload_project_file(&mut self, file_name: String, data: Vec) -> Result<(), PrinterError> { + let gcode = String::from_utf8(data) + .map_err(|_| PrinterError(format!("{file_name} is not valid UTF-8 gcode")))?; + println!("[{}] streaming {file_name} over {}", self.base.name, self.port); + super::upload_gcode(self, &gcode).await + } + + /// OVERRIDE, and the real implementation, not a stub: writes `gcode_line` + /// to the already-open port and waits for Marlin's "ok" - see + /// `marlin_serial.rs` for why that wait matters. + async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError> { + let connection = self + .connection + .as_mut() + .ok_or_else(|| PrinterError(format!("{} is not connected", self.base.name)))?; + connection.send_line(&gcode_line).await + } }