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
+206 -122
View File
@@ -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<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
@@ -102,28 +125,88 @@ fn pem_to_der(pem: &[u8]) -> anyhow::Result<Vec<u8>> {
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<BambuProtocolVersion>,
}
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<Vec<u8>> {
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<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(())
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<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
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<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 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<u8>,
) -> 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
}
}