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:
+206
-122
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user