Files
continuum-proxy/src/printer/bambu_commands/print.rs
T
octoturgeandClaude Opus 5 6766ebbe80 Clear the clippy warnings, except the two that are not cosmetic
Down from 29 warnings to 2, in the spirit of 4ab95ff.

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

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

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

51 tests still pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 21:43:06 +00:00

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,
}