Model vt_tray and the stage fields, and use them

## vt_tray closes a hole in the filament decision

`filament_source_for` used to refuse a project that wanted the external
spool, on a printer that had an AMS, purely because there was no way to see
what the spool held - and claiming a match without looking is how the
`use_ams: false` bug happened. Now that `vt_tray` is on `PrintReport`, the
question can be answered: `external_spool_satisfies` reuses `map_to_trays`
with the spool as the single candidate tray, so an `Ok` means every filament
matched it.

The decision is now: the AMS first when there is one, because a project
sliced for the AMS should use the AMS even if the spool happens to hold the
same filament; the spool if nothing in the AMS fits and the spool does; and
an error otherwise, reporting the AMS mismatch rather than the spool one
since that names what was wanted against what was loaded. A reported spool
that does not match is a real negative now and is refused.

One case stays unverifiable: a v2 printer reporting neither AMS trays nor
`vt_tray`. v2 omits the key entirely, so absence means "not reported", not
"empty". That one is logged as an unverified assumption instead of being
passed off as a match - and it is the same reason `EXTERNAL_SPOOLS` is still
a constant. Counting reported spools would give 0 on exactly the printer the
ams_mapping schema says has two.

Tests use the real P1P capture's own spool (PETG P9599a25 in D3C5A3FF): it
satisfies a project asking for that filament, and does not satisfy one asking
for the AMS's blue GFG99. Those two were indistinguishable before.

## stage reaches the cloud

`stg_cur`/`stg` are modelled, and `stage` is now on `PrinterTelemetry`,
through `continuum-schemas` and the ts-types mirror. Without it progress is
ambiguous in a way that reads as a fault: on the confirmed P1P run `percent`
climbed to 14% while `layer` stayed 0 for minutes, the printer walking
`stg_cur` 54 -> 14 -> 13 -> 0 before laying the first layer. A console
showing 14% at layer 0 with no stage looks stuck.

A zero stage means "printing" and has its own test on both sides, because
`skip_serializing_if = "Option::is_none"` plus one `unwrap_or_default` is all
it takes to turn "printing" into "no stage".

The backend test asserts a *wrongly typed* stage is rejected, not just that a
valid one is accepted: `PrinterTelemetrySchema` permits unknown properties -
verified - so `{ stage: 54 }` validates whether or not the field is declared.
The negative is what fails if someone removes it.

The live capture fixture is left alone. It predates the field, the field is
optional, and editing a capture by hand would defeat the point of having one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 21:37:20 +00:00
co-authored by Claude Opus 5
parent 6ff0c9a649
commit aa49cbba49
8 changed files with 372 additions and 31 deletions
+66
View File
@@ -89,6 +89,30 @@ pub struct PrintReport {
/// 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
@@ -153,6 +177,10 @@ impl PrintReport {
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());
}
}
@@ -259,6 +287,44 @@ pub struct LoadedTray {
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)]