//! The ongoing print-state push (root key "print"). The one real capture //! this struct was originally built from didn't show `sequence_id`/ //! `command` - turned out to be because that capture was itself a //! truncated example (it's also missing dozens of other fields the real //! 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, Clone)] 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, pub ams: Option, pub ams_exist_bits: Option, pub insert_flag: Option, pub power_on_flag: Option, pub tray_exist_bits: Option, pub tray_is_bbl_bits: Option, pub xcam: Option, pub xcam_status: Option, // --- 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>, // --- Stage. Without these a print that is busy preparing is // --- indistinguishable from one that has stalled. /// The stage the printer is in right now; `0` is "none in particular" /// (idle, or actively printing). Confirmed live on a P1P mid-print: it /// walked `54 -> 14 -> 13 -> 0` while `layer_num` stayed `0` and /// `mc_percent` climbed to 14%. That is the only signal separating "doing /// bed levelling and the like before the first layer" from "stuck at /// layer 0", so a stall detector built on `layer_num` alone would raise /// a false alarm on every healthy print start. Deliberately a bare /// number: the code-to-meaning table is Bambu's, and an unknown code /// should still show up as itself. pub stg_cur: Option, /// The stages planned for this print, in order - `[54, 14, 13]` on the /// live P1P above, `[]` on an idle one, twelve entries on the H2C /// fixture. `stg_cur` walks through these. pub stg: Option>, /// The external spool ("virtual tray"). **Observed on v1 only:** the P1P /// fixture carries it, the H2C one has no `vt_tray` key at all. That is a /// genuine generation divergence, not a gap in the capture, so this must /// stay `Option` - the `tray_temp`/`drying_temp` split on `LoadedTray` /// is the precedent, and making that one required broke every report from /// the other generation. pub vt_tray: 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 { /// 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`. /// 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; 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()); self.stg_cur = newer.stg_cur.or(self.stg_cur.take()); self.stg = newer.stg.or(self.stg.take()); self.vt_tray = newer.vt_tray.or(self.vt_tray.take()); } } /// 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 /// actually sends. #[derive(serde::Deserialize, Debug, Clone)] pub struct AmsState { pub ams: Vec, } /// 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 id: 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) - /// two genuinely different shapes, not one shape with optional fields, so /// this is an untagged enum rather than a struct full of `Option`. /// `#[serde(deny_unknown_fields)]` on `Empty` is load-bearing: untagged /// enums try variants in order, and without it a *loaded* tray (which has /// every field `Empty` allows, plus more) would incorrectly match `Empty` /// first, since a plain struct doesn't reject extra fields by default. /// Verified this the hard way - see git history. #[derive(serde::Deserialize, Debug, Clone)] #[serde(untagged)] pub enum AmsTray { Empty(EmptyTray), 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, /// 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, pub tag_uid: String, pub tray_color: String, pub tray_diameter: String, pub tray_id_name: String, pub tray_info_idx: String, pub tray_sub_brands: String, pub tray_type: String, 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, } /// The external spool. Kept separate from `LoadedTray` rather than shared: /// the shapes differ (no `cols`/`state`, but `k`/`n`/`cali_idx` are always /// there), and bending `LoadedTray` to fit would loosen its required fields /// for AMS trays, which have been seen on both generations. Only `id` is /// required; the rest come from a single P1P capture, and any of them being /// required would fail the whole report the first time another model or /// firmware omits one - the same silent failure `LoadedTray` already hit. /// Not `deny_unknown_fields`: a new key here should be ignored, not take /// every `push_status` down with it. #[derive(serde::Deserialize, Debug, Clone)] pub struct VtTray { /// `"254"` on the P1P - Bambu's fixed id for the external spool. pub id: String, pub bed_temp: Option, pub bed_temp_type: Option, pub nozzle_temp_max: Option, pub nozzle_temp_min: Option, pub remain: Option, pub tag_uid: Option, pub tray_color: Option, pub tray_diameter: Option, pub tray_id_name: Option, pub tray_info_idx: Option, pub tray_sub_brands: Option, pub tray_type: Option, pub tray_uuid: Option, pub tray_weight: Option, pub xcam_info: Option, /// v1 spelling; v2 AMS trays call these `drying_temp`/`drying_time`, /// and it is unknown what a v2 external spool would send. pub tray_temp: Option, pub tray_time: Option, /// Flow-calibration profile index (`-1` when none) and coefficients. pub cali_idx: Option, pub k: Option, pub n: Option, } /// Camera-based AI print monitoring settings (spaghetti detection, /// first-layer inspection, etc.). #[derive(serde::Deserialize, Debug, Clone)] pub struct XcamSettings { pub allow_skip_parts: bool, pub buildplate_marker_detector: bool, pub first_layer_inspector: bool, pub halt_print_sensitivity: String, pub print_halt: bool, pub printing_monitor: bool, pub spaghetti_detector: bool, }