Down from 29 warnings to 2, in the spirit of 4ab95ff.
The doc warnings were all one mistake made repeatedly: a paragraph wrapped so
that a line *began* with `- ` or `+ `, which rustdoc reads as a markdown list
marker and the following lines as unindented continuations. The prose always
meant an em-dash. Fixed by moving the dash to the end of the previous line and
re-wrapping; not one word changed, which is the point - these comments record
things confirmed against real printers and are worth more than the warning.
The genuine lists just had their continuations de-indented and stayed lists.
The code ones are `match x { V(v) => .., _ => {} }` to `if let`, and nested
`if let Some(x) = .. { if cond { .. } }` to a let-chain. None had an `else`,
so each is the same control flow written once instead of twice.
Two warnings are left deliberately, because fixing them is not cosmetic:
`AmsTray` (544 bytes against 32) and `BambuReport` (984 against 120) both have
one variant far larger than the rest. The fix clippy wants is a `Box`, which
changes how every reader of those enums is written and puts an allocation on
the push_status path that currently has none. Worth measuring before doing,
not worth doing to silence a warning.
51 tests still pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
340 lines
16 KiB
Rust
340 lines
16 KiB
Rust
//! 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<i32>,
|
|
pub ams: Option<AmsState>,
|
|
pub ams_exist_bits: Option<String>,
|
|
pub insert_flag: Option<bool>,
|
|
pub power_on_flag: Option<bool>,
|
|
pub tray_exist_bits: Option<String>,
|
|
pub tray_is_bbl_bits: Option<String>,
|
|
pub xcam: Option<XcamSettings>,
|
|
pub xcam_status: Option<String>,
|
|
|
|
// --- 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<String>,
|
|
/// Percent complete, 0-100. v2 also sends an identical `percent`.
|
|
pub mc_percent: Option<i32>,
|
|
/// Minutes remaining. v2 also sends an identical `remain_time`.
|
|
pub mc_remaining_time: Option<i32>,
|
|
pub layer_num: Option<i32>,
|
|
pub total_layer_num: Option<i32>,
|
|
/// Path on the printer, e.g. `/data/Metadata/plate_1.gcode`.
|
|
pub gcode_file: Option<String>,
|
|
/// The human-facing job name, e.g. a model filename.
|
|
pub subtask_name: Option<String>,
|
|
/// `0` when nothing is wrong.
|
|
pub print_error: Option<i32>,
|
|
|
|
// --- 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<f32>,
|
|
pub nozzle_target_temper: Option<f32>,
|
|
pub bed_temper: Option<f32>,
|
|
pub bed_target_temper: Option<f32>,
|
|
/// Not sent by every model - absent from the H2C capture.
|
|
pub chamber_temper: Option<f32>,
|
|
|
|
// --- 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<pwm>`, 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<String>,
|
|
pub heatbreak_fan_speed: Option<String>,
|
|
pub big_fan1_speed: Option<String>,
|
|
pub big_fan2_speed: Option<String>,
|
|
pub wifi_signal: Option<String>,
|
|
|
|
/// 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<Vec<serde_json::Value>>,
|
|
|
|
// --- 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<i32>,
|
|
/// 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<Vec<i32>>,
|
|
/// 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<VtTray>,
|
|
|
|
// 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<T>` 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<AmsUnit>,
|
|
}
|
|
|
|
/// 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<AmsTray>,
|
|
pub humidity: Option<String>,
|
|
pub temp: Option<String>,
|
|
pub humidity_raw: Option<String>,
|
|
pub dry_time: Option<i32>,
|
|
pub info: Option<String>,
|
|
}
|
|
|
|
/// 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<T>`.
|
|
/// `#[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<i32>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
/// v2 only.
|
|
pub drying_temp: Option<String>,
|
|
/// v2 only.
|
|
pub drying_time: Option<String>,
|
|
/// v1 only - the same idea as `drying_temp` under a different name.
|
|
pub tray_temp: Option<String>,
|
|
/// v1 only - the same idea as `drying_time` under a different name.
|
|
pub tray_time: Option<String>,
|
|
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<i32>,
|
|
pub cali_idx: Option<i32>,
|
|
pub ctype: Option<i32>,
|
|
pub total_len: Option<i64>,
|
|
/// Flow-calibration coefficients - v1 sends these on a loaded tray.
|
|
pub k: Option<f32>,
|
|
pub n: Option<i32>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
pub bed_temp_type: Option<String>,
|
|
pub nozzle_temp_max: Option<String>,
|
|
pub nozzle_temp_min: Option<String>,
|
|
pub remain: Option<i32>,
|
|
pub tag_uid: Option<String>,
|
|
pub tray_color: Option<String>,
|
|
pub tray_diameter: Option<String>,
|
|
pub tray_id_name: Option<String>,
|
|
pub tray_info_idx: Option<String>,
|
|
pub tray_sub_brands: Option<String>,
|
|
pub tray_type: Option<String>,
|
|
pub tray_uuid: Option<String>,
|
|
pub tray_weight: Option<String>,
|
|
pub xcam_info: Option<String>,
|
|
/// 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<String>,
|
|
pub tray_time: Option<String>,
|
|
/// Flow-calibration profile index (`-1` when none) and coefficients.
|
|
pub cali_idx: Option<i32>,
|
|
pub k: Option<f32>,
|
|
pub n: Option<i32>,
|
|
}
|
|
|
|
/// 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,
|
|
}
|