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
+95 -15
View File
@@ -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<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
/// 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<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(())
}