Add print.rs (AmsState/AmsUnit/AmsTray/XcamSettings), fix print vs pushing

Mirrors continuum-schemas' schemas/bambulab/ restructuring: PushallResponse
removed (it never existed on the wire — pushall's request goes to
"pushing", but the state-push stream it triggers arrives under "print",
a different root key, not a direct reply). BambuReport::Pushing(...) ->
BambuReport::Print(PrintReport).

AmsTray is an untagged enum (Empty/Loaded) rather than one struct full of
Option<T> fields, matching the JSON Schema's oneOf for the same slot -
#[serde(deny_unknown_fields)] on EmptyTray is load-bearing: untagged enums
try variants in order, and a loaded tray (superset of Empty's one field)
would otherwise incorrectly match Empty first.

Verified against the full real ams/xcam example from the conversation via
examples/bambu_commands_demo.rs - both the empty and loaded tray in that
payload deserialize to the correct enum variant.
This commit is contained in:
2026-08-28 23:19:20 +00:00
parent 4eef52714b
commit 1583f531fa
5 changed files with 144 additions and 25 deletions
+22 -2
View File
@@ -5,7 +5,7 @@
//! matches byte for byte, same as the proto-based version did, with none //! matches byte for byte, same as the proto-based version did, with none
//! of the build.rs ceremony. //! of the build.rs ceremony.
use continuum_proxy::printer::{CommandEnvelope, GetVersionRequest, GetVersionResponse, PushallRequest}; use continuum_proxy::printer::{CommandEnvelope, GetVersionRequest, GetVersionResponse, PrintReport, PushallRequest};
fn main() { fn main() {
// --- get_version request --- // --- get_version request ---
@@ -31,5 +31,25 @@ fn main() {
let actual = serde_json::to_string(&pushall).unwrap(); let actual = serde_json::to_string(&pushall).unwrap();
let expected = r#"{"sequence_id":"0","command":"pushall","version":1,"push_target":1}"#; let expected = r#"{"sequence_id":"0","command":"pushall","version":1,"push_target":1}"#;
println!("pushall request: {actual}"); println!("pushall request: {actual}");
println!("matches Bambu's shape: {}", actual == expected); println!("matches Bambu's shape: {}\n", actual == expected);
// --- print report: arrives under "print", NOT "pushing" (pushall
// triggers this stream, it isn't a direct reply to it) — includes the
// full ams/xcam example, with one empty tray and one loaded tray, to
// exercise the untagged Empty/Loaded split.
let real_print_report = r#"{
"ams": {"ams": [{"humidity":"4","id":"0","temp":"22.7","tray":[
{"id":"0"},
{"bed_temp":"0","bed_temp_type":"0","cols":["000000FF"],"drying_temp":"0","drying_time":"0","id":"1","nozzle_temp_max":"240","nozzle_temp_min":"190","remain":0,"tag_uid":"0000000000000000","tray_color":"000000FF","tray_diameter":"0.00","tray_id_name":"","tray_info_idx":"GFA00","tray_sub_brands":"","tray_type":"PLA","tray_uuid":"00000000000000000000000000000000","tray_weight":"0","xcam_info":"000000000000000000000000"}
]}]},
"ams_exist_bits": "1",
"insert_flag": true,
"power_on_flag": false,
"tray_exist_bits": "e",
"tray_is_bbl_bits": "e",
"xcam": {"allow_skip_parts":false,"buildplate_marker_detector":false,"first_layer_inspector":true,"halt_print_sensitivity":"medium","print_halt":true,"printing_monitor":true,"spaghetti_detector":true},
"xcam_status": "0"
}"#;
let parsed: PrintReport = serde_json::from_str(real_print_report).unwrap();
println!("print report parsed fine, empty + loaded tray both matched correctly:\n{parsed:#?}");
} }
+22 -12
View File
@@ -7,15 +7,26 @@
//! continuum-schemas for continuum.v1, which is a schema we designed //! continuum-schemas for continuum.v1, which is a schema we designed
//! ourselves and that both TS and Rust genuinely need. //! ourselves and that both TS and Rust genuinely need.
//! //!
//! One file per command (`get_version.rs`, `pushall.rs`, ...) — each holds //! One file per Bambu *category* (`get_version.rs` for "info", `pushall.rs`
//! its request AND response together, since they're conceptually one unit. //! + `print.rs` for "pushing"/"print" — see below for why those are two
//! Copy `get_version.rs`'s shape for your next command. //! files, not one). Copy `get_version.rs`'s shape for your next command.
//!
//! Mirrors continuum-schemas' `schemas/bambulab/` JSON Schemas — same
//! category split, same "print vs pushing" correction (see that
//! directory's README for the full explanation): a `pushall` *request*
//! goes to the "pushing" key, but the ongoing state-push stream it
//! triggers arrives under a different key, "print" — so `PushallRequest`
//! lives in `pushall.rs` and the thing it triggers, `PrintReport`, gets
//! its own file, `print.rs`, rather than living together as if one were
//! simply "the response to" the other.
mod get_version; mod get_version;
mod print;
mod pushall; mod pushall;
pub use get_version::{GetVersionRequest, GetVersionResponse, ModuleInfo}; pub use get_version::{GetVersionRequest, GetVersionResponse, ModuleInfo};
pub use pushall::{PushallRequest, PushallResponse}; pub use print::{AmsState, AmsTray, AmsUnit, EmptyTray, LoadedTray, PrintReport, XcamSettings};
pub use pushall::PushallRequest;
/// Fields every Bambu command/response shares. This is the "inheritance" /// Fields every Bambu command/response shares. This is the "inheritance"
/// replacement for these structs — composition, not extension: every /// replacement for these structs — composition, not extension: every
@@ -34,12 +45,10 @@ pub struct CommandEnvelope {
/// One request you can send to a Bambu printer, keyed by Bambu's top-level /// One request you can send to a Bambu printer, keyed by Bambu's top-level
/// JSON category ("info", "pushing", ...). NOTE: that top-level key is a /// JSON category ("info", "pushing", ...). NOTE: that top-level key is a
/// *category*, not the specific command — `CommandEnvelope::command` is /// *category*, not the specific command — `CommandEnvelope::command` is
/// what actually says which one ("get_version", "pushall", ...). Right /// what actually says which one ("get_version", "pushall", ...). A
/// now each category maps to exactly one known command, one-to-one. If a /// category can have more than one possible request shape — if that ever
/// category ever needs a second command (e.g. "info" gaining something /// happens here, that variant's payload becomes its own small
/// besides get_version), that variant's payload becomes its own small /// `#[serde(tag = "command")]` enum instead of a single struct.
/// `#[serde(tag = "command")]` enum instead of a single struct — ask if
/// you get there and want a worked example.
#[derive(serde::Serialize, Debug)] #[derive(serde::Serialize, Debug)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum BambuRequest { pub enum BambuRequest {
@@ -47,10 +56,11 @@ pub enum BambuRequest {
Pushing(PushallRequest), Pushing(PushallRequest),
} }
/// Same idea, the response direction. /// Same idea, the response direction — note "print", not "pushing" (see
/// this module's doc comment).
#[derive(serde::Deserialize, Debug)] #[derive(serde::Deserialize, Debug)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum BambuReport { pub enum BambuReport {
Info(GetVersionResponse), Info(GetVersionResponse),
Pushing(PushallResponse), Print(PrintReport),
} }
+94
View File
@@ -0,0 +1,94 @@
//! The ongoing print-state push (root key "print"). Doesn't carry a
//! `CommandEnvelope` — no `sequence_id`/`command` seen on this one in the
//! real capture, and it may genuinely not have them (an async push, not a
//! reply to a specific request) — revisit if a real capture proves
//! otherwise.
#[derive(serde::Deserialize, Debug)]
pub struct PrintReport {
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>,
// 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<T>` for anything that isn't always present.
}
/// 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)]
pub struct AmsState {
pub ams: Vec<AmsUnit>,
}
/// One physical AMS unit (Bambu's numbering starts at 0 via `id`).
#[derive(serde::Deserialize, Debug)]
pub struct AmsUnit {
pub humidity: String,
pub id: String,
pub temp: String,
pub tray: Vec<AmsTray>,
}
/// 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)]
#[serde(untagged)]
pub enum AmsTray {
Empty(EmptyTray),
Loaded(LoadedTray),
}
#[derive(serde::Deserialize, Debug)]
#[serde(deny_unknown_fields)]
pub struct EmptyTray {
pub id: String,
}
#[derive(serde::Deserialize, Debug)]
pub struct LoadedTray {
pub id: String,
pub bed_temp: String,
pub bed_temp_type: String,
pub cols: Vec<String>,
pub drying_temp: String,
pub drying_time: 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,
}
/// Camera-based AI print monitoring settings (spaghetti detection,
/// first-layer inspection, etc.).
#[derive(serde::Deserialize, Debug)]
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,
}
+4 -10
View File
@@ -8,13 +8,7 @@ pub struct PushallRequest {
pub push_target: i32, pub push_target: i32,
} }
#[derive(serde::Deserialize, Debug)] // No PushallResponse here — sending this request doesn't get a direct
pub struct PushallResponse { // reply. It makes the printer start/refresh the ongoing print-state push,
#[serde(flatten)] // which arrives under a different root key ("print", not "pushing") — see
pub envelope: CommandEnvelope, // print.rs for that.
pub result: Option<String>,
pub reason: Option<String>,
// The real pushall/print report has a lot more fields (ams, xcam,
// tray_exist_bits, ...) — add them here the same way, one field per
// line, `Option<T>` for anything that isn't always present.
}
+2 -1
View File
@@ -35,7 +35,8 @@ mod prusa;
pub use bambu::{BambuGenericPrinter, BambuTls, BambuV1Printer, BambuV2Printer}; pub use bambu::{BambuGenericPrinter, BambuTls, BambuV1Printer, BambuV2Printer};
pub use bambu_commands::{ pub use bambu_commands::{
BambuReport, BambuRequest, CommandEnvelope, GetVersionRequest, GetVersionResponse, PushallRequest, PushallResponse, AmsState, AmsTray, AmsUnit, BambuReport, BambuRequest, CommandEnvelope, EmptyTray, GetVersionRequest,
GetVersionResponse, LoadedTray, PrintReport, PushallRequest, XcamSettings,
}; };
pub use klipper::KlipperPrinter; pub use klipper::KlipperPrinter;
pub use prusa::{PrusaLinkPrinter, PrusaSerialPrinter}; pub use prusa::{PrusaLinkPrinter, PrusaSerialPrinter};