Files
continuum-proxy/src/printer/bambu.rs
T
octoturge 9643c8b685 Real MQTT wiring for Bambu: connect, auto-detect v5/v3.1.1, request/reply
Removes GenericPrinter::receive (two disconnected fire-and-forget stubs
that didn't actually give request/response) in favor of a real mechanism
in the new src/printer/bambu_mqtt.rs:

- BambuMqttSession wraps whichever rumqttc client a connection ended up
  using (v3.1.1's AsyncClient/EventLoop for BambuMQTT v1, v5's for
  BambuMQTT v2 - different types, different event/error shapes) behind
  one interface. `open()` respects ForceV1/ForceV2, and for Auto tries
  v5 first, falling back to v3.1.1 on any failure (see the module doc
  comment for why "any failure", not a precisely-matched version-
  rejection code - unverified against a live printer, no broker here to
  test against).
- Reuses BambuTls::build_connector (now pub(crate)) for rumqttc's TLS
  transport via TlsConfiguration::NativeConnector, instead of a second,
  separate TLS trust story - hence rumqttc now pulls in use-native-tls
  instead of its default rustls stack, so there's one TLS implementation
  in the binary, not two.
- request() publishes and waits (with a timeout) for a report whose
  sequence_id matches, correlated via a shared pending-request table
  that a spawned background task completes as reports arrive - the
  first real tokio::spawn in this crate, flagged as deferred since
  printer/mod.rs's very first doc comment; this is why it was deferred
  until now, and why it's needed now (polling the eventloop has to run
  continuously, independent of whatever call site is waiting).
- publish()/publish_raw() are the fire-and-forget half, for commands
  like gcode_line that only get acknowledged implicitly.

BambuPrinter::connect/send_gcode_line/publish now call the real session
instead of printing what they would do. Verified against 192.0.2.1 (a
reserved, never-routable test address): both AsyncClient/EventLoop pairs
actually attempt a real TCP connection and fail gracefully (network
timeout, not a panic) when nothing answers, and Auto genuinely attempts
both while ForceV1 skips straight to v3.1.1.
2026-09-05 21:04:39 +00:00

432 lines
18 KiB
Rust

use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
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
/// the executable.
const BUNDLED_CA: &[u8] = include_bytes!("../../certs/bambu_ca2.pem");
/// Which certificate to trust when connecting to a Bambu printer's MQTTS
/// port (8883 - Bambu's LAN mode is TLS-only, there's no unencrypted
/// fallback).
pub enum BambuTls {
/// Bambu's shared root CA. Correct for current-generation printers.
BundledCa,
/// A specific certificate instead - for a printer whose firmware
/// doesn't chain to the shared CA (P1P, at least).
Custom(Vec<u8>),
/// Skip certificate verification entirely. The least safe option, only
/// reasonable because LAN mode never leaves your local network.
Insecure,
}
impl BambuTls {
/// Resolves the trust mode for *one* printer. Call this per printer,
/// passing `config.bambu_ca_cert_overrides.get(printer_id)` - there's
/// no single override for the whole fleet, because not every Bambu
/// model chains to the same CA. `require_valid_cert = false` always
/// means `Insecure`, regardless of whether that printer also has an
/// override entry.
pub fn resolve(cert_path: Option<&Path>, require_valid_cert: bool) -> anyhow::Result<Self> {
if !require_valid_cert {
return Ok(BambuTls::Insecure);
}
match cert_path {
Some(path) => Ok(BambuTls::Custom(std::fs::read(path)?)),
None => Ok(BambuTls::BundledCa),
}
}
/// The CA certificate bytes to hand to the TLS layer - empty when
/// `Insecure`, since there's nothing to verify against.
pub fn ca_bytes(&self) -> &[u8] {
match self {
BambuTls::BundledCa => BUNDLED_CA,
BambuTls::Custom(bytes) => bytes,
BambuTls::Insecure => &[],
}
}
/// Builds a `native_tls` connector configured for this trust mode:
/// trusting only `ca_bytes()`, or accepting anything for `Insecure`.
/// `pub(crate)`, not private: `bambu_mqtt.rs` (a sibling module, not a
/// descendant of this one) reuses this exact connector to configure
/// rumqttc's TLS transport, instead of every consumer's TLS trust
/// decisions living in two separate places.
pub(crate) fn build_connector(&self) -> anyhow::Result<native_tls::TlsConnector> {
let mut builder = native_tls::TlsConnector::builder();
// Bambu printer certificates don't carry their LAN IP as a Subject
// Alternative Name - it's DHCP-assigned, so baking it in at
// manufacture time wouldn't make sense. Hostname verification has
// to be off regardless of trust mode: what matters is whether the
// certificate chains to a CA we trust, not whether its SAN matches
// the specific IP we happened to dial today. Chain verification
// itself stays fully enforced below for BundledCa/Custom - this
// narrows *what* gets checked, it doesn't turn checking off.
builder.danger_accept_invalid_hostnames(true);
if matches!(self, BambuTls::Insecure) {
builder.danger_accept_invalid_certs(true);
} else {
builder.add_root_certificate(native_tls::Certificate::from_pem(self.ca_bytes())?);
}
Ok(builder.build()?)
}
}
fn resolve_addr(host: &str) -> anyhow::Result<SocketAddr> {
(host, 8883)
.to_socket_addrs()?
.next()
.ok_or_else(|| anyhow::anyhow!("could not resolve {host}"))
}
/// Minimal DER -> PEM encoder (base64, 64-char lines, standard
/// header/footer) - just enough to save a fetched certificate in the same
/// format as the bundled one.
fn der_to_pem(der: &[u8]) -> Vec<u8> {
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(der);
let mut pem = String::from("-----BEGIN CERTIFICATE-----\n");
for line in encoded.as_bytes().chunks(64) {
pem.push_str(std::str::from_utf8(line).expect("base64 output is always ASCII"));
pem.push('\n');
}
pem.push_str("-----END CERTIFICATE-----\n");
pem.into_bytes()
}
/// Re-encodes a PEM certificate to DER, so it can be byte-compared against
/// what a live handshake presents (`Certificate::to_der()`).
fn pem_to_der(pem: &[u8]) -> anyhow::Result<Vec<u8>> {
Ok(native_tls::Certificate::from_pem(pem)?.to_der()?)
}
// 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. Carries the live MQTT session
/// alongside the protocol version it settled on, since the two are
/// decided together and always used together - see `bambu_mqtt.rs`.
connection: Option<(super::bambu_mqtt::BambuMqttSession, BambuProtocolVersion)>,
}
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(),
},
access_code: access_code.into(),
sn: sn.into(),
tls,
preference,
connection: None,
}
}
pub fn base(&self) -> &PrinterBase {
&self.base
}
/* 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.
///
/// `BundledCa` verifies normally (Bambu's shared CA is a proper,
/// well-formed root - OpenSSL's usual chain validation handles it
/// fine). `Custom` does something different: *fingerprint pinning*,
/// not chain validation. A device's own leaf certificate often isn't a
/// well-formed CA (no `CA:TRUE`), and the device may present it
/// alongside a separate self-signed root you never captured - asking
/// OpenSSL's normal path-building to accept an arbitrary leaf as a
/// trust anchor is unreliable for exactly that reason (this is a real
/// bug that shipped and failed against a real P1P: `add_root_certificate`
/// with a captured leaf produced `self-signed certificate in
/// certificate chain` / `unable to get local issuer certificate` on
/// 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." */
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 presented = tls_stream
.peer_certificate()?
.ok_or_else(|| anyhow::anyhow!("{} presented no certificate", self.base.name))?
.to_der()?;
if presented != pem_to_der(pinned_pem)? {
anyhow::bail!(
"{} presented a certificate different from the one pinned - could be a legitimate \
certificate rotation, could be something worse; re-run fetch_bambu_cert deliberately \
if you're sure it's the former",
self.base.name
);
}
return Ok(());
}
self.tls
.build_connector()?
.connect(&self.base.host, stream)?;
Ok(())
}
/* "Trust on first connect": connects once with certificate
/// verification disabled, captures whatever certificate the printer
/// presents, and returns it as PEM bytes.
///
/// This is meaningfully weaker than verifying against a CA you already
/// trust - anyone on the network at the exact moment you run this gets
/// 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. */
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 cert = tls_stream
.peer_certificate()?
.ok_or_else(|| anyhow::anyhow!("{} presented no certificate", self.base.name))?;
Ok(der_to_pem(&cert.to_der()?))
}
/* 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:
///
/// - A pinned certificate already on disk (`cert_dir/<sn>.pem`) is used
/// directly. No new trust decision gets made on every run - that
/// already happened once, this just re-verifies against it. Keyed by
/// *serial number*, not `id`: `id` is just an arbitrary label you
/// picked in printers.toml - rename a printer's `id` and the pin
/// should still find it, because a pin certifies "this physical
/// printer", and the serial number is the one thing about it that
/// can't change.
/// - No pin yet, and the bundled CA verifies fine (current-generation
/// printers): nothing to do.
/// - No pin yet, and the bundled CA does *not* verify (P1P, etc.):
/// fetches the certificate the printer presents, confirms it
/// produces a working connection, and saves it to `cert_dir/<sn>.pem`
/// for every run after this one. This is the one moment a MITM
/// active on your network *right now* could plant a certificate that
/// gets trusted from then on - the same tradeoff SSH accepts on a
/// first connection.
/// - A printer with an *explicit* pin (you set `ca_cert_path` yourself
/// 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). */
pub fn ensure_trusted(&mut self, cert_dir: &Path) -> anyhow::Result<()> {
let pinned_path = cert_dir.join(format!("{}.pem", self.sn));
if matches!(self.tls, BambuTls::BundledCa) && pinned_path.exists() {
self.tls = BambuTls::Custom(std::fs::read(&pinned_path)?);
}
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
);
let pem = self.fetch_certificate()?;
std::fs::create_dir_all(cert_dir)?;
std::fs::write(&pinned_path, &pem)?;
self.tls = BambuTls::Custom(pem);
self.test_tls_handshake()
}
Err(err) => Err(err),
}
}
}
impl GenericPrinter for BambuPrinter {
fn base(&self) -> &PrinterBase {
&self.base
}
/// Real, not a stub: opens the MQTT session (see `bambu_mqtt.rs` for
/// `preference`'s `Auto`-tries-v5-then-v3.1.1 fallback) and keeps it -
/// same reasoning as `PrusaSerialPrinter::connect` holding its port
/// open, just an MQTT session instead of a serial port.
async fn connect(&mut self) -> Result<(), PrinterError> {
let (session, version) = super::bambu_mqtt::BambuMqttSession::open(
&self.base.host,
&self.access_code,
&self.sn,
&self.tls,
self.preference,
)
.await?;
println!("[{}] connected over MQTT ({version:?})", self.base.name);
self.connection = Some((session, version));
Ok(())
}
/// Real: a raw pass-through publish to `device/{sn}/request`, no
/// category envelope opinion - `send_gcode_line` below builds and
/// sends its own properly-wrapped request instead of going through
/// this.
async fn publish(&mut self, payload: String) -> Result<(), PrinterError> {
let (session, _) =
self.connection.as_ref().ok_or_else(|| PrinterError(format!("{} is not connected", self.base.name)))?;
session.publish_raw(payload).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> {
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. Real, end to end: publishes over the
/// live MQTT session `connect()` opened, no waiting for a reply -
/// `gcode_line` is fire-and-forget, the printer just executes it.
async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError> {
let (session, version) =
self.connection.as_ref().ok_or_else(|| PrinterError(format!("{} is not connected", self.base.name)))?;
let mut body = 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}")))?;
body["param"] = serde_json::Value::String(gcode_line);
session.publish("print", &body).await
}
}