Files
continuum-proxy/src/printer/bambu_mqtt.rs
T
octoturge 13bfec97fe Clear the clippy warnings, except the two that are not cosmetic
Down from 29 warnings to 2, in the spirit of 4ab95ff.

The doc warnings were all one mistake made repeatedly: a paragraph wrapped so
that a line *began* with `- ` or `+ `, which rustdoc reads as a markdown list
marker and the following lines as unindented continuations. The prose always
meant an em-dash. Fixed by moving the dash to the end of the previous line and
re-wrapping; not one word changed, which is the point - these comments record
things confirmed against real printers and are worth more than the warning.
The genuine lists just had their continuations de-indented and stayed lists.

The code ones are `match x { V(v) => .., _ => {} }` to `if let`, and nested
`if let Some(x) = .. { if cond { .. } }` to a let-chain. None had an `else`,
so each is the same control flow written once instead of twice.

Two warnings are left deliberately, because fixing them is not cosmetic:
`AmsTray` (544 bytes against 32) and `BambuReport` (984 against 120) both have
one variant far larger than the rest. The fix clippy wants is a `Box`, which
changes how every reader of those enums is written and puts an allocation on
the push_status path that currently has none. Worth measuring before doing,
not worth doing to silence a warning.

51 tests still pass.
2026-10-15 21:43:06 +00:00

512 lines
25 KiB
Rust

//! The real MQTT wiring for Bambu printers - the piece `connect`/
//! `send_gcode_line` in `bambu.rs` used to just stub out with a
//! `println!`. Three things make this more than "call `publish`, get a
//! `String` back":
//!
//! 1. MQTT v3.1.1 (BambuMQTT v1) and MQTT v5 (BambuMQTT v2) are two
//! entirely separate rumqttc client types (`rumqttc::AsyncClient` vs
//! `rumqttc::v5::AsyncClient`) with different event/error types -
//! `BambuMqttClient` is the enum that hides which one a connection
//! ended up using, same pattern as `PrinterHandle` hides which vendor
//! struct you're holding.
//! 2. A response to a published request doesn't come back from that same
//! call - it arrives later, asynchronously, on a *subscription* to
//! `device/{sn}/report`. That needs a background task continuously
//! polling the connection (rumqttc doesn't do this for you - nothing
//! happens until you poll the `EventLoop` yourself, repeatedly,
//! forever) - this is the first real use of `tokio::spawn` in this
//! crate, flagged as deferred since `printer/mod.rs`'s very first doc
//! comment, and this is why: it only makes sense once there's a
//! long-running background job that has to run independently of
//! whatever call site is waiting on a response.
//!
//! Correlating a reply to its request isn't as uniform as it looks -
//! confirmed live, not assumed. `info.get_version`'s reply echoes back
//! the `sequence_id` you sent; `request` relies on that. A tempting
//! generalization - add your own extra field instead of `sequence_id`,
//! since most replies are supposed to echo back whatever extra fields
//! a request carried - turned out false for this exact command: a real
//! H2C's `get_version` reply had precisely `["command", "module",
//! "sequence_id"]`, nothing else, so a homemade correlation field never
//! survived and every request timed out. `sequence_id` - proven to
//! round-trip, not just assumed to - is what's actually used.
//! `print.push_status` doesn't echo `sequence_id` either (the printer
//! stamps that with its own free-running counter regardless of what
//! triggered it) - but it was never a request/reply case to begin with
//! (`pushing.pushall` triggers an ongoing *stream*, not a single
//! reply) - see `PrintStatusCache` for how that's handled instead.
//! 3. `print.push_status` also needs to be kept as running in-memory
//! state, not just handed to whoever happens to be waiting at the
//! moment it arrives - see `PrintStatusCache` below for why (short
//! version: v1 only ever sends partial updates, so something has to
//! remember what the fields *were* to produce a coherent whole
//! object).
//!
//! Unverified against a live printer (no way to force it in a test): the
//! exact `ConnectReturnCode`/error shape a real Bambu printer's embedded
//! broker gives when it doesn't understand MQTT v5's CONNECT at all
//! (rather than a clean version-rejection CONNACK) - `open()` below
//! treats *any* failure of the v5 attempt as "fall back to v3.1.1", not
//! just a precisely-matched version-rejection code, since a broker that
//! can't parse a v5 CONNECT packet may just drop the connection instead
//! of replying with one.
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use rumqttc::v5::mqttbytes::v5::Packet as PacketV5;
use rumqttc::v5::mqttbytes::QoS as QoSV5;
use rumqttc::{Event, Packet, QoS, Transport, TlsConfiguration};
use serde_json::Value;
use tokio::sync::{oneshot, Mutex};
use super::bambu::BambuTls;
use super::bambu_commands::PrintReport;
use super::BambuProtocolVersion;
use super::PrinterError;
/// Requests waiting on a report with a matching `sequence_id` - only
/// valid for a command empirically confirmed to echo it back, like
/// `get_version` (see this module's doc comment - a homemade correlation
/// field was tried instead and confirmed *not* to survive on this
/// command's reply). Shared between whichever call registered one (in
/// `request`) and the background poll task that completes it.
type PendingTable = Arc<Mutex<HashMap<String, oneshot::Sender<Value>>>>;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
/// Both rumqttc clients default to a 10KB max packet size in *both*
/// directions - fine for a request, nowhere near enough for a reply:
/// confirmed live against a real H2C, whose `get_version` reply alone
/// (25625 bytes - it reports a lot of modules: dual AMS, AMS HT, six WTM
/// units, ...) blew straight through that limit and killed the whole
/// connection outright (a real MQTT v5 protocol error, not a timeout).
/// `print.push_status` reports (full AMS/xcam state) can get large too.
/// 1MB has real headroom for that.
const MAX_PACKET_SIZE: usize = 1024 * 1024;
/// Every `sequence_id` this crate generates gets this prefix - a printer
/// runs its own independent counter for unsolicited messages (confirmed
/// live: `print.push_status`'s was already in the 500s at the start of a
/// session, climbing steadily), and that counter has only ever been
/// observed as a plain unprefixed integer string. An earlier version of
/// this just started our own counter from a large offset instead of
/// prefixing it, to dodge the same range a printer's counter climbs
/// through - confirmed live (with an ISO-timestamp-prefixed sequence_id)
/// that `sequence_id` isn't required to be numeric at all, so a
/// non-numeric prefix rules out collision outright instead of merely
/// making it improbable, and the counter itself can go back to starting
/// at 1 each session.
const SEQUENCE_ID_PREFIX: &str = "cxproxy-";
enum BambuMqttClient {
V1(rumqttc::AsyncClient),
V2(rumqttc::v5::AsyncClient),
}
/// The running in-memory `print.push_status` object, plus enough state to
/// know how to fold the next update into it:
///
/// - BambuMQTT v2 sends the *entire* object every time - always replace.
/// - BambuMQTT v1 only sends the fields that changed since last time -
/// merge into the existing object, or the picture goes incomplete the
/// instant anything not present in the latest push gets treated as
/// "unset". The exception is a `pushing.pushall`-triggered push, which
/// is always the full object - and v1 self-describes which kind a given
/// message is via its own `msg` field (`1` automatic/partial, `0`
/// forced/full), confirmed against a real printer. `push_status` also
/// doesn't echo `sequence_id` (see this module's doc comment), so it
/// couldn't be identified via `request`'s correlation even if the
/// timing worked out; `msg` needs none of that: it's on the message
/// itself, correct regardless of which client (or nothing at all)
/// triggered it, and regardless of timing.
/// - **v2 does not use `sequence_id` as a counter here at all.** Confirmed
/// live against an H2C mid-print on 2026-10-03: every organic
/// `push_status` carried the *same* `sequence_id` (`"2021"`) for minutes
/// on end while the contents (percent, layer, remaining time) changed
/// normally. So on v2 it identifies nothing and orders nothing - don't
/// use it to detect staleness or to deduplicate. The gap check below
/// already skips v2 (every v2 message is a full object), which turns out
/// to be necessary rather than merely tidy: it would otherwise warn on
/// every single message.
/// - `last_sequence_id` exists to notice when v1's organic stream skipped
/// an update (network hiccup, a message this client wasn't fast enough
/// to read): if the incoming `sequence_id` isn't exactly one more than
/// the last one seen, the merged object is missing whatever changed in
/// the gap. There's no way to know what that baseline number even is
/// ahead of time - the printer runs its own counter, not one this
/// client started - so the very first `push_status` received after
/// connecting just seeds `last_sequence_id` rather than being checked
/// against anything.
struct PrintStatusCache {
report: Option<PrintReport>,
last_sequence_id: Option<u64>,
}
impl PrintStatusCache {
fn new() -> Self {
Self { report: None, last_sequence_id: None }
}
fn apply(&mut self, incoming: PrintReport, version: BambuProtocolVersion) {
let incoming_seq = incoming.envelope.sequence_id.parse::<u64>().ok();
// A forced/full update isn't missing anything by definition, so a
// sequence gap right after one (or on v2, where every message is
// full) isn't a real problem worth warning about.
let is_full_object = matches!(version, BambuProtocolVersion::V2) || incoming.msg != Some(1);
// Only reachable for v1 partial updates. Historically this fired
// constantly and blamed the network, when the real cause was local:
// a push that failed to deserialize never reached this function, so
// the next one that did looked like it had skipped ahead. That is now
// reported where it happens (see `handle_incoming_report`), which
// leaves this warning meaning what it says.
if let (Some(last), Some(incoming_seq), false) = (self.last_sequence_id, incoming_seq, is_full_object)
&& incoming_seq != last.wrapping_add(1)
{
eprintln!(
"print.push_status sequence gap: expected {}, got {incoming_seq} - the in-memory \
object may be missing whatever changed in between",
last.wrapping_add(1)
);
}
if let Some(incoming_seq) = incoming_seq {
self.last_sequence_id = Some(incoming_seq);
}
match (&mut self.report, is_full_object) {
(_, true) => self.report = Some(incoming),
(Some(existing), false) => existing.merge_from(incoming),
(None, false) => self.report = Some(incoming),
}
}
}
/// One live MQTT connection to a Bambu printer - whichever protocol
/// version `open()` settled on.
pub struct BambuMqttSession {
client: BambuMqttClient,
pending: PendingTable,
print_status: Arc<Mutex<PrintStatusCache>>,
sn: String,
next_sequence_id: AtomicU64,
}
impl BambuMqttSession {
/// `ForceV1`/`ForceV2` connect with that protocol directly. `Auto`
/// tries v5 first and falls back to v3.1.1 if that attempt fails -
/// see this module's doc comment for why "any failure", not just a
/// precisely-matched version-rejection code.
pub async fn open(
host: &str,
access_code: &str,
sn: &str,
tls: &BambuTls,
preference: super::bambu::BambuProtocolPreference,
) -> Result<(Self, BambuProtocolVersion), PrinterError> {
use super::bambu::BambuProtocolPreference as Pref;
match preference {
Pref::ForceV1 => {
let session = Self::open_v1(host, access_code, sn, tls).await?;
Ok((session, BambuProtocolVersion::V1))
}
Pref::ForceV2 => {
let session = Self::open_v2(host, access_code, sn, tls).await?;
Ok((session, BambuProtocolVersion::V2))
}
Pref::Auto => match Self::open_v2(host, access_code, sn, tls).await {
Ok(session) => Ok((session, BambuProtocolVersion::V2)),
Err(_) => {
let session = Self::open_v1(host, access_code, sn, tls).await?;
Ok((session, BambuProtocolVersion::V1))
}
},
}
}
async fn open_v1(host: &str, access_code: &str, sn: &str, tls: &BambuTls) -> Result<Self, PrinterError> {
let connector = tls.build_connector().map_err(|err| PrinterError(format!("TLS setup failed: {err}")))?;
let mut options = rumqttc::MqttOptions::new(format!("continuum-{sn}"), host, 8883);
options.set_credentials("bblp", access_code);
options.set_transport(Transport::tls_with_config(TlsConfiguration::NativeConnector(connector)));
options.set_keep_alive(Duration::from_secs(30));
options.set_max_packet_size(MAX_PACKET_SIZE, MAX_PACKET_SIZE);
let (client, mut event_loop) = rumqttc::AsyncClient::new(options, 10);
client
.subscribe(format!("device/{sn}/report"), QoS::AtMostOnce)
.await
.map_err(|err| PrinterError(format!("subscribe failed: {err}")))?;
// Nothing actually happens on the wire until the eventloop is
// polled - this first poll is what makes the TCP+TLS+CONNECT
// handshake happen, and its result is the only way to know
// whether it worked.
match event_loop.poll().await {
Ok(_) => {}
Err(err) => return Err(PrinterError(format!("MQTT v3.1.1 connect failed: {err}"))),
}
let pending: PendingTable = Arc::new(Mutex::new(HashMap::new()));
let print_status = Arc::new(Mutex::new(PrintStatusCache::new()));
let sn_owned = sn.to_string();
tokio::spawn(poll_loop_v1(event_loop, pending.clone(), print_status.clone(), BambuProtocolVersion::V1, sn_owned));
Ok(Self {
client: BambuMqttClient::V1(client),
pending,
print_status,
sn: sn.to_string(),
next_sequence_id: AtomicU64::new(1),
})
}
async fn open_v2(host: &str, access_code: &str, sn: &str, tls: &BambuTls) -> Result<Self, PrinterError> {
let connector = tls.build_connector().map_err(|err| PrinterError(format!("TLS setup failed: {err}")))?;
let mut options = rumqttc::v5::MqttOptions::new(format!("continuum-{sn}"), host, 8883);
options.set_credentials("bblp", access_code);
options.set_transport(Transport::tls_with_config(TlsConfiguration::NativeConnector(connector)));
options.set_keep_alive(Duration::from_secs(30));
options.set_max_packet_size(Some(MAX_PACKET_SIZE as u32));
let (client, mut event_loop) = rumqttc::v5::AsyncClient::new(options, 10);
client
.subscribe(format!("device/{sn}/report"), QoSV5::AtMostOnce)
.await
.map_err(|err| PrinterError(format!("subscribe failed: {err}")))?;
match event_loop.poll().await {
Ok(_) => {}
Err(err) => return Err(PrinterError(format!("MQTT v5 connect failed: {err}"))),
}
let pending: PendingTable = Arc::new(Mutex::new(HashMap::new()));
let print_status = Arc::new(Mutex::new(PrintStatusCache::new()));
let sn_owned = sn.to_string();
tokio::spawn(poll_loop_v2(event_loop, pending.clone(), print_status.clone(), BambuProtocolVersion::V2, sn_owned));
Ok(Self {
client: BambuMqttClient::V2(client),
pending,
print_status,
sn: sn.to_string(),
next_sequence_id: AtomicU64::new(1),
})
}
/// Publishes `body` under `device/{sn}/request`, wrapped as
/// `{category: body}` (Bambu's top-level key names the *category*,
/// e.g. "print"/"info" - `body`'s own `command` field says which
/// specific one), and waits up to `REQUEST_TIMEOUT` for a report
/// whose `sequence_id` matches the one this call sent.
///
/// This crate tried adding its own correlation field
/// (`continuum_correlation_id`) instead of relying on `sequence_id`, on
/// the understanding that extra fields survive into most replies.
/// Confirmed live that this doesn't hold for `get_version`
/// specifically - a real H2C's reply had exactly `["command", "module",
/// "sequence_id"]`, nothing extra at all, so the correlation field
/// never came back and every `get_version` call just timed out.
/// `sequence_id` is what's actually proven to round-trip for it
/// (confirmed earlier, live, on both protocol versions), so that's what
/// this uses. Whether a custom field survives on some *other* command
/// (`calibration`, say) is unverified, not confirmed false - this was
/// only ever checked for `get_version`.
///
/// Not for `print.push_status` - that's not a single reply, see
/// `PrintStatusCache`. Not for a command with no reply at all either,
/// like `gcode_line` - use `publish` (fire-and-forget) for those.
pub async fn request(&self, category: &str, mut body: Value) -> Result<Value, PrinterError> {
let sequence_id = format!(
"{SEQUENCE_ID_PREFIX}{}",
self.next_sequence_id.fetch_add(1, Ordering::Relaxed)
);
body["sequence_id"] = Value::String(sequence_id.clone());
let (tx, rx) = oneshot::channel();
self.pending.lock().await.insert(sequence_id.clone(), tx);
if let Err(err) = self.publish(category, &body).await {
self.pending.lock().await.remove(&sequence_id);
return Err(err);
}
match tokio::time::timeout(REQUEST_TIMEOUT, rx).await {
Ok(Ok(response)) => Ok(response),
Ok(Err(_)) => Err(PrinterError(format!("{} closed before replying", self.sn))),
Err(_) => {
self.pending.lock().await.remove(&sequence_id);
Err(PrinterError(format!("{} did not reply within {REQUEST_TIMEOUT:?}", self.sn)))
}
}
}
/// Publishes `body` under `device/{sn}/request`, wrapped as
/// `{category: body}`, without waiting for a reply - for commands
/// like `gcode_line` and `pushing.pushall` that don't get a direct
/// correlated reply (an implicit ack the printer just executes, or an
/// ongoing stream instead of a single answer, respectively).
pub async fn publish(&self, category: &str, body: &Value) -> Result<(), PrinterError> {
self.publish_raw(serde_json::json!({ category: body }).to_string()).await
}
/// Publishes an already-serialized payload under `device/{sn}/request`
/// as-is, with no category wrapping - the low-level primitive both
/// `publish` above and `GenericPrinter::publish` (a raw pass-through,
/// no envelope opinion at all) are built on.
pub async fn publish_raw(&self, payload: String) -> Result<(), PrinterError> {
let topic = format!("device/{}/request", self.sn);
match &self.client {
BambuMqttClient::V1(client) => client
.publish(topic, QoS::AtLeastOnce, false, payload)
.await
.map_err(|err| PrinterError(format!("publish failed: {err}"))),
BambuMqttClient::V2(client) => client
.publish(topic, QoSV5::AtLeastOnce, false, payload)
.await
.map_err(|err| PrinterError(format!("publish failed: {err}"))),
}
}
/// The current in-memory `print.push_status` object, if at least one
/// has arrived since connecting - `None` until then. Cloned out from
/// behind the lock rather than handing out a guard, so callers aren't
/// holding the poll loop's own lock while they use it.
pub async fn print_status(&self) -> Option<PrintReport> {
self.print_status.lock().await.report.clone()
}
}
/// Runs forever, polling the v3.1.1 eventloop and routing incoming
/// reports (pending-request correlation, `print.push_status` caching).
/// Exits - loudly, not silently like before (that silence is exactly
/// what made a real dropped connection to a live H2C look identical to
/// "the printer just isn't sending anything") - once the connection is
/// gone for good. Dropping the `BambuMqttSession` drops its `AsyncClient`,
/// which is what eventually makes that happen during normal shutdown;
/// this also fires on an unexpected disconnect, which is the case this
/// logging is actually for. No reconnect logic yet - that's the next
/// thing to add here, not something to fake by pretending the loop
/// didn't end.
async fn poll_loop_v1(
mut event_loop: rumqttc::EventLoop,
pending: PendingTable,
print_status: Arc<Mutex<PrintStatusCache>>,
version: BambuProtocolVersion,
sn: String,
) {
loop {
match event_loop.poll().await {
Ok(Event::Incoming(Packet::Publish(publish))) => {
handle_incoming_report(&sn, &pending, &print_status, version, &publish.payload).await;
}
Ok(_) => {}
Err(err) => {
eprintln!("[{sn}] MQTT v3.1.1 connection lost, no longer receiving reports: {err}");
return;
}
}
}
}
async fn poll_loop_v2(
mut event_loop: rumqttc::v5::EventLoop,
pending: PendingTable,
print_status: Arc<Mutex<PrintStatusCache>>,
version: BambuProtocolVersion,
sn: String,
) {
loop {
match event_loop.poll().await {
Ok(rumqttc::v5::Event::Incoming(PacketV5::Publish(publish))) => {
handle_incoming_report(&sn, &pending, &print_status, version, &publish.payload).await;
}
Ok(_) => {}
Err(err) => {
eprintln!("[{sn}] MQTT v5 connection lost, no longer receiving reports: {err}");
return;
}
}
}
}
/// Parses an incoming report, unwraps its single category key (a report
/// is `{category: {"sequence_id": "...", ...}}`, same shape a request is
/// published as - `request` publishes `body` and wants `body`-shaped data
/// back, not the wrapper around it), and:
///
/// 1. If its `sequence_id` matches something `request` is waiting on,
/// hands it the unwrapped body - but doesn't wake that waiter yet
/// (see 3 for why).
/// 2. If the unwrapped body is a `print.push_status`, folds it into the
/// running `PrintStatusCache` (which reads the message's own `msg`
/// field to decide full-vs-merge, not anything to do with step 1 - see
/// `PrintStatusCache::apply`'s doc comment for why not).
/// 3. Only now - once the cache is guaranteed up to date - actually sends
/// on whichever oneshot matched in step 1, waking `request`. The order
/// of 2 and 3 is load-bearing, caught live on an earlier version of
/// this mechanism: sending on the oneshot first let a waiter's task
/// resume and check `print_status()` before this function had gotten
/// around to updating it. Nothing here awaits between the cache write
/// and the send, so that race stays closed.
///
/// A report matching nothing pending isn't an error - most Bambu reports
/// (including every organic `push_status` push) aren't a reply to
/// anything specific.
async fn handle_incoming_report(
sn: &str,
pending: &PendingTable,
print_status: &Arc<Mutex<PrintStatusCache>>,
version: BambuProtocolVersion,
payload: &[u8],
) {
// The raw bytes, before anything gets parsed or thrown away. `PrintReport`
// deliberately models only a fraction of what a real `push_status`
// carries (see its doc comment), so without this the rest is
// unrecoverable - you can't add a field to that struct if you can never
// see what the printer actually sent. At `trace` so it costs nothing
// normally; turn it on with
// `RUST_LOG=continuum_proxy::printer::bambu_mqtt=trace`.
tracing::trace!(target: "continuum_proxy::printer::bambu_mqtt", sn, raw = %String::from_utf8_lossy(payload), "incoming report");
let Ok(value) = serde_json::from_slice::<Value>(payload) else { return };
let Some(object) = value.as_object() else { return };
let Some((category, body)) = object.iter().next() else { return };
let request_sender = match body.get("sequence_id").and_then(Value::as_str) {
Some(sequence_id) => pending.lock().await.remove(sequence_id),
None => None,
};
if category == "print" && body.get("command").and_then(Value::as_str) == Some("push_status") {
match serde_json::from_value::<PrintReport>(body.clone()) {
Ok(report) => print_status.lock().await.apply(report, version),
// Loudly, not silently. This arm used to be an `if let Ok(..)`
// with no `else`, and it cost two misdiagnosed symptoms on real
// hardware: `print_status()` stayed `None` forever on an H2C
// that was in fact pushing a full report every second (read as
// "v2 doesn't push unprompted", which was wrong), and every v1
// push carrying AMS data vanished - which the gap check below
// then reported as a *sequence gap*, blaming the network for a
// local deserialization bug. A report this process can't parse
// is a real defect in `PrintReport`, and the only way to fix it
// is to be told which field.
Err(err) => eprintln!(
"[{sn}] print.push_status (sequence_id {}) did not match PrintReport, dropping it: \
{err} - add the field to PrintReport and a fixture to \
tests/push_status_parsing.rs",
body.get("sequence_id").and_then(Value::as_str).unwrap_or("?")
),
}
}
if let Some(sender) = request_sender {
let _ = sender.send(body.clone());
}
}