From 9a314b2b9f4c2229d182aa2288c33e3587a3cf4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iwo=20Strzebo=C5=84ski?= Date: Sat, 3 Oct 2026 10:12:05 +0000 Subject: [PATCH] Fix print.push_status parsing, and stop losing reports silently Pointed at a real H2C that was 20% into a job, `print_status()` returned nothing at all. The printer was in fact pushing a full status object every second; every one was arriving, failing to deserialize, and being dropped invisibly by an `if let Ok(report) = ..` with no `else`. That silence produced two confidently-wrong claims in our own comments, both corrected here: - `pushall`'s doc said whether v2 emits `push_status` unprompted was "an open question". It is not: v2 pushes a full 97-field object roughly once a second from the moment you subscribe, no trigger needed. There is no missing "other trigger" to hunt for. - The "sequence gap" warning blamed the network for dropped v1 updates. Those updates were not dropped in transit - they failed to parse, so they never reached the counter, making the next one that did look like a skip. Confirmed: seq 57, 62, 64 and 69 all arrived. After the fix, zero gaps and zero parse failures across a 25s run against both printers. Two concrete causes, both found with captured payloads: - v1 trays carry `tray_temp`/`tray_time`; v2 trays carry `drying_temp`/`drying_time`. All four were required, so *every* v1 push carrying AMS data failed. - A third tray shape existed that nothing modelled: an empty v2 slot sends `{"id": "2", "state": 0}`, which matched neither `EmptyTray` (it denies unknown fields, deliberately) nor `LoadedTray` (16 fields short). A parse failure now reports itself, naming the printer, the sequence id and the serde error. A report this process cannot parse is a defect in `PrintReport`, and the only way to fix it is to be told which field. `PrintReport` itself modelled 9 of 97 top-level fields, and none of them were progress or temperature - it was built from a truncated example. It now carries gcode_state, percent, layer counts, remaining time, nozzle/bed temperatures, fan gears, wifi and HMS entries. `merge_from`'s two dozen four-line blocks collapse to `newer.x.or(self.x.take())` one-liners: the old shape made adding a field a three-place edit, and forgetting the merge half produced a field that silently never survived a partial update. Two scale traps documented where they bite: reported fan speeds are 0-15 gears, not percent and not the 0-255 PWM we send (`set_fan_speed(50)` reads back as "7"); and v2 pins `sequence_id` to one value for minutes while contents change, so it orders and identifies nothing there. Two examples, and the distinction between them is the point. `live_reports` is read-only and safe against a printer mid-job. `bambu_idle_checks` writes, so it requires an explicit printer id and refuses to run unless that printer reports `gcode_state: "IDLE"`. Regression fixtures are verbatim captures off both printers, hardware identifiers scrubbed - the only way to keep this from regressing without a printer plugged in. --- README.md | 25 + examples/bambu_idle_checks.rs | 140 +++ examples/live_reports.rs | 178 ++++ src/printer/bambu_commands/print.rs | 189 +++- src/printer/bambu_mqtt.rs | 49 +- tests/fixtures/push_status_v1_p1p_idle.json | 220 +++++ .../fixtures/push_status_v2_h2c_printing.json | 846 ++++++++++++++++++ tests/push_status_parsing.rs | 88 ++ 8 files changed, 1698 insertions(+), 37 deletions(-) create mode 100644 examples/bambu_idle_checks.rs create mode 100644 examples/live_reports.rs create mode 100644 tests/fixtures/push_status_v1_p1p_idle.json create mode 100644 tests/fixtures/push_status_v2_h2c_printing.json create mode 100644 tests/push_status_parsing.rs diff --git a/README.md b/README.md index a55f287..cbe08f1 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,31 @@ cloud control plane (`continuum-backend`). (`src/uplink/`). - **go2rtc watchdog** - restarts the camera-restreaming subprocess if it dies (`src/go2rtc.rs`). +- **Bambu file upload** - implicit FTPS (port 990) into the printer's own + `cache/` directory (`src/printer/bambu_ftps.rs`, via + `BambuPrinter::upload_file`). **Confirmed live against a P1P on + 2026-10-03**: login, binary mode, and the transfer all work, byte count + matched exactly. +- **Print status** - `print.push_status` parsed into `PrintReport` with real + job progress, temperatures, fans and HMS entries. Confirmed live on both + generations (idle P1P on v1, H2C 20% into a job on v2); regression + fixtures from those captures are in `tests/fixtures/`. + +## Examples worth knowing + +- `cargo run --example live_reports [printer-id]` - **read-only**, safe + against a printer that is mid-print. Connects, reports firmware, and + watches the status stream. Redirect stderr to capture raw payloads. +- `cargo run --example bambu_idle_checks -- ` - **writes** + (FTPS upload, fan speed). Requires an explicit printer id and refuses to + run unless that printer reports `gcode_state: "IDLE"`. + +## What's still a stub (cont.) + +`start_uploaded_file` - the MQTT command that actually *starts* a print from +an uploaded file - is written but has deliberately never been fired, since +that commits a machine to hours of work. Its doc comment lists the three +details to confirm on the first real attempt. ## What's still a stub diff --git a/examples/bambu_idle_checks.rs b/examples/bambu_idle_checks.rs new file mode 100644 index 0000000..eddaf8e --- /dev/null +++ b/examples/bambu_idle_checks.rs @@ -0,0 +1,140 @@ +//! Run with: cargo run --example bambu_idle_checks -- +//! +//! **This one writes.** Unlike `live_reports`, it sends commands that change +//! the printer: an FTPS file upload and a fan-speed change. So it does two +//! things before touching anything: +//! +//! 1. Requires an explicit printer id from the command line. No "do this to +//! every printer in the fleet" mode - that's not a thing you want to +//! discover you ran. +//! 2. Refuses to continue unless that printer reports `gcode_state: "IDLE"`. +//! It connects, waits for a real `print.push_status`, and bails out if the +//! printer turns out to be mid-job. Sending a fan command to a running +//! print would corrupt it. +//! +//! What it deliberately does **not** do, even on an idle printer: +//! +//! - **Start a print.** The upload here goes through `upload_file`, which is +//! FTPS only. `upload_project_file` (upload *then* print) and +//! `start_uploaded_file` are left alone - committing a machine to hours of +//! physical work needs a human deciding to. +//! - **Calibrate.** `get_calibration` runs a real multi-minute bed-levelling +//! / vibration-compensation sequence. Available, intentionally not wired +//! in here. +//! - **Heat anything.** No temperature commands. +//! +//! The fan is set and then put back to 0 before exiting. + +use std::time::Duration; + +use continuum_proxy::fleet; +use continuum_proxy::printer::{GenericPrinter, PrinterHandle}; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(20); +/// How long to wait for the first `push_status` before giving up on knowing +/// whether the printer is idle. v2 pushes within about a second; v1 needs the +/// `pushall` this example sends first. +const STATUS_TIMEOUT: Duration = Duration::from_secs(15); + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let Some(target) = std::env::args().nth(1) else { + anyhow::bail!( + "usage: cargo run --example bambu_idle_checks -- \n\ + (the id from printers.toml - required on purpose, this example writes to the printer)" + ); + }; + + let mut printers = fleet::load(std::path::Path::new("printers.toml"))?; + let printer = printers + .iter_mut() + .find(|p| p.base().id == target) + .ok_or_else(|| anyhow::anyhow!("no printer with id '{target}' in printers.toml"))?; + + let PrinterHandle::Bambu(bambu) = printer else { + anyhow::bail!("'{target}' is not a Bambu printer - this example is Bambu-specific"); + }; + + println!("=== {} ===", bambu.display_name()); + + match tokio::time::timeout(CONNECT_TIMEOUT, bambu.connect()).await { + Ok(Ok(())) => {} + Ok(Err(err)) => anyhow::bail!("connect failed: {err}"), + Err(_) => anyhow::bail!("connect timed out - printer off or unreachable?"), + } + + // --- Safety gate: is it actually idle? --- + // v1 only sends a full object after a pushall; v2 sends one unprompted. + bambu.pushall().await.ok(); + + let deadline = tokio::time::Instant::now() + STATUS_TIMEOUT; + let state = loop { + if let Some(report) = bambu.print_status().await { + if let Some(state) = report.gcode_state.clone() { + break state; + } + } + if tokio::time::Instant::now() >= deadline { + anyhow::bail!( + "no print.push_status with a gcode_state within {STATUS_TIMEOUT:?} - refusing to \ + send anything, because there's no way to tell whether this printer is busy" + ); + } + tokio::time::sleep(Duration::from_millis(500)).await; + }; + + println!(" gcode_state: {state}"); + if state != "IDLE" { + anyhow::bail!( + "printer reports '{state}', not IDLE - refusing to send write commands to a printer \ + that may be mid-job. Use the `live_reports` example instead, it's read-only." + ); + } + println!(" idle - safe to continue\n"); + + // --- 1. FTPS upload (no print start) --- + // The real point of this example: the FTPS path in bambu_ftps.rs had + // never touched hardware. A tiny text file is enough to prove login, + // binary mode, cwd into cache/, and the transfer itself. + let file_name = "continuum-ftps-check.txt"; + let body = format!( + "written by continuum-proxy bambu_idle_checks at {:?}\n", + std::time::SystemTime::now() + ); + let expected = body.len() as u64; + + println!(" uploading {file_name} ({expected} bytes) over FTPS to cache/ ..."); + match bambu.upload_file(file_name, body.into_bytes()).await { + Ok(written) if written == expected => println!(" OK - {written} bytes, matches what we sent"), + Ok(written) => println!(" PARTIAL - printer accepted {written} of {expected} bytes"), + Err(err) => println!(" FAILED: {err}"), + } + + // --- 2. Fan speed (an MQTT gcode_line publish under the hood) --- + // Audible and visible on the machine, and harmless on an idle printer. + println!("\n setting part-cooling fan to 50% ..."); + match bambu.set_fan_speed(50).await { + Ok(()) => println!(" sent (M106 P1 S127) - listen for the fan"), + Err(err) => println!(" FAILED: {err}"), + } + tokio::time::sleep(Duration::from_secs(5)).await; + + println!(" putting the fan back to 0% ..."); + match bambu.set_fan_speed(0).await { + Ok(()) => println!(" sent"), + Err(err) => println!(" FAILED: {err}"), + } + + // Give the printer a moment to report the change back, so the fan + // reading below reflects the command rather than racing it. + tokio::time::sleep(Duration::from_secs(3)).await; + if let Some(report) = bambu.print_status().await { + println!( + "\n cooling_fan_speed now reads: {:?}", + report.cooling_fan_speed.as_deref().unwrap_or("?") + ); + } + + println!("\ndone - nothing was printed, calibrated, or heated"); + Ok(()) +} diff --git a/examples/live_reports.rs b/examples/live_reports.rs new file mode 100644 index 0000000..9e617ec --- /dev/null +++ b/examples/live_reports.rs @@ -0,0 +1,178 @@ +//! Run with: cargo run --example live_reports +//! +//! **Read-only. Safe to run against a printer that is mid-print.** +//! +//! Connects to every Bambu printer in printers.toml, asks each one what +//! firmware it's running, then watches its `print.push_status` stream for a +//! while. Everything it sends either reads state or asks the printer to +//! re-send what it already knows: +//! +//! - `get_version` - read-only. +//! - `pushall` - "re-send the full status object". A request for data, +//! not a state change. v1 only; a no-op on v2. +//! - `print_status` - reads this process's own in-memory cache, no traffic. +//! +//! It deliberately sends **no** `gcode_line`, **no** `set_fan_speed`, **no** +//! `calibration` (that one starts a physical calibration sequence and would +//! wreck a running print), no upload, and nothing that starts or stops a +//! job. Keep it that way - the whole point of this example is that it can +//! be pointed at a working printer without a second thought. Use +//! `bambu_idle_checks` for anything that writes. +//! +//! The raw payloads are the real output. `PrintReport` models only a small +//! fraction of what a real `push_status` carries (see its doc comment), so +//! this turns on the `trace`-level raw-payload log in `bambu_mqtt.rs` and +//! lets you capture the untouched JSON: +//! +//! ```sh +//! cargo run --example live_reports 2> capture.jsonl +//! ``` +//! +//! Then grow `PrintReport` and `continuum-schemas`' `push_status.schema.json` +//! from what's actually in there, rather than from guesses. + +use std::time::Duration; + +use continuum_proxy::fleet; +use continuum_proxy::printer::{GenericPrinter, PrinterHandle}; +use tracing_subscriber::EnvFilter; + +/// How long to sit and watch each printer's stream before moving on. +const WATCH_FOR: Duration = Duration::from_secs(25); +/// How often to print a one-line summary of the cached report. +const POLL_EVERY: Duration = Duration::from_secs(2); +/// A printer that's switched off shouldn't hold the whole run up - the TLS +/// handshake inside `connect` has its own 5s timeout, but the MQTT half +/// doesn't, so this bounds the lot. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(20); + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Default to trace for the raw-payload target specifically, so a plain + // run captures payloads without needing RUST_LOG set by hand. An + // explicit RUST_LOG still wins. + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| { + EnvFilter::new("info,continuum_proxy::printer::bambu_mqtt=trace") + })) + .with_writer(std::io::stderr) + .init(); + + let mut printers = fleet::load(std::path::Path::new("printers.toml"))?; + + // Optional: a printer id to look at just that one. No argument means the + // whole fleet. Read-only either way, so there's nothing to guard here. + let only: Option = std::env::args().nth(1); + + for printer in &mut printers { + if let Some(id) = &only { + if &printer.base().id != id { + continue; + } + } + let PrinterHandle::Bambu(bambu) = printer else { + println!("skipping {} - not a Bambu printer", printer.display_name()); + continue; + }; + + println!("\n=== {} ===", bambu.display_name()); + + match tokio::time::timeout(CONNECT_TIMEOUT, bambu.connect()).await { + Ok(Ok(())) => {} + Ok(Err(err)) => { + println!(" connect failed: {err}"); + continue; + } + Err(_) => { + println!(" connect timed out after {CONNECT_TIMEOUT:?} - printer off or unreachable?"); + continue; + } + } + + match bambu.get_version().await { + Ok(version) => { + println!(" get_version OK: {} modules", version.module.len()); + for module in &version.module { + println!(" {:<12} sw {:<14} hw {}", module.name, module.sw_ver, module.hw_ver); + } + } + Err(err) => println!(" get_version failed: {err}"), + } + + // v1 needs this to get a full object at all; it's a no-op on v2 (see + // `pushall`'s doc comment). Harmless either way. + if let Err(err) = bambu.pushall().await { + println!(" pushall failed: {err}"); + } + + println!(" watching print.push_status for {WATCH_FOR:?}..."); + let deadline = tokio::time::Instant::now() + WATCH_FOR; + // Keyed on the *contents*, not on `sequence_id`. v2 leaves + // `sequence_id` pinned at one value while the report genuinely + // changes underneath it (confirmed live - see `PrintStatusCache`), + // so keying on it reports one update for a whole print. + let mut last_seen: Option = None; + let mut updates = 0u32; + + while tokio::time::Instant::now() < deadline { + tokio::time::sleep(POLL_EVERY).await; + + match bambu.print_status().await { + Some(report) => { + let seq = report.envelope.sequence_id.clone(); + let fingerprint = format!( + "{:?}|{:?}|{:?}|{:?}|{:?}|{:?}", + report.gcode_state, + report.mc_percent, + report.layer_num, + report.mc_remaining_time, + report.nozzle_temper, + report.bed_temper + ); + let changed = last_seen.as_ref() != Some(&fingerprint); + if changed { + updates += 1; + last_seen = Some(fingerprint); + } + println!( + " seq={seq:<6} {:<8} {:>3}% layer {}/{} {} min left nozzle {:.1}/{:.1}C bed {:.1}/{:.1}C{}", + report.gcode_state.as_deref().unwrap_or("?"), + report.mc_percent.unwrap_or(0), + report.layer_num.unwrap_or(0), + report.total_layer_num.unwrap_or(0), + report.mc_remaining_time.unwrap_or(0), + report.nozzle_temper.unwrap_or(0.0), + report.nozzle_target_temper.unwrap_or(0.0), + report.bed_temper.unwrap_or(0.0), + report.bed_target_temper.unwrap_or(0.0), + if changed { "" } else { " (unchanged)" } + ); + if let Some(job) = report.subtask_name.as_deref().filter(|s| !s.is_empty()) { + println!(" job: {job}"); + } + if let Some(hms) = report.hms.as_ref().filter(|h| !h.is_empty()) { + println!(" hms: {} active entr(y/ies)", hms.len()); + } + println!( + " fans: cooling={} heatbreak={} big1={} big2={} wifi={}", + report.cooling_fan_speed.as_deref().unwrap_or("?"), + report.heatbreak_fan_speed.as_deref().unwrap_or("?"), + report.big_fan1_speed.as_deref().unwrap_or("?"), + report.big_fan2_speed.as_deref().unwrap_or("?"), + report.wifi_signal.as_deref().unwrap_or("?") + ); + } + // Reaching here now means genuinely nothing arrived. It used + // to also mean "something arrived and failed to parse", + // which is why a mid-print H2C once looked completely + // silent - see `handle_incoming_report`. + None => println!(" no push_status received yet"), + } + } + + println!(" {updates} distinct push_status update(s) in {WATCH_FOR:?}"); + } + + println!("\ndone - raw payloads went to stderr (redirect it to capture them)"); + Ok(()) +} diff --git a/src/printer/bambu_commands/print.rs b/src/printer/bambu_commands/print.rs index c7e559d..865de0a 100644 --- a/src/printer/bambu_commands/print.rs +++ b/src/printer/bambu_commands/print.rs @@ -32,10 +32,69 @@ pub struct PrintReport { pub tray_is_bbl_bits: Option, pub xcam: Option, pub xcam_status: Option, - // The real payload has a lot more fields than shown here (this - // example was truncated) - add them the same way, one field per line, - // `Option` for anything that isn't always present. Remember to - // also add each new field to `merge_from` below. + + // --- Job progress. The reason this struct exists at all, for a print + // --- farm: a real capture has 97 top-level fields and the nine above + // --- contained none of the ones an operator actually wants. + /// `"IDLE"`, `"RUNNING"`, `"PAUSE"`, `"FINISH"`, `"FAILED"` - observed + /// `IDLE` on an idle P1P and `RUNNING` mid-print on an H2C. Treated as a + /// string rather than an enum deliberately: an unknown state should show + /// up as itself, not fail the whole report. + pub gcode_state: Option, + /// Percent complete, 0-100. v2 also sends an identical `percent`. + pub mc_percent: Option, + /// Minutes remaining. v2 also sends an identical `remain_time`. + pub mc_remaining_time: Option, + pub layer_num: Option, + pub total_layer_num: Option, + /// Path on the printer, e.g. `/data/Metadata/plate_1.gcode`. + pub gcode_file: Option, + /// The human-facing job name, e.g. a model filename. + pub subtask_name: Option, + /// `0` when nothing is wrong. + pub print_error: Option, + + // --- Temperatures. `f32` throughout, not `i32`: v1 reports an idle + // --- target as the integer `0` while v2 reports `35.0`, and serde + // --- widens an integer into a float but not the reverse. + pub nozzle_temper: Option, + pub nozzle_target_temper: Option, + pub bed_temper: Option, + pub bed_target_temper: Option, + /// Not sent by every model - absent from the H2C capture. + pub chamber_temper: Option, + + // --- Fans and connectivity. + /// Fan speeds come back as *strings* holding a **0-15 gear**, not a + /// percentage and not the 0-255 PWM value `set_fan_speed` sends. All + /// three scales are in play at once, so converting carelessly gives a + /// number that looks plausible and is wrong: + /// + /// - we send PWM 0-255 (`M106 S`, see `percent_to_pwm`), + /// - the operator thinks in percent 0-100, + /// - the printer reports gear 0-15. + /// + /// Confirmed live on a P1P: a full-speed heatbreak fan reads `"15"`, and + /// `set_fan_speed(50)` (PWM 127) read back as `"7"`. Divide by 15 for a + /// fraction, not by 100 and not by 255. + pub cooling_fan_speed: Option, + pub heatbreak_fan_speed: Option, + pub big_fan1_speed: Option, + pub big_fan2_speed: Option, + pub wifi_signal: Option, + + /// Health-management-system entries - the printer's own error/warning + /// list, empty when healthy. Left as raw JSON for now: an H2C mid-print + /// reported several `{attr, code}` pairs whose meaning needs decoding + /// against Bambu's HMS tables before it's worth a typed shape. + pub hms: Option>, + + // There are still ~60 more top-level fields in a real v2 payload (see + // `tests/fixtures/push_status_v2_h2c_printing.json` for the full set): + // the `job`/`device`/`net`/`upload` sub-objects, queue state, the + // `2D`/`3D` blocks, and so on. Add them the same way - one field per + // line, `Option` unless it was seen on every capture from both + // generations. `merge_from` below needs no edit when you do. } impl PrintReport { @@ -45,33 +104,55 @@ impl PrintReport { /// 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`. + /// Every field below is the same one-liner: + /// `self.x = newer.x.or(self.x.take())`. `Option::or` returns the first + /// `Option` if it's `Some` and the second otherwise, so this reads as + /// "prefer the new value, fall back to what we had" - exactly the merge + /// rule, in the place a TS version would write `newer.x ?? self.x`. + /// `take()` is what makes it work for a `String`/`Vec` field too: it + /// moves the old value out and leaves `None` behind, since the value + /// can't be copied out of `self` and we're overwriting it anyway. + /// + /// This replaced two dozen four-line `if newer.x.is_some()` blocks. The + /// point isn't brevity for its own sake - it's that the old shape made + /// adding a field a three-place edit (struct, merge, and remembering to), + /// and forgetting the merge half produced a field that silently never + /// persisted across a partial update. 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; - } + + self.ams = newer.ams.or(self.ams.take()); + self.ams_exist_bits = newer.ams_exist_bits.or(self.ams_exist_bits.take()); + self.insert_flag = newer.insert_flag.or(self.insert_flag.take()); + self.power_on_flag = newer.power_on_flag.or(self.power_on_flag.take()); + self.tray_exist_bits = newer.tray_exist_bits.or(self.tray_exist_bits.take()); + self.tray_is_bbl_bits = newer.tray_is_bbl_bits.or(self.tray_is_bbl_bits.take()); + self.xcam = newer.xcam.or(self.xcam.take()); + self.xcam_status = newer.xcam_status.or(self.xcam_status.take()); + + self.gcode_state = newer.gcode_state.or(self.gcode_state.take()); + self.mc_percent = newer.mc_percent.or(self.mc_percent.take()); + self.mc_remaining_time = newer.mc_remaining_time.or(self.mc_remaining_time.take()); + self.layer_num = newer.layer_num.or(self.layer_num.take()); + self.total_layer_num = newer.total_layer_num.or(self.total_layer_num.take()); + self.gcode_file = newer.gcode_file.or(self.gcode_file.take()); + self.subtask_name = newer.subtask_name.or(self.subtask_name.take()); + self.print_error = newer.print_error.or(self.print_error.take()); + + self.nozzle_temper = newer.nozzle_temper.or(self.nozzle_temper.take()); + self.nozzle_target_temper = newer.nozzle_target_temper.or(self.nozzle_target_temper.take()); + self.bed_temper = newer.bed_temper.or(self.bed_temper.take()); + self.bed_target_temper = newer.bed_target_temper.or(self.bed_target_temper.take()); + self.chamber_temper = newer.chamber_temper.or(self.chamber_temper.take()); + + self.cooling_fan_speed = newer.cooling_fan_speed.or(self.cooling_fan_speed.take()); + self.heatbreak_fan_speed = newer.heatbreak_fan_speed.or(self.heatbreak_fan_speed.take()); + self.big_fan1_speed = newer.big_fan1_speed.or(self.big_fan1_speed.take()); + self.big_fan2_speed = newer.big_fan2_speed.or(self.big_fan2_speed.take()); + self.wifi_signal = newer.wifi_signal.or(self.wifi_signal.take()); + + self.hms = newer.hms.or(self.hms.take()); } } @@ -83,13 +164,19 @@ pub struct AmsState { pub ams: Vec, } -/// One physical AMS unit (Bambu's numbering starts at 0 via `id`). +/// One physical AMS unit (Bambu's numbering starts at 0 via `id`). Only +/// `id` and `tray` were present on every unit across both captures, so the +/// rest are optional - an H2C reports three units of different kinds +/// (`n3f`/`n3s`) and they don't all carry the same fields. #[derive(serde::Deserialize, Debug, Clone)] pub struct AmsUnit { - pub humidity: String, pub id: String, - pub temp: String, pub tray: Vec, + pub humidity: Option, + pub temp: Option, + pub humidity_raw: Option, + pub dry_time: Option, + pub info: Option, } /// A tray slot is either empty (just `id`) or loaded (the full field set) @@ -107,20 +194,47 @@ pub enum AmsTray { Loaded(LoadedTray), } +/// `deny_unknown_fields` stays - it's what stops a *loaded* tray (23 fields, +/// a superset of this one) matching `Empty` first. But an empty slot is not +/// always just `{"id": "..."}`: an H2C reports `{"id": "2", "state": 0}` on +/// an empty slot, and that two-field shape matched neither variant, which +/// failed the entire report (and so every `push_status` the printer sent +/// while an AMS slot was empty). Captured in +/// `tests/fixtures/push_status_v2_h2c_printing.json`. +/// +/// Anything genuinely new here will fail loudly rather than be quietly +/// dropped, which is the point of keeping `deny_unknown_fields`: better a +/// visible error naming the field than a report that silently vanishes. #[derive(serde::Deserialize, Debug, Clone)] #[serde(deny_unknown_fields)] pub struct EmptyTray { pub id: String, + /// v2 sends this on an empty slot; v1 omits it entirely. + pub state: Option, } +/// Captured from both generations (see `tests/push_status_parsing.rs`), and +/// they do **not** agree. A v2 tray carries `drying_temp`/`drying_time`; a +/// v1 tray carries `tray_temp`/`tray_time` and neither `drying_*` field. +/// Every one of these was `String` (required) before, which meant every +/// single v1 push carrying AMS data failed to deserialize - silently, so it +/// looked like the printer simply wasn't sending anything. All four are +/// `Option` now, and so is anything else only one generation was observed +/// to send. #[derive(serde::Deserialize, Debug, Clone)] pub struct LoadedTray { pub id: String, pub bed_temp: String, pub bed_temp_type: String, pub cols: Vec, - pub drying_temp: String, - pub drying_time: String, + /// v2 only. + pub drying_temp: Option, + /// v2 only. + pub drying_time: Option, + /// v1 only - the same idea as `drying_temp` under a different name. + pub tray_temp: Option, + /// v1 only - the same idea as `drying_time` under a different name. + pub tray_time: Option, pub nozzle_temp_max: String, pub nozzle_temp_min: String, pub remain: i32, @@ -134,6 +248,15 @@ pub struct LoadedTray { pub tray_uuid: String, pub tray_weight: String, pub xcam_info: String, + /// Seen on both, but not in the original truncated example this struct + /// was built from. + pub state: Option, + pub cali_idx: Option, + pub ctype: Option, + pub total_len: Option, + /// Flow-calibration coefficients - v1 sends these on a loaded tray. + pub k: Option, + pub n: Option, } /// Camera-based AI print monitoring settings (spaghetti detection, diff --git a/src/printer/bambu_mqtt.rs b/src/printer/bambu_mqtt.rs index 30b16f3..d6f2e3d 100644 --- a/src/printer/bambu_mqtt.rs +++ b/src/printer/bambu_mqtt.rs @@ -121,6 +121,15 @@ enum BambuMqttClient { /// 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 @@ -147,6 +156,12 @@ impl PrintStatusCache { // 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) { if incoming_seq != last.wrapping_add(1) { eprintln!( @@ -388,7 +403,7 @@ async fn poll_loop_v1( loop { match event_loop.poll().await { Ok(Event::Incoming(Packet::Publish(publish))) => { - handle_incoming_report(&pending, &print_status, version, &publish.payload).await; + handle_incoming_report(&sn, &pending, &print_status, version, &publish.payload).await; } Ok(_) => {} Err(err) => { @@ -409,7 +424,7 @@ async fn poll_loop_v2( loop { match event_loop.poll().await { Ok(rumqttc::v5::Event::Incoming(PacketV5::Publish(publish))) => { - handle_incoming_report(&pending, &print_status, version, &publish.payload).await; + handle_incoming_report(&sn, &pending, &print_status, version, &publish.payload).await; } Ok(_) => {} Err(err) => { @@ -444,11 +459,21 @@ async fn poll_loop_v2( /// (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>, 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::(payload) else { return }; let Some(object) = value.as_object() else { return }; let Some((category, body)) = object.iter().next() else { return }; @@ -459,8 +484,24 @@ async fn handle_incoming_report( }; if category == "print" && body.get("command").and_then(Value::as_str) == Some("push_status") { - if let Ok(report) = serde_json::from_value::(body.clone()) { - print_status.lock().await.apply(report, version); + match serde_json::from_value::(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("?") + ), } } diff --git a/tests/fixtures/push_status_v1_p1p_idle.json b/tests/fixtures/push_status_v1_p1p_idle.json new file mode 100644 index 0000000..bd28f6d --- /dev/null +++ b/tests/fixtures/push_status_v1_p1p_idle.json @@ -0,0 +1,220 @@ +{ + "upgrade_state": { + "sequence_id": 0, + "progress": "", + "status": "IDLE", + "consistency_request": false, + "dis_state": 0, + "err_code": 0, + "force_upgrade": false, + "message": "0%, 0B/s", + "module": "", + "new_version_state": 0, + "cur_state_code": 0, + "idx2": 4275297388, + "new_ver_list": [] + }, + "ipcam": { + "ipcam_dev": "1", + "ipcam_record": "enable", + "timelapse": "disable", + "resolution": "", + "tutk_server": "disable", + "mode_bits": 3 + }, + "upload": { + "status": "idle", + "progress": 0, + "message": "" + }, + "net": { + "conf": 0, + "info": [ + { + "ip": 0, + "mask": 0 + } + ] + }, + "nozzle_temper": 22.125, + "nozzle_target_temper": 0, + "bed_temper": 21.34375, + "bed_target_temper": 0, + "chamber_temper": 5, + "mc_print_stage": "1", + "heatbreak_fan_speed": "15", + "cooling_fan_speed": "0", + "big_fan1_speed": "0", + "big_fan2_speed": "0", + "mc_percent": 0, + "mc_remaining_time": 0, + "ams_status": 0, + "ams_rfid_status": 0, + "hw_switch_state": 0, + "spd_mag": 100, + "spd_lvl": 2, + "print_error": 0, + "lifecycle": "product", + "wifi_signal": "-64dBm", + "gcode_state": "IDLE", + "gcode_file_prepare_percent": "0", + "queue_number": 0, + "queue_total": 0, + "queue_est": 0, + "queue_sts": 0, + "project_id": "0", + "profile_id": "0", + "task_id": "0", + "subtask_id": "0", + "subtask_name": "", + "gcode_file": "", + "stg": [], + "stg_cur": 0, + "print_type": "idle", + "home_flag": 158549400, + "mc_print_line_number": "0", + "mc_print_sub_stage": 0, + "sdcard": true, + "force_upgrade": false, + "mess_production_state": "active", + "layer_num": 0, + "total_layer_num": 0, + "s_obj": [], + "filam_bak": [], + "fan_gear": 0, + "nozzle_diameter": "0.4", + "nozzle_type": "stainless_steel", + "cali_version": 0, + "k": "0.0000", + "flag3": 655, + "hms": [], + "online": { + "ahb": false, + "rfid": false, + "version": 1849554829 + }, + "ams": { + "ams": [ + { + "chip_id": "", + "ams_id": "", + "check": 1, + "id": "0", + "humidity": "2", + "humidity_raw": "41", + "temp": "23.0", + "dry_time": 0, + "info": "1001", + "tray": [ + { + "id": "0" + }, + { + "id": "1" + }, + { + "id": "2", + "state": 3, + "remain": -1, + "k": 0.03999999910593033, + "n": 1, + "cali_idx": -1, + "total_len": 330000, + "tag_uid": "", + "tray_id_name": "", + "tray_info_idx": "GFG99", + "tray_type": "PETG", + "tray_sub_brands": "", + "tray_color": "161616FF", + "tray_weight": "0", + "tray_diameter": "0.00", + "tray_temp": "0", + "tray_time": "0", + "bed_temp_type": "0", + "bed_temp": "0", + "nozzle_temp_max": "270", + "nozzle_temp_min": "220", + "xcam_info": "000000000000000000000000", + "tray_uuid": "", + "ctype": 0, + "cols": [ + "161616FF" + ] + }, + { + "id": "3", + "state": 3, + "remain": -1, + "k": 0.03999999910593033, + "n": 1, + "cali_idx": -1, + "total_len": 330000, + "tag_uid": "", + "tray_id_name": "", + "tray_info_idx": "GFG99", + "tray_type": "PETG", + "tray_sub_brands": "", + "tray_color": "2850E0FF", + "tray_weight": "0", + "tray_diameter": "0.00", + "tray_temp": "0", + "tray_time": "0", + "bed_temp_type": "0", + "bed_temp": "0", + "nozzle_temp_max": "270", + "nozzle_temp_min": "220", + "xcam_info": "000000000000000000000000", + "tray_uuid": "", + "ctype": 0, + "cols": [ + "2850E0FF" + ] + } + ] + } + ], + "ams_exist_bits": "1", + "tray_exist_bits": "c", + "tray_is_bbl_bits": "c", + "tray_tar": "255", + "tray_now": "255", + "tray_pre": "255", + "tray_read_done_bits": "c", + "tray_reading_bits": "0", + "version": 2, + "insert_flag": false, + "power_on_flag": false + }, + "vt_tray": { + "id": "254", + "tag_uid": "", + "tray_id_name": "", + "tray_info_idx": "P9599a25", + "tray_type": "PETG", + "tray_sub_brands": "", + "tray_color": "D3C5A3FF", + "tray_weight": "0", + "tray_diameter": "0.00", + "tray_temp": "0", + "tray_time": "0", + "bed_temp_type": "0", + "bed_temp": "0", + "nozzle_temp_max": "240", + "nozzle_temp_min": "225", + "xcam_info": "000000000000000000000000", + "tray_uuid": "", + "remain": 0, + "k": 0.019999999552965164, + "n": 1, + "cali_idx": -1 + }, + "lights_report": [ + { + "node": "chamber_light", + "mode": "off" + } + ], + "command": "push_status", + "msg": 0, + "sequence_id": "53" +} \ No newline at end of file diff --git a/tests/fixtures/push_status_v2_h2c_printing.json b/tests/fixtures/push_status_v2_h2c_printing.json new file mode 100644 index 0000000..00cba2c --- /dev/null +++ b/tests/fixtures/push_status_v2_h2c_printing.json @@ -0,0 +1,846 @@ +{ + "2D": { + "bs": { + "bi": [ + { + "clock_in": false, + "est_time": 0, + "idx": 0, + "print_then": false, + "proc_list": [], + "step_type": 1, + "tool_info": { + "color": "", + "diameter": 0.4000000059604645, + "id": 8978432 + }, + "type": 137 + } + ], + "total_time": 0 + }, + "cond": 14, + "cur_stage": { + "clock_in_time": 0, + "idx": 0, + "left_time": 0, + "process": 0, + "state": 0 + }, + "first_confirm": false, + "makeable": false, + "material": { + "cur_id_list": [], + "state": 0, + "tar_id": "", + "tar_name": "" + }, + "pm": 0 + }, + "3D": { + "layer_num": 2, + "print_cali_option": 2, + "total_layer_num": 50 + }, + "ams": { + "ams": [ + { + "dry_time": 0, + "humidity": "2", + "humidity_raw": "34", + "id": "0", + "info": "1003", + "temp": "30.6", + "tray": [ + { + "bed_temp": "0", + "bed_temp_type": "0", + "cali_idx": -1, + "cols": [ + "2850E0FF" + ], + "ctype": 0, + "drying_temp": "0", + "drying_time": "0", + "id": "0", + "nozzle_temp_max": "280", + "nozzle_temp_min": "240", + "remain": 0, + "state": 11, + "tag_uid": "", + "total_len": 330000, + "tray_color": "2850E0FF", + "tray_diameter": "1.75", + "tray_id_name": "", + "tray_info_idx": "GFN99", + "tray_sub_brands": "", + "tray_type": "PA", + "tray_uuid": "", + "tray_weight": "0", + "xcam_info": "000000000000000000000000" + }, + { + "bed_temp": "0", + "bed_temp_type": "0", + "cali_idx": -1, + "cols": [ + "7C4B00FF" + ], + "ctype": 0, + "drying_temp": "0", + "drying_time": "0", + "id": "1", + "nozzle_temp_max": "300", + "nozzle_temp_min": "260", + "remain": 0, + "state": 11, + "tag_uid": "", + "total_len": 330000, + "tray_color": "7C4B00FF", + "tray_diameter": "1.75", + "tray_id_name": "", + "tray_info_idx": "GFN08", + "tray_sub_brands": "", + "tray_type": "PA-GF", + "tray_uuid": "", + "tray_weight": "0", + "xcam_info": "000000000000000000000000" + }, + { + "bed_temp": "0", + "bed_temp_type": "0", + "cali_idx": -1, + "cols": [ + "898989FF" + ], + "ctype": 0, + "drying_temp": "0", + "drying_time": "0", + "id": "2", + "nozzle_temp_max": "290", + "nozzle_temp_min": "260", + "remain": 0, + "state": 11, + "tag_uid": "", + "total_len": 330000, + "tray_color": "898989FF", + "tray_diameter": "1.75", + "tray_id_name": "", + "tray_info_idx": "GFC99", + "tray_sub_brands": "", + "tray_type": "PC", + "tray_uuid": "", + "tray_weight": "0", + "xcam_info": "000000000000000000000000" + }, + { + "bed_temp": "0", + "bed_temp_type": "0", + "cali_idx": -1, + "cols": [ + "161616FF" + ], + "ctype": 0, + "drying_temp": "0", + "drying_time": "0", + "id": "3", + "nozzle_temp_max": "290", + "nozzle_temp_min": "260", + "remain": 0, + "state": 11, + "tag_uid": "", + "total_len": 330000, + "tray_color": "161616FF", + "tray_diameter": "1.75", + "tray_id_name": "", + "tray_info_idx": "GFC99", + "tray_sub_brands": "", + "tray_type": "PC", + "tray_uuid": "", + "tray_weight": "0", + "xcam_info": "000000000000000000000000" + } + ] + }, + { + "dry_time": 0, + "humidity": "1", + "humidity_raw": "47", + "id": "1", + "info": "1003", + "temp": "27.1", + "tray": [ + { + "bed_temp": "0", + "bed_temp_type": "0", + "cali_idx": -1, + "cols": [ + "FFFFFF00" + ], + "ctype": 0, + "drying_temp": "0", + "drying_time": "0", + "id": "0", + "nozzle_temp_max": "280", + "nozzle_temp_min": "240", + "remain": 0, + "state": 11, + "tag_uid": "", + "total_len": 330000, + "tray_color": "FFFFFF00", + "tray_diameter": "1.75", + "tray_id_name": "", + "tray_info_idx": "GFB00", + "tray_sub_brands": "", + "tray_type": "ABS", + "tray_uuid": "", + "tray_weight": "0", + "xcam_info": "000000000000000000000000" + }, + { + "bed_temp": "0", + "bed_temp_type": "0", + "cali_idx": -1, + "cols": [ + "FFF144FF" + ], + "ctype": 0, + "drying_temp": "0", + "drying_time": "0", + "id": "1", + "nozzle_temp_max": "270", + "nozzle_temp_min": "220", + "remain": 0, + "state": 11, + "tag_uid": "", + "total_len": 330000, + "tray_color": "FFF144FF", + "tray_diameter": "1.75", + "tray_id_name": "", + "tray_info_idx": "GFG99", + "tray_sub_brands": "", + "tray_type": "PETG", + "tray_uuid": "", + "tray_weight": "0", + "xcam_info": "000000000000000000000000" + }, + { + "id": "2", + "state": 10 + }, + { + "bed_temp": "0", + "bed_temp_type": "0", + "cali_idx": -1, + "cols": [ + "898989FF" + ], + "ctype": 0, + "drying_temp": "0", + "drying_time": "0", + "id": "3", + "nozzle_temp_max": "240", + "nozzle_temp_min": "190", + "remain": 0, + "state": 11, + "tag_uid": "", + "total_len": 330000, + "tray_color": "898989FF", + "tray_diameter": "1.75", + "tray_id_name": "", + "tray_info_idx": "GFL95", + "tray_sub_brands": "", + "tray_type": "PLA", + "tray_uuid": "", + "tray_weight": "0", + "xcam_info": "000000000000000000000000" + } + ] + }, + { + "dry_time": 0, + "humidity": "0", + "humidity_raw": "16", + "id": "128", + "info": "2104", + "temp": "41.5", + "tray": [ + { + "id": "0", + "state": 10 + } + ] + } + ], + "ams_exist_bits": "13", + "ams_exist_bits_raw": "13", + "cali_id": 255, + "cali_stat": 0, + "insert_flag": false, + "power_on_flag": false, + "tray_exist_bits": "bf", + "tray_is_bbl_bits": "bf", + "tray_now": "0", + "tray_pre": "0", + "tray_read_done_bits": "bf", + "tray_reading_bits": "0", + "tray_tar": "0", + "unbind_ams_stat": 0, + "version": 169763 + }, + "ams_rfid_status": 0, + "ams_status": 768, + "ap_err": 0, + "aux": "2001004", + "aux_part_fan": false, + "batch_id": 0, + "bed_target_temper": 35.0, + "bed_temper": 35.0, + "big_fan1_speed": "14", + "big_fan2_speed": "4", + "cali_version": 0, + "canvas_id": 0, + "care": [ + { + "id": "ls", + "info": "1A00" + }, + { + "id": "ld", + "info": "641864" + }, + { + "id": "lr", + "info": "1A00" + }, + { + "id": "hr", + "info": "5E185E" + } + ], + "cfg": "685DDAB8", + "command": "push_status", + "cooling_fan_speed": "14", + "design_id": "", + "device": { + "airduct": { + "modeCur": 0, + "modeFunc": 0, + "modeList": [ + { + "ctrl": [ + 16, + 32, + 48 + ], + "modeId": 0, + "off": [ + 96 + ] + }, + { + "ctrl": [ + 16 + ], + "modeId": 1, + "off": [ + 32, + 48 + ] + }, + { + "ctrl": [ + 48 + ], + "modeId": 2, + "off": [] + } + ], + "modeVisable": 7, + "parts": [ + { + "func": 0, + "id": 16, + "range": 6553600, + "state": 100, + "tar_state": 100 + }, + { + "func": 1, + "id": 32, + "range": 6553600, + "state": 100, + "tar_state": 100 + }, + { + "func": 2, + "id": 48, + "range": 6553600, + "state": 30, + "tar_state": 30 + }, + { + "func": 4, + "id": 96, + "range": 6553600, + "state": 0, + "tar_state": 0 + } + ], + "subFunc": 0, + "subMode": 0, + "subVisable": 7, + "version": 1 + }, + "bed": { + "info": { + "temp": 2293795 + }, + "state": 2 + }, + "bed_temp": 2293795, + "cam": { + "laser": { + "cond": 252, + "state": 0 + }, + "timelapse_path": "" + }, + "ctc": { + "info": { + "temp": 27 + }, + "state": 0 + }, + "ext_tool": { + "calib": 2, + "low_prec": true, + "mount": 0, + "mount_3d": 1, + "th_temp": 0, + "type": "F000" + }, + "extruder": { + "info": [ + { + "filam_bak": [], + "hnow": 0, + "hpre": 0, + "htar": 0, + "id": 0, + "info": 1611, + "snow": 65280, + "spre": 65280, + "star": 65280, + "stat": 197376, + "temp": 15073510 + }, + { + "filam_bak": [], + "hnow": 1, + "hpre": 1, + "htar": 1, + "id": 1, + "info": 1048, + "snow": 65279, + "spre": 65279, + "star": 65279, + "stat": 0, + "temp": 34 + } + ], + "state": 2 + }, + "fan": 419300, + "fire_ext": { + "bot_err": 0, + "cd": 0, + "cd_init": 15, + "connect_flag": 0, + "drill_flag": 0, + "history": [], + "remain_time": 0, + "rm_init": 33, + "state": 0 + }, + "holder": { + "info": 1, + "job": 0, + "pos": 3, + "stat": 0, + "step": [ + 0 + ] + }, + "laser": { + "power": 0 + }, + "nozzle": { + "exist": 3604483, + "info": [ + { + "color_m": "00000000", + "diameter": 0.4, + "fila_id": "GFB99", + "id": 18, + "sn": "20D06A5A1512964", + "stat": 0, + "tm": 350, + "type": "HS01", + "wear": 0.0 + }, + { + "color_m": "00000000", + "diameter": 0.4, + "fila_id": "", + "id": 1, + "sn": "N/A", + "stat": 0, + "tm": 0, + "type": "HS01", + "wear": 0.0 + }, + { + "color_m": "F95D73FF", + "diameter": 0.4, + "fila_id": "Pf3dbd0c", + "id": 0, + "sn": "20D06A5A1507129", + "stat": 0, + "tm": 350, + "type": "HS01", + "wear": 0.0 + }, + { + "color_m": "7C4B00FF", + "diameter": 0.4, + "fila_id": "GFN08", + "id": 16, + "sn": "20D06A5A1507166", + "stat": 0, + "tm": 350, + "type": "HS01", + "wear": 0.0 + }, + { + "color_m": "FFF144FF", + "diameter": 0.6, + "fila_id": "GFG99", + "id": 21, + "sn": "20D06A592727617", + "stat": 0, + "tm": 350, + "type": "HS01", + "wear": 0.0 + }, + { + "color_m": "2850E0FF", + "diameter": 0.4, + "fila_id": "GFN99", + "id": 17, + "sn": "20D06A5A1215962", + "stat": 0, + "tm": 350, + "type": "HS01", + "wear": 0.0 + }, + { + "color_m": "161616FF", + "diameter": 0.4, + "fila_id": "GFC99", + "id": 20, + "sn": "20D06A592725573", + "stat": 0, + "tm": 350, + "type": "HS01", + "wear": 0.0 + } + ], + "src_id": 19, + "state": 0, + "tar_id": 19 + }, + "plate": { + "base": 6, + "cali2d_id": "", + "cur_id": "P0102", + "mat": 1, + "tar_id": "" + }, + "type": 1 + }, + "err": "0", + "fail_reason": "0", + "fan_gear": 5046271, + "file": "/data/Metadata/plate_1.gcode", + "force_upgrade": false, + "fun": "10023FF18FFF9CB7", + "fun2": "E", + "gcode_file": "/data/Metadata/plate_1.gcode", + "gcode_file_prepare_percent": "100", + "gcode_state": "RUNNING", + "heatbreak_fan_speed": "14", + "hms": [ + { + "attr": 83952640, + "code": 196610 + }, + { + "attr": 83952640, + "code": 196612 + }, + { + "attr": 83887616, + "code": 131156 + } + ], + "home_flag": -1066967745, + "hw_switch_state": 1, + "info": { + "temp": 27 + }, + "ipcam": { + "agora_service": "disable", + "brtc_service": "enable", + "bs_state": 0, + "cap_pic_enable": "invalid", + "ipcam_dev": "1", + "ipcam_record": "enable", + "laser_preview_res": 6, + "mode_bits": 2, + "resolution": "1080p", + "rtsp_url": "rtsps://192.168.0.21:322/streaming/live/1", + "timelapse": "enable", + "tl_store_hpd_type": 2, + "tl_store_path_type": 2, + "tutk_server": "disable" + }, + "job": { + "cur_stage": { + "idx": 0, + "state": 2 + }, + "stage": [ + { + "clock_in": false, + "color": [ + "", + "" + ], + "diameter": [ + 0.4000000059604645, + 0.4000000059604645 + ], + "est_time": 0, + "heigh": 0.0, + "idx": 0, + "platform": "", + "print_then": false, + "proc_list": [], + "tool": [ + "HS01", + "HS01" + ], + "type": 2 + } + ] + }, + "job_attr": 18, + "job_id": "0", + "lan_task_id": "0", + "layer_num": 2, + "lights_report": [ + { + "mode": "on", + "node": "chamber_light" + }, + { + "mode": "flashing", + "node": "work_light" + }, + { + "mode": "on", + "node": "chamber_light2" + } + ], + "mapping": [ + 65280 + ], + "mc_action": 0, + "mc_err": 0, + "mc_percent": 13, + "mc_print_error_code": "0", + "mc_print_stage": "2", + "mc_print_sub_stage": 0, + "mc_remaining_time": 102, + "mc_stage": 2, + "model_id": "", + "msg": 0, + "net": { + "conf": 16, + "info": [ + { + "ip": 352364736, + "mask": 16777215 + }, + { + "ip": 0, + "mask": 0 + } + ] + }, + "nozzle_diameter": "0.4", + "nozzle_target_temper": 230.0, + "nozzle_temper": 230.0, + "nozzle_type": "HS01-0.4", + "online": { + "ahb": true, + "ext": true, + "version": 16 + }, + "percent": 13, + "plate_cnt": 1, + "plate_id": 1, + "plate_idx": 1, + "prepare_per": 100, + "print_error": 0, + "print_gcode_action": 0, + "print_real_action": 0, + "print_type": "local", + "profile_id": "0", + "project_id": "0", + "queue": 0, + "queue_est": 0, + "queue_number": 0, + "queue_sts": 0, + "queue_total": 0, + "remain_time": 102, + "s_obj": [], + "sdcard": true, + "sequence_id": "2021", + "spd_lvl": 2, + "spd_mag": 100, + "stat": "16782180F0", + "state": 4, + "stg": [ + 64, + 29, + 13, + 74, + 72, + 4, + 54, + 39, + 8, + 14, + 1, + 3 + ], + "stg_cur": 0, + "subtask_id": "0", + "subtask_name": "świeczka-24.stl", + "task_id": "5101", + "total_layer_num": 50, + "upgrade_state": { + "ahb_new_version_number": "", + "ams_new_version_number": "", + "consistency_request": false, + "dis_state": 0, + "err_code": 0, + "ext_new_version_number": "", + "force_upgrade": false, + "idx": 16, + "idx2": 1393374092, + "lower_limit": "00.00.00.00", + "message": "", + "module": "", + "new_version_state": 0, + "ota_new_version_number": "", + "progress": "0", + "sequence_id": 0, + "sn": "31B8BP5A1800585", + "status": "IDLE", + "upgrade_fail_list": [], + "upgrade_type": "unknown" + }, + "upload": { + "file_size": 0, + "finish_size": 0, + "message": "Good", + "oss_url": "", + "progress": 0, + "sequence_id": "0903", + "speed": 0, + "status": "idle", + "task_id": "", + "time_remaining": 0, + "trouble_id": "" + }, + "ver": "20000", + "vir_slot": [ + { + "bed_temp": "0", + "bed_temp_type": "0", + "cali_idx": -1, + "cols": [ + "0ACC38FF" + ], + "ctype": 0, + "drying_temp": "0", + "drying_time": "0", + "id": "254", + "nozzle_temp_max": "250", + "nozzle_temp_min": "200", + "remain": 0, + "tag_uid": "", + "total_len": 330000, + "tray_color": "0ACC38FF", + "tray_diameter": "1.75", + "tray_id_name": "", + "tray_info_idx": "GFU98", + "tray_sub_brands": "", + "tray_type": "TPU-AMS", + "tray_uuid": "", + "tray_weight": "0", + "xcam_info": "000000000000000000000000" + }, + { + "bed_temp": "0", + "bed_temp_type": "0", + "cali_idx": -1, + "cols": [ + "F95D73FF" + ], + "ctype": 0, + "drying_temp": "0", + "drying_time": "0", + "id": "255", + "nozzle_temp_max": "240", + "nozzle_temp_min": "230", + "remain": 0, + "tag_uid": "", + "total_len": 330000, + "tray_color": "F95D73FF", + "tray_diameter": "1.75", + "tray_id_name": "", + "tray_info_idx": "Pf3dbd0c", + "tray_sub_brands": "", + "tray_type": "TPU", + "tray_uuid": "", + "tray_weight": "0", + "xcam_info": "000000000000000000000000" + } + ], + "wifi_signal": "-66dBm", + "xcam": { + "allow_skip_parts": false, + "buildplate_marker_detector": true, + "cfg": 1797559, + "first_layer_inspector": true, + "halt_print_sensitivity": "medium", + "print_halt": true, + "printing_monitor": true, + "spaghetti_detector": true + }, + "xcam_status": "0" +} \ No newline at end of file diff --git a/tests/push_status_parsing.rs b/tests/push_status_parsing.rs new file mode 100644 index 0000000..e49dca6 --- /dev/null +++ b/tests/push_status_parsing.rs @@ -0,0 +1,88 @@ +//! Does `PrintReport` actually parse what real printers really send? +//! +//! The fixtures in `tests/fixtures/` are verbatim `print.push_status` +//! payloads captured off real hardware on 2026-10-03 via +//! `cargo run --example live_reports` (hardware identifiers scrubbed): +//! +//! - `push_status_v1_p1p_idle.json` - a P1P on BambuMQTT v1, idle. +//! - `push_status_v2_h2c_printing.json` - an H2C on BambuMQTT v2, 13% into +//! a real job. +//! +//! These exist because the struct was originally built from a *truncated* +//! example payload, and the gap between that and reality turned out to be +//! the cause of two live misbehaviours (silently empty `print_status()`, and +//! bogus "sequence gap" warnings). A fixture is the only way to keep that +//! from regressing without a printer plugged in. + +use continuum_proxy::printer::PrintReport; + +fn load(name: &str) -> serde_json::Value { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/"); + let text = std::fs::read_to_string(format!("{path}{name}")).expect("fixture should exist"); + serde_json::from_str(&text).expect("fixture should be valid JSON") +} + +/// The failure mode that mattered: `handle_incoming_report` does +/// `if let Ok(report) = serde_json::from_value::(..)`, so a +/// parse error doesn't just lose that one update - it's invisible. Assert on +/// the error text, not just `is_ok()`, so a regression says *what* broke. +fn assert_parses(name: &str) { + let value = load(name); + match serde_json::from_value::(value) { + Ok(_) => {} + Err(err) => panic!("{name} failed to parse as PrintReport: {err}"), + } +} + +#[test] +fn parses_v1_idle_push_status() { + assert_parses("push_status_v1_p1p_idle.json"); +} + +#[test] +fn parses_v2_printing_push_status() { + assert_parses("push_status_v2_h2c_printing.json"); +} + +/// The whole point of consuming these at all: a print farm needs progress +/// and temperatures. If the struct parses but drops these, it's no more +/// useful than not parsing. +#[test] +fn v2_printing_report_carries_progress_and_temperatures() { + let value = load("push_status_v2_h2c_printing.json"); + let report: PrintReport = serde_json::from_value(value).expect("should parse"); + + assert_eq!(report.gcode_state.as_deref(), Some("RUNNING")); + assert_eq!(report.mc_percent, Some(13)); + assert_eq!(report.layer_num, Some(2)); + assert_eq!(report.total_layer_num, Some(50)); + assert_eq!(report.mc_remaining_time, Some(102)); + assert_eq!(report.nozzle_temper, Some(230.0)); + assert_eq!(report.subtask_name.as_deref(), Some("\u{15b}wieczka-24.stl")); +} + +/// An idle printer still has to produce a coherent report - just with +/// nothing printing. +#[test] +fn v1_idle_report_reads_as_idle() { + let value = load("push_status_v1_p1p_idle.json"); + let report: PrintReport = serde_json::from_value(value).expect("should parse"); + + assert_eq!(report.gcode_state.as_deref(), Some("IDLE")); + assert_eq!(report.mc_percent, Some(0)); +} + +/// v1 and v2 disagree about tray field names (`tray_temp`/`tray_time` vs +/// `drying_temp`/`drying_time`) - this is the specific mismatch that made +/// every v1 push carrying AMS data fail. Both spellings have to work. +#[test] +fn both_protocol_generations_parse_their_ams_trays() { + for name in ["push_status_v1_p1p_idle.json", "push_status_v2_h2c_printing.json"] { + let value = load(name); + let report: PrintReport = serde_json::from_value(value).unwrap_or_else(|err| { + panic!("{name}: {err}"); + }); + let ams = report.ams.expect("fixture should carry AMS state"); + assert!(!ams.ams.is_empty(), "{name}: expected at least one AMS unit"); + } +}