Add pushall + print_status cache; fix envelope, race, and matching bugs

PrintReport now flattens CommandEnvelope (sequence_id/command) -
push_status.schema.json composes the shared envelope like every other
Bambu message; the struct's old doc comment concluded otherwise from a
capture that turned out to be an incomplete example (also missing dozens
of other real fields), not proof the message lacks them.

Added BambuMqttSession's PrintStatusCache: the in-memory
print.push_status object, replaced wholesale on v2 (always full) or on a
v1 message with msg != 1 (forced/full), merged field-by-field otherwise
(v1's organic partial updates) via PrintReport::merge_from. Tracks
last_sequence_id to warn on a gap in the organic stream. BambuPrinter
gets pushall() (v1-only; pushing.pushall doesn't exist in v2's schema at
all) and print_status().

Three real bugs found and fixed via live testing against an actual P1P,
none of them guessable from the schema alone:
- pushall's reply doesn't echo the sequence_id we send - the printer
  stamps its own free-running counter regardless of what triggered it.
  request()'s ID correlation silently can't work here; first fix attempt
  added request_next_of_category (wait for the next message under a
  category, no ID needed).
- That undershot too: matching by category alone let an unrelated
  "print" message (that category carries many commands - gcode_line,
  pause, push_status, ...) satisfy the wait before the real push_status
  reply arrived, so pushall() returned Ok having consumed the wrong
  message - now keyed by (category, command).
- A real ordering race: handle_incoming_report woke a waiting oneshot
  before updating the print_status cache, so a caller could resume and
  check print_status() before the write landed - pushall() returned Ok
  immediately followed by print_status() still showing None. Fixed by
  applying the cache update before sending on any matched oneshot.

Superseded by real protocol knowledge before any of the above needed to
ship, though: push_status carries its own msg field (1 = automatic/
partial, 0 = forced/full) - the actual, reliable, self-describing signal
for which kind a message is, regardless of timing or which client (if
any) triggered it. PrintStatusCache now reads that directly instead of
inferring it from request correlation, and pushall() is plain fire-and-
forget (publish, no wait) since there's nothing left to correlate for -
confirmed live: msg reads correctly off a real P1P, and the cache
populates/updates correctly with no gap warnings across several organic
pushes. request_next_of_category is kept (a future case may still need
"no correlation ID at all" for real), just no longer used here.

Also: the poll loop now logs when it exits instead of returning silently
- that silence is exactly what made a real dropped MQTT connection to a
live H2C indistinguishable from "nothing happening" during testing.
This commit is contained in:
2026-09-07 14:39:55 +00:00
parent 835a4ae778
commit 6ce60542c6
4 changed files with 452 additions and 86 deletions
+7 -1
View File
@@ -36,8 +36,14 @@ fn main() {
// --- print report: arrives under "print", NOT "pushing" (pushall // --- print report: arrives under "print", NOT "pushing" (pushall
// triggers this stream, it isn't a direct reply to it) - includes the // triggers this stream, it isn't a direct reply to it) - includes the
// full ams/xcam example, with one empty tray and one loaded tray, to // full ams/xcam example, with one empty tray and one loaded tray, to
// exercise the untagged Empty/Loaded split. // exercise the untagged Empty/Loaded split. sequence_id/command added
// by hand here (the original real capture this was built from was
// itself a truncated example that happened to omit them, along with
// dozens of other real fields the schema defines - not proof the real
// message lacks them, see this struct's own doc comment).
let real_print_report = r#"{ let real_print_report = r#"{
"sequence_id": "1",
"command": "push_status",
"ams": {"ams": [{"humidity":"4","id":"0","temp":"22.7","tray":[ "ams": {"ams": [{"humidity":"4","id":"0","temp":"22.7","tray":[
{"id":"0"}, {"id":"0"},
{"bed_temp":"0","bed_temp_type":"0","cols":["000000FF"],"drying_temp":"0","drying_time":"0","id":"1","nozzle_temp_max":"240","nozzle_temp_min":"190","remain":0,"tag_uid":"0000000000000000","tray_color":"000000FF","tray_diameter":"0.00","tray_id_name":"","tray_info_idx":"GFA00","tray_sub_brands":"","tray_type":"PLA","tray_uuid":"00000000000000000000000000000000","tray_weight":"0","xcam_info":"000000000000000000000000"} {"bed_temp":"0","bed_temp_type":"0","cols":["000000FF"],"drying_temp":"0","drying_time":"0","id":"1","nozzle_temp_max":"240","nozzle_temp_min":"190","remain":0,"tag_uid":"0000000000000000","tray_color":"000000FF","tray_diameter":"0.00","tray_id_name":"","tray_info_idx":"GFA00","tray_sub_brands":"","tray_type":"PLA","tray_uuid":"00000000000000000000000000000000","tray_weight":"0","xcam_info":"000000000000000000000000"}
+51
View File
@@ -402,6 +402,57 @@ impl BambuPrinter {
serde_json::from_value(response) serde_json::from_value(response)
.map_err(|err| PrinterError(format!("failed to parse get_version response: {err}"))) .map_err(|err| PrinterError(format!("failed to parse get_version response: {err}")))
} }
/// Bambu-only, same reasoning as `get_version` above. Publishes
/// `pushing.pushall`, which - unlike `get_version` - doesn't reply
/// under its own category: it triggers/refreshes the ongoing
/// `print.push_status` stream instead. Fire-and-forget, same as
/// `send_gcode_line`: there's nothing to wait for here, since the
/// forced full update this triggers self-describes as such via its
/// own `msg` field once it arrives (see `PrintStatusCache::apply`),
/// whenever that turns out to be - waiting on it added two real bugs
/// (a race, and matching the wrong "print" message) for no benefit
/// once that was understood.
///
/// Call this periodically for v1 printers specifically - v2 has no
/// `pushing` category at all (checked: `schemas/bambulab/v2/
/// request.schema.json` only wires camera/info/print/system/upgrade/
/// xcam). Whether v2 needs some *other* trigger to get
/// `print.push_status` flowing at all is still an open question (a
/// live test meant to check that lost its connection entirely, an
/// unrelated failure, not real evidence either way) - if it does,
/// that's a different method to add once identified, not a variant
/// of this one. A v2 connection makes this call a no-op rather than
/// an error, since `pushing.pushall` specifically is confirmed not to
/// exist for it regardless.
pub async fn pushall(&self) -> Result<(), PrinterError> {
let (session, version) =
self.connection.as_ref().ok_or_else(|| PrinterError(format!("{} is not connected", self.base.name)))?;
if matches!(version, BambuProtocolVersion::V2) {
return Ok(());
}
let mut body = continuum_schema_instance::build_instance_from_file(&get_schema_for_command(
*version,
"pushing".into(),
"pushall".into(),
))
.map_err(|err| PrinterError(format!("failed to build pushall request: {err}")))?;
body["push_target"] = serde_json::Value::from(1);
session.publish("pushing", &body).await
}
/// The current in-memory `print.push_status` object - `None` until at
/// least one has arrived (which, on v1, requires having called
/// `pushall` at least once since connecting; v2 pushes on its own).
pub async fn print_status(&self) -> Option<super::PrintReport> {
match self.connection.as_ref() {
Some((session, _)) => session.print_status().await,
None => None,
}
}
} }
impl GenericPrinter for BambuPrinter { impl GenericPrinter for BambuPrinter {
+69 -13
View File
@@ -1,11 +1,29 @@
//! The ongoing print-state push (root key "print"). Doesn't carry a //! The ongoing print-state push (root key "print"). The one real capture
//! `CommandEnvelope` - no `sequence_id`/`command` seen on this one in the //! this struct was originally built from didn't show `sequence_id`/
//! real capture, and it may genuinely not have them (an async push, not a //! `command` - turned out to be because that capture was itself a
//! reply to a specific request) - revisit if a real capture proves //! truncated example (it's also missing dozens of other fields the real
//! otherwise. //! schema defines), not because the message genuinely lacks them:
//! `push_status.schema.json` composes the shared envelope schema like
//! every other Bambu message, so it does carry `sequence_id` - which
//! matters a lot, see `BambuMqttSession`'s doc comment on how it's used
//! both to correlate a `pushall` reply and to detect a gap in the
//! ongoing organic stream.
use super::CommandEnvelope;
#[derive(serde::Deserialize, Debug)] #[derive(serde::Deserialize, Debug, Clone)]
pub struct PrintReport { pub struct PrintReport {
#[serde(flatten)]
pub envelope: CommandEnvelope,
/// BambuMQTT v1 only: `1` on an automatic/organic push (a partial
/// update - only the fields that changed), `0` on a forced one (a
/// `pushing.pushall`-triggered reply - the full object). This is the
/// actual, reliable signal for which kind a given message is -
/// confirmed against a real printer, unlike trying to infer it from
/// request/reply timing (see `BambuMqttSession`'s `PrintStatusCache`,
/// which used to try that and got it wrong twice). Not in
/// `push_status.schema.json` yet - add it there too when you're back
/// in continuum-schemas.
pub msg: Option<i32>,
pub ams: Option<AmsState>, pub ams: Option<AmsState>,
pub ams_exist_bits: Option<String>, pub ams_exist_bits: Option<String>,
pub insert_flag: Option<bool>, pub insert_flag: Option<bool>,
@@ -16,19 +34,57 @@ pub struct PrintReport {
pub xcam_status: Option<String>, pub xcam_status: Option<String>,
// The real payload has a lot more fields than shown here (this // The real payload has a lot more fields than shown here (this
// example was truncated) - add them the same way, one field per line, // example was truncated) - add them the same way, one field per line,
// `Option<T>` for anything that isn't always present. // `Option<T>` for anything that isn't always present. Remember to
// also add each new field to `merge_from` below.
}
impl PrintReport {
/// Merges `newer`'s fields into `self`, in place - BambuMQTT v1's
/// organic `push_status` pushes only carry the fields that changed,
/// so any field `newer` doesn't have (`None`) leaves `self`'s
/// existing value alone instead of clobbering it. Not used for v2
/// (always a full object) or for a `pushall`-triggered reply on
/// either version (also guaranteed full) - see `BambuMqttSession`.
pub fn merge_from(&mut self, newer: PrintReport) {
self.envelope = newer.envelope;
self.msg = newer.msg;
if newer.ams.is_some() {
self.ams = newer.ams;
}
if newer.ams_exist_bits.is_some() {
self.ams_exist_bits = newer.ams_exist_bits;
}
if newer.insert_flag.is_some() {
self.insert_flag = newer.insert_flag;
}
if newer.power_on_flag.is_some() {
self.power_on_flag = newer.power_on_flag;
}
if newer.tray_exist_bits.is_some() {
self.tray_exist_bits = newer.tray_exist_bits;
}
if newer.tray_is_bbl_bits.is_some() {
self.tray_is_bbl_bits = newer.tray_is_bbl_bits;
}
if newer.xcam.is_some() {
self.xcam = newer.xcam;
}
if newer.xcam_status.is_some() {
self.xcam_status = newer.xcam_status;
}
}
} }
/// Bambu's own naming has the outer container and the inner array both /// Bambu's own naming has the outer container and the inner array both
/// called `ams` - `print.ams.ams`, not a typo here, just what the printer /// called `ams` - `print.ams.ams`, not a typo here, just what the printer
/// actually sends. /// actually sends.
#[derive(serde::Deserialize, Debug)] #[derive(serde::Deserialize, Debug, Clone)]
pub struct AmsState { pub struct AmsState {
pub ams: Vec<AmsUnit>, pub ams: Vec<AmsUnit>,
} }
/// One physical AMS unit (Bambu's numbering starts at 0 via `id`). /// One physical AMS unit (Bambu's numbering starts at 0 via `id`).
#[derive(serde::Deserialize, Debug)] #[derive(serde::Deserialize, Debug, Clone)]
pub struct AmsUnit { pub struct AmsUnit {
pub humidity: String, pub humidity: String,
pub id: String, pub id: String,
@@ -44,20 +100,20 @@ pub struct AmsUnit {
/// every field `Empty` allows, plus more) would incorrectly match `Empty` /// every field `Empty` allows, plus more) would incorrectly match `Empty`
/// first, since a plain struct doesn't reject extra fields by default. /// first, since a plain struct doesn't reject extra fields by default.
/// Verified this the hard way - see git history. /// Verified this the hard way - see git history.
#[derive(serde::Deserialize, Debug)] #[derive(serde::Deserialize, Debug, Clone)]
#[serde(untagged)] #[serde(untagged)]
pub enum AmsTray { pub enum AmsTray {
Empty(EmptyTray), Empty(EmptyTray),
Loaded(LoadedTray), Loaded(LoadedTray),
} }
#[derive(serde::Deserialize, Debug)] #[derive(serde::Deserialize, Debug, Clone)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct EmptyTray { pub struct EmptyTray {
pub id: String, pub id: String,
} }
#[derive(serde::Deserialize, Debug)] #[derive(serde::Deserialize, Debug, Clone)]
pub struct LoadedTray { pub struct LoadedTray {
pub id: String, pub id: String,
pub bed_temp: String, pub bed_temp: String,
@@ -82,7 +138,7 @@ pub struct LoadedTray {
/// Camera-based AI print monitoring settings (spaghetti detection, /// Camera-based AI print monitoring settings (spaghetti detection,
/// first-layer inspection, etc.). /// first-layer inspection, etc.).
#[derive(serde::Deserialize, Debug)] #[derive(serde::Deserialize, Debug, Clone)]
pub struct XcamSettings { pub struct XcamSettings {
pub allow_skip_parts: bool, pub allow_skip_parts: bool,
pub buildplate_marker_detector: bool, pub buildplate_marker_detector: bool,
+325 -72
View File
@@ -1,6 +1,6 @@
//! The real MQTT wiring for Bambu printers - the piece `connect`/ //! The real MQTT wiring for Bambu printers - the piece `connect`/
//! `send_gcode_line` in `bambu.rs` used to just stub out with a //! `send_gcode_line` in `bambu.rs` used to just stub out with a
//! `println!`. Two things make this more than "call `publish`, get a //! `println!`. Three things make this more than "call `publish`, get a
//! `String` back": //! `String` back":
//! //!
//! 1. MQTT v3.1.1 (BambuMQTT v1) and MQTT v5 (BambuMQTT v2) are two //! 1. MQTT v3.1.1 (BambuMQTT v1) and MQTT v5 (BambuMQTT v2) are two
@@ -11,21 +11,39 @@
//! struct you're holding. //! struct you're holding.
//! 2. A response to a published request doesn't come back from that same //! 2. A response to a published request doesn't come back from that same
//! call - it arrives later, asynchronously, on a *subscription* to //! call - it arrives later, asynchronously, on a *subscription* to
//! `device/{sn}/report`, correlated only by matching `sequence_id`. //! `device/{sn}/report`. That needs a background task continuously
//! That needs: a background task continuously polling the connection //! polling the connection (rumqttc doesn't do this for you - nothing
//! (rumqttc doesn't do this for you - nothing happens until you poll //! happens until you poll the `EventLoop` yourself, repeatedly,
//! the `EventLoop` yourself, repeatedly, forever), and a shared table //! forever) - this is the first real use of `tokio::spawn` in this
//! of "who's waiting for which `sequence_id`" that task completes as //! crate, flagged as deferred since `printer/mod.rs`'s very first doc
//! reports arrive. This is the first real use of `tokio::spawn` in //! comment, and this is why: it only makes sense once there's a
//! this crate - flagged as deferred since `printer/mod.rs`'s very //! long-running background job that has to run independently of
//! first doc comment, and this is why: it only makes sense once there's //! whatever call site is waiting on a response.
//! a long-running background job (polling forever) that has to run
//! independently of whatever call site is waiting on a response.
//! //!
//! Unverified against a live printer (no broker to test against here): //! Two genuinely different correlation problems live here, confirmed
//! the exact `ConnectReturnCode`/error shape a real Bambu printer's //! against real printers, not assumed:
//! embedded broker gives when it doesn't understand MQTT v5's CONNECT at //! - `info.get_version`'s reply *does* echo the `sequence_id` you sent
//! all (rather than a clean version-rejection CONNACK) - `open()` below //! - `request`/`pending` (keyed by sequence_id) is correct for this,
//! and for anything else empirically confirmed to behave the same
//! way.
//! - `print.push_status` does NOT - the printer stamps it with its own
//! free-running counter regardless of what triggered it (confirmed
//! live: a `pushing.pushall` request's sequence_id never came back on
//! the reply). So there's no ID to match a `pushall` reply against at
//! all - `next_category`, keyed by category name instead of an ID,
//! is "wait for the *next* message under this category, whichever
//! one it is" - the only thing that actually works here.
//! 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 //! 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 //! just a precisely-matched version-rejection code, since a broker that
//! can't parse a v5 CONNECT packet may just drop the connection instead //! can't parse a v5 CONNECT packet may just drop the connection instead
@@ -43,26 +61,114 @@ use serde_json::Value;
use tokio::sync::{oneshot, Mutex}; use tokio::sync::{oneshot, Mutex};
use super::bambu::BambuTls; use super::bambu::BambuTls;
use super::PrinterError; use super::bambu_commands::PrintReport;
use super::BambuProtocolVersion; use super::BambuProtocolVersion;
use super::PrinterError;
/// Requests waiting on a report with a matching `sequence_id`. Shared /// Requests waiting on a report with a matching `sequence_id` - only
/// between whichever call registered one (in `request`) and the /// valid for a command empirically confirmed to echo it back, like
/// background poll task that completes it when the report arrives. /// `get_version`. 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>>>>; type PendingTable = Arc<Mutex<HashMap<String, oneshot::Sender<Value>>>>;
/// Callers waiting on the *next* `(category, command)` message,
/// regardless of its `sequence_id` - for a reply with no usable
/// correlation ID at all, like `pushall`'s. Keyed by the pair, not just
/// category: a category like "print" carries many different commands
/// (`gcode_line`, `pause`, `push_status`, ...), and matching on category
/// alone means some unrelated "print" message arriving first would
/// silently satisfy the wait instead of the one actually meant - caught
/// live, see `request_next_of_category`'s doc comment. One slot per
/// pair: a second wait registered before the first resolves silently
/// replaces it (the first caller's `rx` then just sees the channel
/// close) - fine for now since nothing calls `pushall` concurrently with
/// itself.
type NextCategoryTable = Arc<Mutex<HashMap<(String, String), oneshot::Sender<Value>>>>;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
/// Old firmware has been observed taking noticeably longer than
/// `REQUEST_TIMEOUT` to answer `pushall` - confirmed live (a real P1P's
/// first reply arrived after that 10s window had already elapsed).
const NEXT_CATEGORY_TIMEOUT: Duration = Duration::from_secs(30);
enum BambuMqttClient { enum BambuMqttClient {
V1(rumqttc::AsyncClient), V1(rumqttc::AsyncClient),
V2(rumqttc::v5::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. Two earlier attempts
/// at inferring this from request/reply *timing* instead (matching
/// `sequence_id`, then "wait for the next print/push_status message")
/// both turned out wrong in practice - the printer stamps `sequence_id`
/// from its own counter regardless of what triggered a push, and
/// category-only matching couldn't tell one "print" message from
/// another. `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.
/// - `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);
if let (Some(last), Some(incoming_seq), false) = (self.last_sequence_id, incoming_seq, is_full_object) {
if 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 /// One live MQTT connection to a Bambu printer - whichever protocol
/// version `open()` settled on. /// version `open()` settled on.
pub struct BambuMqttSession { pub struct BambuMqttSession {
client: BambuMqttClient, client: BambuMqttClient,
pending: PendingTable, pending: PendingTable,
next_category: NextCategoryTable,
print_status: Arc<Mutex<PrintStatusCache>>,
sn: String, sn: String,
next_sequence_id: AtomicU64, next_sequence_id: AtomicU64,
} }
@@ -124,9 +230,26 @@ impl BambuMqttSession {
} }
let pending: PendingTable = Arc::new(Mutex::new(HashMap::new())); let pending: PendingTable = Arc::new(Mutex::new(HashMap::new()));
tokio::spawn(poll_loop_v1(event_loop, pending.clone())); let next_category: NextCategoryTable = 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(),
next_category.clone(),
print_status.clone(),
BambuProtocolVersion::V1,
sn_owned,
));
Ok(Self { client: BambuMqttClient::V1(client), pending, sn: sn.to_string(), next_sequence_id: AtomicU64::new(1) }) Ok(Self {
client: BambuMqttClient::V1(client),
pending,
next_category,
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> { async fn open_v2(host: &str, access_code: &str, sn: &str, tls: &BambuTls) -> Result<Self, PrinterError> {
@@ -149,9 +272,26 @@ impl BambuMqttSession {
} }
let pending: PendingTable = Arc::new(Mutex::new(HashMap::new())); let pending: PendingTable = Arc::new(Mutex::new(HashMap::new()));
tokio::spawn(poll_loop_v2(event_loop, pending.clone())); let next_category: NextCategoryTable = 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(),
next_category.clone(),
print_status.clone(),
BambuProtocolVersion::V2,
sn_owned,
));
Ok(Self { client: BambuMqttClient::V2(client), pending, sn: sn.to_string(), next_sequence_id: AtomicU64::new(1) }) Ok(Self {
client: BambuMqttClient::V2(client),
pending,
next_category,
print_status,
sn: sn.to_string(),
next_sequence_id: AtomicU64::new(1),
})
} }
/// Publishes `body` under `device/{sn}/request`, wrapped as /// Publishes `body` under `device/{sn}/request`, wrapped as
@@ -163,26 +303,15 @@ impl BambuMqttSession {
/// empty placeholder, since it has no way to know a real one) with a /// empty placeholder, since it has no way to know a real one) with a
/// fresh one from this session's own counter. /// fresh one from this session's own counter.
/// ///
/// Only correct for commands that genuinely get *one direct, /// Only use this for a command *empirically confirmed* to echo the
/// sequence_id-correlated reply* - `info.get_version` is the /// sequence_id you send back on its reply - `info.get_version` is
/// canonical example (the envelope schema says as much: "Sequence ID /// verified to do this against real hardware. Don't assume every
/// matching the original request"). It is NOT a general "send a /// command does just because the field exists on every message (they
/// command, get the response" primitive - Bambu's own mapping isn't /// all compose the same envelope schema): `print.push_status` also
/// 1:1: /// has a `sequence_id`, but a live P1P proved the printer stamps it
/// - `pushing.pushall` doesn't reply at all - it triggers an *ongoing* /// with its own free-running counter regardless of what triggered
/// `print.push_status` stream, unrelated to this request's /// it - `pushall` uses `next_category` below instead, precisely
/// `sequence_id`. Use `publish` (fire-and-forget) for it, the same /// because this method's assumption doesn't hold for it.
/// way `send_gcode_line` already does for `gcode_line`.
/// - Even where a field genuinely exists, which report carries it can
/// differ *by protocol version*: `print.calibration` data is bundled
/// inside `print.push_status` on BambuMQTT v1, but v2 gives it its
/// own dedicated report. Calling `request` for something like that
/// would just hang until `REQUEST_TIMEOUT` waiting for a reply that
/// was never going to arrive correlated by `sequence_id` - not a
/// crash, but a silent design bug if used on the wrong kind of
/// command. An ongoing-stream subscription (register interest in a
/// *category*, get every report under it as it arrives, not a single
/// `oneshot`) is a separate primitive this doesn't provide yet.
pub async fn request(&self, category: &str, mut body: Value) -> Result<Value, PrinterError> { pub async fn request(&self, category: &str, mut body: Value) -> Result<Value, PrinterError> {
let sequence_id = self.next_sequence_id.fetch_add(1, Ordering::Relaxed).to_string(); let sequence_id = self.next_sequence_id.fetch_add(1, Ordering::Relaxed).to_string();
body["sequence_id"] = Value::String(sequence_id.clone()); body["sequence_id"] = Value::String(sequence_id.clone());
@@ -205,6 +334,51 @@ impl BambuMqttSession {
} }
} }
/// Publishes `body` under `device/{sn}/request`, wrapped as
/// `{category: body}`, then waits up to `NEXT_CATEGORY_TIMEOUT` for
/// the *next* `reply_category`/`reply_command` report to arrive - not
/// matched by any ID, just whichever one shows up next. For a command
/// whose reply carries no usable correlation ID at all (see
/// `request`'s doc comment) - currently just `pushall`, but written
/// generically since nothing about the mechanism is push_status-
/// specific.
///
/// Matching needs *both* `reply_category` and `reply_command`, not
/// category alone - caught live: matching on category only let an
/// unrelated "print" message (that category carries many commands -
/// `gcode_line`, `pause`, `push_status`, ...) satisfy a `pushall` wait
/// before the real `push_status` reply had even arrived, so
/// `pushall()` returned `Ok` having consumed the wrong message
/// entirely, and the in-memory cache never actually got its update.
pub async fn request_next_of_category(
&self,
category: &str,
body: Value,
reply_category: &str,
reply_command: &str,
) -> Result<Value, PrinterError> {
let key = (reply_category.to_string(), reply_command.to_string());
let (tx, rx) = oneshot::channel();
self.next_category.lock().await.insert(key.clone(), tx);
if let Err(err) = self.publish(category, &body).await {
self.next_category.lock().await.remove(&key);
return Err(err);
}
match tokio::time::timeout(NEXT_CATEGORY_TIMEOUT, rx).await {
Ok(Ok(response)) => Ok(response),
Ok(Err(_)) => Err(PrinterError(format!("{} closed before replying", self.sn))),
Err(_) => {
self.next_category.lock().await.remove(&key);
Err(PrinterError(format!(
"{} sent no {reply_category}/{reply_command} within {NEXT_CATEGORY_TIMEOUT:?}",
self.sn
)))
}
}
}
/// Publishes `body` under `device/{sn}/request`, wrapped as /// Publishes `body` under `device/{sn}/request`, wrapped as
/// `{category: body}`, without waiting for a reply - for commands /// `{category: body}`, without waiting for a reply - for commands
/// like `gcode_line` that only get acknowledged implicitly (the /// like `gcode_line` that only get acknowledged implicitly (the
@@ -231,59 +405,138 @@ impl BambuMqttSession {
.map_err(|err| PrinterError(format!("publish failed: {err}"))), .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 completing pending /// Runs forever, polling the v3.1.1 eventloop and routing incoming
/// requests as matching reports arrive. Ends (silently) once the /// reports (pending-request correlation, `print.push_status` caching).
/// connection is gone for good - dropping the `BambuMqttSession` drops /// Exits - loudly, not silently like before (that silence is exactly
/// its `AsyncClient`, which is what eventually makes that happen. /// what made a real dropped connection to a live H2C look identical to
async fn poll_loop_v1(mut event_loop: rumqttc::EventLoop, pending: PendingTable) { /// "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,
next_category: NextCategoryTable,
print_status: Arc<Mutex<PrintStatusCache>>,
version: BambuProtocolVersion,
sn: String,
) {
loop { loop {
match event_loop.poll().await { match event_loop.poll().await {
Ok(Event::Incoming(Packet::Publish(publish))) => { Ok(Event::Incoming(Packet::Publish(publish))) => {
complete_pending(&pending, &publish.payload).await; handle_incoming_report(&pending, &next_category, &print_status, version, &publish.payload).await;
} }
Ok(_) => {} Ok(_) => {}
Err(_) => return, 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) { async fn poll_loop_v2(
mut event_loop: rumqttc::v5::EventLoop,
pending: PendingTable,
next_category: NextCategoryTable,
print_status: Arc<Mutex<PrintStatusCache>>,
version: BambuProtocolVersion,
sn: String,
) {
loop { loop {
match event_loop.poll().await { match event_loop.poll().await {
Ok(rumqttc::v5::Event::Incoming(PacketV5::Publish(publish))) => { Ok(rumqttc::v5::Event::Incoming(PacketV5::Publish(publish))) => {
complete_pending(&pending, &publish.payload).await; handle_incoming_report(&pending, &next_category, &print_status, version, &publish.payload).await;
} }
Ok(_) => {} Ok(_) => {}
Err(_) => return, Err(err) => {
eprintln!("[{sn}] MQTT v5 connection lost, no longer receiving reports: {err}");
return;
}
} }
} }
} }
/// Parses an incoming report and, if its `sequence_id` matches something /// Parses an incoming report, unwraps its single category key (a report
/// `request` is waiting on, hands it the *unwrapped* body. A report with /// is `{category: {"sequence_id": "...", ...}}`, same shape a request is
/// no `sequence_id`, or one nothing's waiting on (an unsolicited status /// published as - both `request` and `request_next_of_category` publish
/// push, not a reply to anything), is silently ignored - most Bambu /// `body` and want `body`-shaped data back, not the wrapper around it),
/// reports aren't replies to a specific request at all. /// and:
/// ///
/// A report is `{category: {"sequence_id": "...", ...}}` - one key, same /// 1. If its `sequence_id` matches something `request` is waiting on,
/// shape a request is published as. `request` publishes `body` and wants /// hands it the unwrapped body.
/// `body`-shaped data back, not the wrapper around it, so this looks /// 2. If its `(category, command)` matches something
/// inside whichever single category key is present (rather than /// `request_next_of_category` is waiting on, hands it the unwrapped
/// requiring the caller to already know it) and sends only that inner /// body too - independent of 1, since the whole reason this table
/// value through - confirmed against a real printer's `get_version` /// exists is replies with no usable correlation ID.
/// reply, which failed to parse as `GetVersionResponse` before this /// 3. If the unwrapped body is a `print.push_status`, folds it into the
/// unwrapped it (real device, real bug: `serde_json::from_value` on the /// running `PrintStatusCache` (which decides full-vs-merge on its own,
/// still-wrapped `{"info": {...}}` reported "missing field `module`", /// from the message's own `msg` field - not from whether step 1 or 2
/// since `module` lives one level deeper than that). /// matched, see `PrintStatusCache::apply`'s doc comment for why not)
async fn complete_pending(pending: &PendingTable, payload: &[u8]) { /// - and *before* step 4 wakes anyone up, not after (see 4).
/// 4. Only now - once the cache is guaranteed up to date - actually sends
/// on whichever oneshot(s) matched in steps 1/2, waking `request`/
/// `request_next_of_category`.
///
/// The order of 3 and 4 is load-bearing, caught live: sending on the
/// oneshot first let a waiter's task resume and check `print_status()`
/// before this function had gotten around to updating it - `pushall()`
/// returned `Ok`, and the very next `print_status()` call still saw
/// `None`. Nothing here awaits between the cache write and the sends
/// below, so that race is now closed.
///
/// A report matching neither table 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(
pending: &PendingTable,
next_category: &NextCategoryTable,
print_status: &Arc<Mutex<PrintStatusCache>>,
version: BambuProtocolVersion,
payload: &[u8],
) {
let Ok(value) = serde_json::from_slice::<Value>(payload) else { return }; let Ok(value) = serde_json::from_slice::<Value>(payload) else { return };
let Some(object) = value.as_object() else { return }; let Some(object) = value.as_object() else { return };
let Some((_, body)) = object.iter().next() else { return }; let Some((category, body)) = object.iter().next() else { return };
let Some(sequence_id) = body.get("sequence_id").and_then(Value::as_str) else { return };
if let Some(sender) = pending.lock().await.remove(sequence_id) { let command = body.get("command").and_then(Value::as_str);
let request_sender =
match body.get("sequence_id").and_then(Value::as_str) {
Some(sequence_id) => pending.lock().await.remove(sequence_id),
None => None,
};
let next_category_sender = match command {
Some(command) => next_category.lock().await.remove(&(category.clone(), command.to_string())),
None => None,
};
// push_status doesn't need to know whether this client solicited it -
// full-vs-partial is self-described by the message's own `msg` field,
// see `PrintStatusCache::apply`.
if category == "print" && command == Some("push_status") {
if let Ok(report) = serde_json::from_value::<PrintReport>(body.clone()) {
print_status.lock().await.apply(report, version);
}
}
if let Some(sender) = request_sender {
let _ = sender.send(body.clone());
}
if let Some(sender) = next_category_sender {
let _ = sender.send(body.clone()); let _ = sender.send(body.clone());
} }
} }