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.
This commit is contained in:
2026-09-05 21:04:39 +00:00
parent b352fd618d
commit 9643c8b685
6 changed files with 310 additions and 84 deletions
+44 -60
View File
@@ -73,7 +73,11 @@ impl BambuTls {
/// Builds a `native_tls` connector configured for this trust mode:
/// trusting only `ca_bytes()`, or accepting anything for `Insecure`.
fn build_connector(&self) -> anyhow::Result<native_tls::TlsConnector> {
/// `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
@@ -182,8 +186,10 @@ pub struct BambuPrinter {
pub sn: String,
tls: BambuTls,
preference: BambuProtocolPreference,
/// `None` until `connect()` succeeds.
version: Option<BambuProtocolVersion>,
/// `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 {
@@ -206,7 +212,7 @@ impl BambuPrinter {
sn: sn.into(),
tls,
preference,
version: None,
connection: None,
}
}
@@ -344,51 +350,33 @@ impl GenericPrinter for BambuPrinter {
&self.base
}
/// 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.
/// 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 mode = match &self.tls {
BambuTls::BundledCa => "bundled Bambu CA",
BambuTls::Custom(_) => "custom CA cert",
BambuTls::Insecure => "no certificate verification",
};
let (session, version) = super::bambu_mqtt::BambuMqttSession::open(
&self.base.host,
&self.access_code,
&self.sn,
&self.tls,
self.preference,
)
.await?;
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);
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> {
// TODO: add publishing via MQTT
println!(
"[{}] publishing to device/{}/request: {payload}",
self.base.name, self.sn
);
Ok(())
}
async fn receive(&mut self, payload: String) -> Result<(), PrinterError> {
// TODO: add MQTT response handling
Ok(())
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
@@ -423,25 +411,21 @@ impl GenericPrinter for BambuPrinter {
/// 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.
/// 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 version = self
.version
.ok_or_else(|| PrinterError(format!("{} is not connected", self.base.name)))?;
let (session, version) =
self.connection.as_ref().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 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);
let payload = serde_json::json!({ "print": print }).to_string();
self.publish(payload).await
session.publish("print", &body).await
}
}