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<P> 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).
This commit is contained in:
2026-09-05 20:40:21 +00:00
parent a2d4e578ec
commit b352fd618d
13 changed files with 559 additions and 203 deletions
+14 -7
View File
@@ -34,6 +34,18 @@ name = "bambu_commands_demo"
path = "examples/bambu_commands_demo.rs" path = "examples/bambu_commands_demo.rs"
[dependencies] [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 = { version = "1.40", features = ["full"] }
tokio-tungstenite = { version = "0.24", features = ["native-tls"] } tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
futures-util = "0.3" futures-util = "0.3"
@@ -47,10 +59,5 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
anyhow = "1" anyhow = "1"
thiserror = "1" thiserror = "1"
dotenvy = "0.15" dotenvy = "0.15"
tokio-serial = { version = "5.5.0", default-features = false }
# Real printer protocol clients (MQTT for Bambu, HTTP for PrusaLink/ rumqttc = "0.25.1"
# 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" }
+1 -2
View File
@@ -17,8 +17,7 @@ fn main() -> anyhow::Result<()> {
for printer in &mut printers { for printer in &mut printers {
match printer { match printer {
PrinterHandle::BambuV1(bambu) => report(&bambu.ensure_trusted(cert_dir)), PrinterHandle::Bambu(bambu) => report(&bambu.ensure_trusted(cert_dir)),
PrinterHandle::BambuV2(bambu) => report(&bambu.ensure_trusted(cert_dir)),
_ => {} _ => {}
} }
} }
+1 -2
View File
@@ -23,8 +23,7 @@ fn main() -> anyhow::Result<()> {
let pem = printers let pem = printers
.iter() .iter()
.find_map(|printer| match printer { .find_map(|printer| match printer {
PrinterHandle::BambuV1(bambu) if bambu.base().id == printer_id => Some(bambu.fetch_certificate()), PrinterHandle::Bambu(bambu) if bambu.base().id == printer_id => Some(bambu.fetch_certificate()),
PrinterHandle::BambuV2(bambu) if bambu.base().id == printer_id => Some(bambu.fetch_certificate()),
_ => None, _ => None,
}) })
.ok_or_else(|| anyhow::anyhow!("no Bambu printer with id '{printer_id}' in printers.toml"))??; .ok_or_else(|| anyhow::anyhow!("no Bambu printer with id '{printer_id}' in printers.toml"))??;
+15 -15
View File
@@ -4,36 +4,40 @@
//! - `connect()` is called the same way regardless of vendor (polymorphism), //! - `connect()` is called the same way regardless of vendor (polymorphism),
//! but each variant's `impl GenericPrinter` runs completely different //! but each variant's `impl GenericPrinter` runs completely different
//! code (override). //! code (override).
//! - `dispatch_gcode()` only exists on the two Bambu structs (extension) - //! - `dispatch_gcode()` only exists on the Bambu struct (extension) - you
//! you have to `match` the enum back down to the concrete type to reach //! have to `match` the enum back down to the concrete type to reach it,
//! it, which is the trade-off for not having implicit downcasting. //! which is the trade-off for not having implicit downcasting.
use continuum_proxy::printer::{ use continuum_proxy::printer::{
BambuTls, BambuV1Printer, BambuV2Printer, GenericPrinter, KlipperPrinter, PrinterHandle, PrusaLinkPrinter, BambuPrinter, BambuProtocolPreference, BambuTls, GenericPrinter, KlipperPrinter, PrinterHandle,
PrusaSerialPrinter, PrusaLinkPrinter, PrusaSerialPrinter,
}; };
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let mut fleet: Vec<PrinterHandle> = vec![ let mut fleet: Vec<PrinterHandle> = vec![
PrinterHandle::BambuV1(BambuV1Printer::new( // Forced: a known-old printer.
PrinterHandle::Bambu(BambuPrinter::new(
"p1", "p1",
"P1S", "P1S",
"192.168.1.49", "192.168.1.49",
"87654321", "87654321",
"01P00A000000000", "01P00A000000000",
BambuTls::BundledCa, BambuTls::BundledCa,
BambuProtocolPreference::ForceV1,
)), )),
PrinterHandle::BambuV2(BambuV2Printer::new( // Auto: the recommended default - see bambu.rs's doc comment.
PrinterHandle::Bambu(BambuPrinter::new(
"p2", "p2",
"X1 Carbon", "X1 Carbon",
"192.168.1.50", "192.168.1.50",
"12345678", "12345678",
"00M00A000000000", "00M00A000000000",
BambuTls::BundledCa, BambuTls::BundledCa,
BambuProtocolPreference::Auto,
)), )),
PrinterHandle::PrusaLink(PrusaLinkPrinter::new("p3", "MK4", "192.168.1.51", "prusa-api-key")), 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")), PrinterHandle::Klipper(KlipperPrinter::new("p5", "Voron 2.4", "192.168.1.52")),
// No host configured - this one will hit the Err path. // No host configured - this one will hit the Err path.
PrinterHandle::Klipper(KlipperPrinter::new("p6", "Broken Voron", "")), PrinterHandle::Klipper(KlipperPrinter::new("p6", "Broken Voron", "")),
@@ -50,15 +54,11 @@ async fn main() {
} }
// Vendor-only extension: reachable only after matching the concrete // Vendor-only extension: reachable only after matching the concrete
// variant back out of the enum. BambuV1Printer and BambuV2Printer are // variant back out of the enum - one arm now that Bambu is a single
// separate types now (each *has* a BambuGenericPrinter rather than // type regardless of which protocol version `connect()` settled on.
// 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.
for printer in &fleet { for printer in &fleet {
match printer { match printer {
PrinterHandle::BambuV1(bambu) => bambu.dispatch_gcode("part.gcode.3mf"), PrinterHandle::Bambu(bambu) => bambu.dispatch_gcode("part.gcode.3mf"),
PrinterHandle::BambuV2(bambu) => bambu.dispatch_gcode("part.gcode.3mf"),
_ => {} _ => {}
} }
} }
+1 -5
View File
@@ -12,13 +12,9 @@ use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
fn main() -> anyhow::Result<()> { fn main() -> anyhow::Result<()> {
let printers = fleet::load(std::path::Path::new("printers.toml"))?; 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 { for printer in &printers {
match printer { match printer {
PrinterHandle::BambuV1(bambu) => report(bambu.base().name.as_str(), bambu.test_tls_handshake()), PrinterHandle::Bambu(bambu) => report(bambu.base().name.as_str(), bambu.test_tls_handshake()),
PrinterHandle::BambuV2(bambu) => report(bambu.base().name.as_str(), bambu.test_tls_handshake()),
_ => {} _ => {}
} }
} }
+14 -2
View File
@@ -5,7 +5,9 @@
[[printer]] [[printer]]
id = "p1p" id = "p1p"
name = "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" host = "192.168.1.50"
access_code = "REPLACE_WITH_ACCESS_CODE" access_code = "REPLACE_WITH_ACCESS_CODE"
sn = "REPLACE_WITH_SERIAL_NUMBER" # printer screen > Settings > Device (used for MQTT topics later) 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]] [[printer]]
id = "h2c" id = "h2c"
name = "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" host = "192.168.1.51"
access_code = "REPLACE_WITH_ACCESS_CODE" access_code = "REPLACE_WITH_ACCESS_CODE"
sn = "REPLACE_WITH_SERIAL_NUMBER" sn = "REPLACE_WITH_SERIAL_NUMBER"
@@ -37,3 +42,10 @@ sn = "REPLACE_WITH_SERIAL_NUMBER"
# vendor = "prusa_link" # vendor = "prusa_link"
# host = "192.168.1.53" # host = "192.168.1.53"
# api_key = "REPLACE_WITH_API_KEY" # api_key = "REPLACE_WITH_API_KEY"
# [[printer]]
# id = "mk3s"
# name = "MK3S+"
# vendor = "prusa_serial"
# port = "/dev/ttyACM0"
# baud_rate = 115200 # optional, defaults to 115200
+30 -19
View File
@@ -7,7 +7,10 @@ use std::path::Path;
use serde::Deserialize; 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)] #[derive(Debug, Deserialize)]
struct FleetFile { struct FleetFile {
@@ -19,7 +22,10 @@ struct FleetFile {
struct PrinterEntry { struct PrinterEntry {
id: String, id: String,
name: 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, vendor: String,
host: Option<String>, host: Option<String>,
access_code: Option<String>, access_code: Option<String>,
@@ -27,7 +33,10 @@ struct PrinterEntry {
/// by serial (`device/{sn}/report`), not by our own `id`. /// by serial (`device/{sn}/report`), not by our own `id`.
sn: Option<String>, sn: Option<String>,
api_key: Option<String>, api_key: Option<String>,
com_port: Option<i16>, /// Serial device path, e.g. `/dev/ttyACM0` on Linux.
port: Option<String>,
#[serde(default = "default_baud_rate")]
baud_rate: u32,
/// Path to a printer-specific CA certificate. Leave unset to use the /// 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 /// bundled Bambu CA - only needed for a printer that doesn't chain to
/// it (P1P, at least; see certs/README.md). /// it (P1P, at least; see certs/README.md).
@@ -40,6 +49,11 @@ fn default_true() -> bool {
true 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. /// Reads and parses a printers file into ready-to-use `PrinterHandle`s.
pub fn load(path: &Path) -> anyhow::Result<Vec<PrinterHandle>> { pub fn load(path: &Path) -> anyhow::Result<Vec<PrinterHandle>> {
let text = std::fs::read_to_string(path).map_err(|err| { let text = std::fs::read_to_string(path).map_err(|err| {
@@ -54,28 +68,17 @@ pub fn load(path: &Path) -> anyhow::Result<Vec<PrinterHandle>> {
fn build_printer(entry: PrinterEntry) -> anyhow::Result<PrinterHandle> { fn build_printer(entry: PrinterEntry) -> anyhow::Result<PrinterHandle> {
match entry.vendor.as_str() { match entry.vendor.as_str() {
"bambu_v1" => { "bambu" => build_bambu(entry, BambuProtocolPreference::Auto),
let host = require_field(&entry.id, "host", entry.host)?; "bambu_v1" => build_bambu(entry, BambuProtocolPreference::ForceV1),
let access_code = require_field(&entry.id, "access_code", entry.access_code)?; "bambu_v2" => build_bambu(entry, BambuProtocolPreference::ForceV2),
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)))
}
"prusa_link" => { "prusa_link" => {
let host = require_field(&entry.id, "host", entry.host)?; let host = require_field(&entry.id, "host", entry.host)?;
let api_key = require_field(&entry.id, "api_key", entry.api_key)?; let api_key = require_field(&entry.id, "api_key", entry.api_key)?;
Ok(PrinterHandle::PrusaLink(PrusaLinkPrinter::new(&entry.id, &entry.name, &host, &api_key))) Ok(PrinterHandle::PrusaLink(PrusaLinkPrinter::new(&entry.id, &entry.name, &host, &api_key)))
} }
"prusa_serial" => { "prusa_serial" => {
let com_port = entry.com_port.ok_or_else(|| anyhow::anyhow!("{}: prusa_serial needs com_port", entry.id))?; let port = require_field(&entry.id, "port", entry.port)?;
Ok(PrinterHandle::PrusaSerial(PrusaSerialPrinter::new(&entry.id, &entry.name, com_port))) Ok(PrinterHandle::PrusaSerial(PrusaSerialPrinter::new(&entry.id, &entry.name, &port, entry.baud_rate)))
} }
"klipper" => { "klipper" => {
let host = require_field(&entry.id, "host", entry.host)?; let host = require_field(&entry.id, "host", entry.host)?;
@@ -85,6 +88,14 @@ fn build_printer(entry: PrinterEntry) -> anyhow::Result<PrinterHandle> {
} }
} }
fn build_bambu(entry: PrinterEntry, preference: BambuProtocolPreference) -> anyhow::Result<PrinterHandle> {
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<String>) -> anyhow::Result<String> { fn require_field(printer_id: &str, field: &str, value: Option<String>) -> anyhow::Result<String> {
value.ok_or_else(|| anyhow::anyhow!("{printer_id}: missing required field '{field}'")) value.ok_or_else(|| anyhow::anyhow!("{printer_id}: missing required field '{field}'"))
} }
+206 -122
View File
@@ -1,9 +1,29 @@
use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
use std::path::Path; use std::path::{Path, PathBuf};
use std::time::Duration; use std::time::Duration;
use super::{GenericPrinter, PrinterBase, PrinterError}; 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 /// 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 /// 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 /// 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<SocketAddr> { fn resolve_addr(host: &str) -> anyhow::Result<SocketAddr> {
(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 /// Minimal DER -> PEM encoder (base64, 64-char lines, standard
@@ -102,28 +125,88 @@ fn pem_to_der(pem: &[u8]) -> anyhow::Result<Vec<u8>> {
Ok(native_tls::Certificate::from_pem(pem)?.to_der()?) Ok(native_tls::Certificate::from_pem(pem)?.to_der()?)
} }
/// Fields and behavior every Bambu printer shares, regardless of protocol // endregion: Certificates
/// generation - the layer between `GenericPrinter` and the version-specific
/// `BambuV1Printer`/`BambuV2Printer` leaves below. Those two *have* one of // region: BambuMQTTProtocol
/// these (composition) rather than a deeper inheritance chain; because /// Which BambuMQTT message schema (and, on real firmware, which MQTT
/// Rust's field/method privacy is scoped to the *module* (this whole file), /// transport version) a connection ended up using. This is an *outcome*
/// not the struct, `BambuV1Printer`/`BambuV2Printer` can freely use /// `connect()` settles on, not something you can assume from the printer
/// `BambuGenericPrinter`'s private fields even though they're separate /// model - a firmware update can move the same physical X1C from v1 to
/// types. /// v2. Every later message this printer sends is built against
pub struct BambuGenericPrinter { /// `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, base: PrinterBase,
pub access_code: String, pub access_code: String,
pub sn: String, pub sn: String,
tls: BambuTls, tls: BambuTls,
preference: BambuProtocolPreference,
/// `None` until `connect()` succeeds.
version: Option<BambuProtocolVersion>,
} }
impl BambuGenericPrinter { impl BambuPrinter {
pub fn new(id: &str, name: &str, host: &str, access_code: &str, sn: &str, tls: BambuTls) -> Self { pub fn new(
id: &str,
name: &str,
host: &str,
access_code: &str,
sn: &str,
tls: BambuTls,
preference: BambuProtocolPreference,
) -> Self {
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(), access_code: access_code.into(),
sn: sn.into(), sn: sn.into(),
tls, tls,
preference,
version: None,
} }
} }
@@ -131,13 +214,7 @@ impl BambuGenericPrinter {
&self.base &self.base
} }
/// Not part of the `GenericPrinter` trait - Prusa/Klipper have no /* A real (blocking, one-shot) TLS handshake to port 8883 - no MQTT
/// 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
/// protocol, just "does the certificate verify" against this printer's /// protocol, just "does the certificate verify" against this printer's
/// configured trust mode. /// configured trust mode.
/// ///
@@ -155,13 +232,15 @@ impl BambuGenericPrinter {
/// the very next connection). So instead: connect with verification /// the very next connection). So instead: connect with verification
/// off, then compare the certificate actually presented against the /// off, then compare the certificate actually presented against the
/// exact bytes pinned, byte for byte. A mismatch is a hard failure - /// 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<()> { pub fn test_tls_handshake(&self) -> anyhow::Result<()> {
let addr = resolve_addr(&self.base.host)?; let addr = resolve_addr(&self.base.host)?;
let stream = TcpStream::connect_timeout(&addr, Duration::from_secs(5))?; let stream = TcpStream::connect_timeout(&addr, Duration::from_secs(5))?;
if let BambuTls::Custom(pinned_pem) = &self.tls { 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 let presented = tls_stream
.peer_certificate()? .peer_certificate()?
.ok_or_else(|| anyhow::anyhow!("{} presented no certificate", self.base.name))? .ok_or_else(|| anyhow::anyhow!("{} presented no certificate", self.base.name))?
@@ -178,11 +257,13 @@ impl BambuGenericPrinter {
return Ok(()); return Ok(());
} }
self.tls.build_connector()?.connect(&self.base.host, stream)?; self.tls
.build_connector()?
.connect(&self.base.host, stream)?;
Ok(()) Ok(())
} }
/// "Trust on first connect": connects once with certificate /* "Trust on first connect": connects once with certificate
/// verification disabled, captures whatever certificate the printer /// verification disabled, captures whatever certificate the printer
/// presents, and returns it as PEM bytes. /// 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 /// trusted forever after. That's why it's a method you call once, by
/// hand (see `examples/fetch_bambu_cert.rs`), rather than something /// hand (see `examples/fetch_bambu_cert.rs`), rather than something
/// `connect()` falls back to silently. Save the result to a file and /// `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<Vec<u8>> { pub fn fetch_certificate(&self) -> anyhow::Result<Vec<u8>> {
let addr = resolve_addr(&self.base.host)?; let addr = resolve_addr(&self.base.host)?;
let stream = TcpStream::connect_timeout(&addr, Duration::from_secs(5))?; 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 let cert = tls_stream
.peer_certificate()? .peer_certificate()?
@@ -204,7 +287,7 @@ impl BambuGenericPrinter {
Ok(der_to_pem(&cert.to_der()?)) 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 - /// auto-pinning a certificate via trust-on-first-connect if needed -
/// the "smooth, no manual steps" version of the fetch_bambu_cert /// the "smooth, no manual steps" version of the fetch_bambu_cert
/// workflow, built the way SSH handles host keys: /// workflow, built the way SSH handles host keys:
@@ -230,7 +313,7 @@ impl BambuGenericPrinter {
/// in printers.toml) never auto-pins over it - a verification /// in printers.toml) never auto-pins over it - a verification
/// failure there is a real error, not something to paper over, /// failure there is a real error, not something to paper over,
/// since that's exactly the signal pinning exists to give you (the /// 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<()> { pub fn ensure_trusted(&mut self, cert_dir: &Path) -> anyhow::Result<()> {
let pinned_path = cert_dir.join(format!("{}.pem", self.sn)); let pinned_path = cert_dir.join(format!("{}.pem", self.sn));
@@ -241,7 +324,10 @@ impl BambuGenericPrinter {
match self.test_tls_handshake() { match self.test_tls_handshake() {
Ok(()) => Ok(()), Ok(()) => Ok(()),
Err(err) if matches!(self.tls, BambuTls::BundledCa) => { 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()?; let pem = self.fetch_certificate()?;
std::fs::create_dir_all(cert_dir)?; std::fs::create_dir_all(cert_dir)?;
std::fs::write(&pinned_path, &pem)?; std::fs::write(&pinned_path, &pem)?;
@@ -251,113 +337,111 @@ impl BambuGenericPrinter {
Err(err) => Err(err), Err(err) => Err(err),
} }
} }
}
/// Shared by both `BambuV1Printer` and `BambuV2Printer` - same MQTT impl GenericPrinter for BambuPrinter {
/// gcode-injection mechanism regardless of protocol generation. Real fn base(&self) -> &PrinterBase {
/// version would publish &self.base
/// `{"print":{"command":"gcode_line","param":"M106 P1 S<pwm>"}}` 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(())
} }
/// Shared "connect" logic both V1 and V2 delegate to - the real /// Settles `self.version` - forced directly if `preference` says so,
/// version would open the MQTTS session here. Still a stub, but no /// otherwise (the real version) attempting an MQTT v5 CONNECT first
/// longer duplicated across two structs. /// and falling back to v3.1.1 if the broker's CONNACK rejects that
async fn connect_impl(&mut self) -> Result<(), PrinterError> { /// 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 { let mode = match &self.tls {
BambuTls::BundledCa => "bundled Bambu CA", BambuTls::BundledCa => "bundled Bambu CA",
BambuTls::Custom(_) => "custom CA cert", BambuTls::Custom(_) => "custom CA cert",
BambuTls::Insecure => "no certificate verification", 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(()) Ok(())
} }
}
/// Older Bambu MQTT report schema (P1P, P1S, original X1 firmware). async fn publish(&mut self, payload: String) -> Result<(), PrinterError> {
/// No V1-only fields yet - add them here as you learn what differs. // TODO: add publishing via MQTT
pub struct BambuV1Printer { println!(
generic: BambuGenericPrinter, "[{}] publishing to device/{}/request: {payload}",
} self.base.name, self.sn
);
impl BambuV1Printer { Ok(())
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) { async fn receive(&mut self, payload: String) -> Result<(), PrinterError> {
self.generic.dispatch_gcode(file_name) // TODO: add MQTT response handling
} Ok(())
pub fn test_tls_handshake(&self) -> anyhow::Result<()> {
self.generic.test_tls_handshake()
}
pub fn fetch_certificate(&self) -> anyhow::Result<Vec<u8>> {
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
} }
/// 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> { 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
} }
/// Current-generation Bambu MQTT report schema (X1 Carbon/X1E, H2 series). /// Real version FTPS-`PUT`s `data` to the printer's own `/cache/`
/// No V2-only fields yet (e.g. AMS slot state) - add them here as you build /// directory (that's storage the printer's firmware owns, same idea
/// out real report parsing. /// as PrusaLink's SD card), then publishes a `project_file` MQTT
pub struct BambuV2Printer { /// command referencing it by name so the printer actually starts
generic: BambuGenericPrinter, /// printing it - two separate steps, both stubbed here as one line
} /// for now.
async fn upload_project_file(
impl BambuV2Printer { &mut self,
pub fn new(id: &str, name: &str, host: &str, access_code: &str, sn: &str, tls: BambuTls) -> Self { file_name: String,
Self { generic: BambuGenericPrinter::new(id, name, host, access_code, sn, tls) } data: Vec<u8>,
} ) -> Result<(), PrinterError> {
println!(
pub fn dispatch_gcode(&self, file_name: &str) { "[{}] FTPS upload {file_name} ({} bytes) to /cache/",
self.generic.dispatch_gcode(file_name) self.base.name,
} data.len()
);
pub fn test_tls_handshake(&self) -> anyhow::Result<()> { Ok(())
self.generic.test_tls_handshake() }
}
/// Builds the real JSON payload straight from continuum-schemas'
pub fn fetch_certificate(&self) -> anyhow::Result<Vec<u8>> { /// `schemas/bambulab/{v1,v2}/request/print/gcode_line.schema.json` -
self.generic.fetch_certificate() /// 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
pub fn ensure_trusted(&mut self, cert_dir: &Path) -> anyhow::Result<()> { /// one field we actually set. Only the actual MQTT publish to
self.generic.ensure_trusted(cert_dir) /// `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
impl GenericPrinter for BambuV2Printer { .ok_or_else(|| PrinterError(format!("{} is not connected", self.base.name)))?;
fn base(&self) -> &PrinterBase {
self.generic.base() let mut print =
} continuum_schema_instance::build_instance_from_file(
&get_schema_for_command(
async fn connect(&mut self) -> Result<(), PrinterError> { version,
self.generic.connect_impl().await "print".into(),
} "gcode_line".into()
)
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> { )
self.generic.set_fan_speed_impl(percent).await .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
} }
} }
+6
View File
@@ -49,6 +49,12 @@ pub struct CommandEnvelope {
/// category can have more than one possible request shape - if that ever /// category can have more than one possible request shape - if that ever
/// happens here, that variant's payload becomes its own small /// happens here, that variant's payload becomes its own small
/// `#[serde(tag = "command")]` enum instead of a single struct. /// `#[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)] #[derive(serde::Serialize, Debug)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum BambuRequest { pub enum BambuRequest {
+25
View File
@@ -29,6 +29,15 @@ impl GenericPrinter for KlipperPrinter {
Ok(()) 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 /// OVERRIDE: Moonraker's own **hypothetical** native API, not gcode injection - no
/// percent-to-PWM conversion needed since it already takes a /// percent-to-PWM conversion needed since it already takes a
/// percentage directly. Real version: /// 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); println!("[{}] Moonraker: SET_FAN_SPEED SPEED={:.2}", self.base.name, percent as f32 / 100.0);
Ok(()) 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<u8>) -> 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(())
}
} }
+69
View File
@@ -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<SerialStream>,
lines_in: Lines<BufReader<ReadHalf<SerialStream>>>,
}
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<Self, PrinterError> {
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(());
}
}
}
}
+95 -15
View File
@@ -2,12 +2,10 @@
//! `GenericPrinter -> BambuV1/BambuV2/PrusaLink/PrusaSerial/Klipper`-style //! `GenericPrinter -> BambuV1/BambuV2/PrusaLink/PrusaSerial/Klipper`-style
//! inheritance, since Rust structs can't `extend` each other. //! inheritance, since Rust structs can't `extend` each other.
//! //!
//! Bambu goes one level deeper than the others: `bambu.rs` has //! Bambu is a single `BambuPrinter` type, not a per-version split: which
//! `BambuGenericPrinter` (fields + behavior every Bambu printer shares - //! MQTT/BambuMQTT protocol generation (v1 vs v2) a given printer uses is
//! access code, CA trust, the TLS handshake test/fetch) which //! something `connect()` discovers (or is told to force), not something
//! `BambuV1Printer`/`BambuV2Printer` each *have* one of, mirroring the //! config picks a *type* for - see `bambu.rs`'s doc comment.
//! `GenericPrinter -> BambuGenericPrinter -> BambuV1/V2` chain this project
//! actually wanted from the start - see that file's doc comment.
//! //!
//! Three pieces, each doing one job an OOP base class would normally do: //! Three pieces, each doing one job an OOP base class would normally do:
//! //!
@@ -31,9 +29,10 @@
mod bambu; mod bambu;
mod bambu_commands; mod bambu_commands;
mod klipper; mod klipper;
mod marlin_serial;
mod prusa; mod prusa;
pub use bambu::{BambuGenericPrinter, BambuTls, BambuV1Printer, BambuV2Printer}; pub use bambu::{BambuPrinter, BambuProtocolPreference, BambuProtocolVersion, BambuTls};
pub use bambu_commands::{ pub use bambu_commands::{
AmsState, AmsTray, AmsUnit, BambuReport, BambuRequest, CommandEnvelope, EmptyTray, GetVersionRequest, AmsState, AmsTray, AmsUnit, BambuReport, BambuRequest, CommandEnvelope, EmptyTray, GetVersionRequest,
GetVersionResponse, LoadedTray, PrintReport, PushallRequest, XcamSettings, GetVersionResponse, LoadedTray, PrintReport, PushallRequest, XcamSettings,
@@ -72,12 +71,34 @@ pub trait GenericPrinter {
/// into the multi-threaded daemon - safe to ignore for now. /// into the multi-threaded daemon - safe to ignore for now.
async fn connect(&mut self) -> Result<(), PrinterError>; 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 /// REQUIRED, no default body: `percent` is 0-100, but how it actually
/// gets sent - an MQTT gcode-injection command, a REST call, raw bytes /// gets sent - an MQTT gcode-injection command, a REST call, raw bytes
/// on a serial port - is different for every vendor, same as /// on a serial port - is different for every vendor, same as
/// `connect()`. /// `connect()`.
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError>; 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<u8>) -> 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 /// OPTIONAL: a default body every printer gets for free unless it
/// writes its own. None of ours do below, so all three vendors share /// writes its own. None of ours do below, so all three vendors share
/// this exact implementation - the trait-method equivalent of /// 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. /// The closed set of printer kinds this fleet can talk to.
pub enum PrinterHandle { pub enum PrinterHandle {
BambuV1(BambuV1Printer), Bambu(BambuPrinter),
BambuV2(BambuV2Printer),
PrusaLink(PrusaLinkPrinter), PrusaLink(PrusaLinkPrinter),
PrusaSerial(PrusaSerialPrinter), PrusaSerial(PrusaSerialPrinter),
Klipper(KlipperPrinter), Klipper(KlipperPrinter),
@@ -114,8 +134,7 @@ pub enum PrinterHandle {
impl GenericPrinter for PrinterHandle { impl GenericPrinter for PrinterHandle {
fn base(&self) -> &PrinterBase { fn base(&self) -> &PrinterBase {
match self { match self {
PrinterHandle::BambuV1(p) => p.base(), PrinterHandle::Bambu(p) => p.base(),
PrinterHandle::BambuV2(p) => p.base(),
PrinterHandle::PrusaLink(p) => p.base(), PrinterHandle::PrusaLink(p) => p.base(),
PrinterHandle::PrusaSerial(p) => p.base(), PrinterHandle::PrusaSerial(p) => p.base(),
PrinterHandle::Klipper(p) => p.base(), PrinterHandle::Klipper(p) => p.base(),
@@ -124,21 +143,82 @@ impl GenericPrinter for PrinterHandle {
async fn connect(&mut self) -> Result<(), PrinterError> { async fn connect(&mut self) -> Result<(), PrinterError> {
match self { match self {
PrinterHandle::BambuV1(p) => p.connect().await, PrinterHandle::Bambu(p) => p.connect().await,
PrinterHandle::BambuV2(p) => p.connect().await,
PrinterHandle::PrusaLink(p) => p.connect().await, PrinterHandle::PrusaLink(p) => p.connect().await,
PrinterHandle::PrusaSerial(p) => p.connect().await, PrinterHandle::PrusaSerial(p) => p.connect().await,
PrinterHandle::Klipper(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> { async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> {
match self { match self {
PrinterHandle::BambuV1(p) => p.set_fan_speed(percent).await, PrinterHandle::Bambu(p) => p.set_fan_speed(percent).await,
PrinterHandle::BambuV2(p) => p.set_fan_speed(percent).await,
PrinterHandle::PrusaLink(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::PrusaSerial(p) => p.set_fan_speed(percent).await,
PrinterHandle::Klipper(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<u8>) -> 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 `<P extends GenericPrinter>`, 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<P: GenericPrinter>(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(())
} }
+82 -14
View File
@@ -29,6 +29,16 @@ impl GenericPrinter for PrusaLinkPrinter {
Ok(()) 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 /// OVERRIDE: gcode via PrusaLink's command-injection endpoint - same
/// underlying M106 as Bambu, reusing the shared percent->PWM /// underlying M106 as Bambu, reusing the shared percent->PWM
/// conversion, but sent over HTTP instead of MQTT. /// 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); println!("[{}] POST /api/printer/command {{\"commands\":[\"M106 S{pwm}\"]}}", self.base.name);
Ok(()) 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<u8>) -> 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 { pub struct PrusaSerialPrinter {
base: PrinterBase, 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<super::marlin_serial::MarlinSerialConnection>,
} }
impl PrusaSerialPrinter { 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 { Self {
base: PrinterBase { id: id.into(), name: name.into(), host: "127.0.0.1".to_string() }, 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 &self.base
} }
/// OVERRIDE: a fourth, again-different body - no network host at all, /// OVERRIDE, and a real one (not a stub, unlike the other four vendors'
/// just a local serial port (real version: open the port, e.g. via the /// `connect`): opens the port and keeps it, since serial has no
/// `serialport`/`tokio-serial` crate, and speak Prusa's G-code-over- /// separate "session" concept to stub out - the open connection *is*
/// serial protocol). /// the whole thing.
async fn connect(&mut self) -> Result<(), PrinterError> { 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(()) Ok(())
} }
/// OVERRIDE: same gcode as PrusaLink (still M106, still the shared async fn publish(&mut self, payload: String) -> Result<(), PrinterError> {
/// conversion), but written straight to the serial port instead of // TODO: add publishing via serial
/// 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);
Ok(()) 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<u8>) -> 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
}
} }