Files
continuum-proxy/src/printer/bambu_commands/print.rs
T
octoturge 47290175aa
Windows discovery tool / windows (push) Successful in 57s
Accept partial ams objects; allow plain-HTTP downloads from a local store on request
A P1P mid-print pushes ams objects with no unit list; every such update was
dropped as "missing field ams", silencing telemetry for the rest of the print
(found on the first real dispatch). The unit list now defaults to empty and a
partial object does not wipe known units.

CONTINUUM_ALLOW_INSECURE_LOCAL_DOWNLOADS=true lets the downloader fetch a
presigned URL over plain HTTP, but only from a loopback or RFC 1918 address.
2026-10-29 22:13:47 +00:00

403 lines
19 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). Now declared in
/// `push_status.schema.json` too.
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>,
/// What each light is actually doing. This is the read-back for
/// `set_light`, and the only one there is: a `ledctrl` reply says
/// `result: "success"` as soon as the command parses, which on a Bambu
/// means "accepted" and not "the light changed" (the same trap as the
/// print-control verbs). Confirmed on the P1P (2026-10-15) by setting
/// the chamber light on and off and reading
/// `[{"node":"chamber_light","mode":"on"}]` / `"off"` back here - which
/// is the first time `set_light` was shown to do anything at all.
///
/// Observed on v1 despite only the v2 schema declaring it; the v1
/// schema was simply missing it.
pub lights_report: Option<Vec<LightReport>>,
/// The printer's camera settings - and, on the models that have one, the
/// URL of its stream. See `Ipcam`.
pub ipcam: Option<Ipcam>,
// 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;
// A partial `ams` object (no unit list) must not wipe the units already known.
self.ams = match newer.ams {
Some(partial) if partial.ams.is_empty() => self.ams.take(),
other => other.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());
self.lights_report = newer.lights_report.or(self.lights_report.take());
self.ipcam = newer.ipcam.or(self.ipcam.take());
}
}
/// The camera block, which is where a printer says whether it has a stream
/// anything else can connect to.
///
/// Only `rtsp_url` is modelled of the dozen-odd fields in here, because it is
/// the only one that is actionable: the rest are recording and timelapse
/// preferences the printer manages itself.
///
/// **It is absent on most printers, and that is the headline.** Confirmed
/// from live captures of two machines: an H2C reports
/// `rtsps://<ip>:322/streaming/live/1`, while a P1P's block carries
/// `ipcam_dev: "1"`, `ipcam_record: "enable"` and *no URL at all*. So the
/// P1/A1 class has a camera that simply cannot be reached this way - it
/// needs Bambu's proprietary port-6000 JPEG stream, which this crate does
/// not implement. `ipcam_dev: "1"` means "a camera exists", not "a camera
/// you can connect to", and reading it as the latter would have the gateway
/// advertise streams that never play.
#[derive(serde::Deserialize, Debug, Clone)]
pub struct Ipcam {
/// `rtsps://<ip>:322/streaming/live/1` on the models that have it. Note
/// it carries no credentials: a client has to add `bblp:<access_code>`
/// itself.
pub rtsp_url: Option<String>,
}
/// One light's state, as the printer reports it rather than as we asked for
/// it. `mode` is the same vocabulary `ledctrl` accepts (`"on"`, `"off"`,
/// `"flashing"`), and `node` the same light name - so a round-trip is
/// directly comparable, which is the point of having it.
///
/// Both fields optional on principle rather than from evidence: every entry
/// seen on the P1P carried both, but one light in a list is not a sample of
/// every light on every model, and a missing `node` must not cost us the
/// whole report.
#[derive(serde::Deserialize, Debug, Clone)]
pub struct LightReport {
pub node: Option<String>,
pub mode: Option<String>,
}
/// 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 {
/// Absent from the partial `ams` objects a P1P pushes mid-print (found on
/// the first real dispatch, 2026-10-29: every update was dropped as
/// "missing field `ams`" and the job could never complete).
#[serde(default)]
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,
}