Derive the AMS mapping from the project, and blink on completion

Two changes, both following from the print that paused at 1% because
`use_ams: false` pointed an AMS-sliced project at the external spool.

## upload_project_file no longer guesses

It now reads the `.3mf` it already holds and works out where each filament
should come from. `Metadata/slice_info.config` names every filament's
`tray_info_idx`, type and colour; the AMS report names the same three per
loaded tray; matching them is the whole job. `src/printer/project_file.rs`
does the reading and matching and is free of printer types, so it is
testable without a printer - which is the only way this gets tested at all,
the alternative being to notice a mapping bug by watching a machine print in
the wrong colour.

The mapping is derived *before* the upload: a project that cannot be matched
should not result in a file being moved onto the machine that cannot then be
printed.

It never falls back to the external spool to make an unmatched project
"work". The one case it answers `ExternalSpool` for is a printer reporting no
AMS trays at all. A printer *with* an AMS whose project wants the spool is an
error, not a fallback, because `vt_tray` is not modelled on `PrintReport` -
there is no way to check the spool holds the right material, and claiming a
match without checking is how this went wrong the first time.

Two fixtures carry the real cases: `cube_slice_info.3mf` is the project that
cost the print (deflated on purpose - every other test archive is Stored, so
the decompression path and the flate2 backend behind zip's `deflate-flate2`
would otherwise never run), and the existing `push_status_v1_p1p_idle.json`
supplies the AMS side. The end-to-end test asserts they agree on
`[3,-1,-1,-1,-1]`: both slots hold PETG GFG99 and only the colour tells them
apart, so a matcher stopping at `tray_info_idx` would print the cube in
black.

A malformed `<filament>` element is an error rather than a skip. A skipped
filament leaves a hole at its index, the hole becomes `-1`, and the printer
is told that material is unused - so the plate prints in whatever remains
mapped.

New dependency: `zip` 9.0.3 (MIT) with `deflate-flate2`, plus `flate2` for a
backend, which zip's feature does not select on its own. All six new crates
are MIT, MIT/Apache-2.0, 0BSD/MIT/Apache-2.0, MIT/Zlib/Apache-2.0 or Zlib,
and `cargo deny check licenses` passes. No bzip2, lzma, xz or zstd entered
the graph.

## Lights on completion

`completion::run` is a third daemon task that notices a print finishing and
blinks that printer's chamber light for ten seconds, then leaves it off:
500ms on, 500ms off, twenty commands, ending dark.

Its own task rather than a branch in the uplink's telemetry loop, because
that loop only ticks while the cloud connection is up - and a light that only
works when the internet does is not much of a notification for someone
standing next to the printer.

Three rules, each a false celebration avoided and each tested: only a
*transition* into `FINISH` fires, because `FINISH` persists until the next
print starts; a printer seen for the first time never fires, so restarting
the gateway does not announce prints that finished yesterday; and `FAILED`
never fires, because that is what a Bambu reports for a cancellation,
including every `print.stop` this software sends.

The blink is driven from `On`/`Off` rather than the firmware's `flashing`
mode. `flashing` is in the schema but the meaning of `loop_times` versus
`interval_time` is unconfirmed, so "blink for ten seconds" could not be
predicted; two obvious commands give an exact duration. The light is left off
even when a blink fails part-way, since a machine abandoned with its light on
looks like one still working.

`set_light` is **unverified against hardware** - the P1P was powered off when
this was written, and the doc comment says so. The shape is built from
`schemas/bambulab/{v1,v2}/request/system/ledctrl.schema.json` rather than by
hand, and it goes out as a correlated request so a rejection is visible.
This commit is contained in:
2026-10-15 21:27:24 +00:00
parent 431dd849da
commit c22ccf5d22
8 changed files with 1075 additions and 36 deletions
+274 -28
View File
@@ -226,6 +226,104 @@ pub struct BambuPrinter {
connection: Option<(super::bambu_mqtt::BambuMqttSession, BambuProtocolVersion)>,
}
/// Which plate of a multi-plate project gets printed.
///
/// Always the first, matching `start_uploaded_file`'s hardcoded
/// `Metadata/plate_1.gcode`. The two have to agree: deriving the filament
/// mapping from plate 2 while printing plate 1 would map confidently onto
/// the wrong thing. When one becomes a parameter, so must the other.
const FIRST_PLATE: u32 = 1;
/// How many slots Bambu numbers each AMS unit as, for turning a
/// (unit, tray) pair into the single slot number `ams_mapping` wants.
///
/// 4 even for an AMS HT, which physically has one: the numbering is by unit
/// index, not by how many slots the unit happens to have.
const SLOTS_PER_AMS: i32 = 4;
/// External spools, which `ams_mapping` has to be padded for even though it
/// cannot address them.
///
/// 1 is right for every printer this has been used with. A dual-nozzle H2C
/// has two, and this should come from the report rather than a constant once
/// `vt_tray` is modelled on `PrintReport` - the ams_mapping schema spells
/// out the arithmetic.
const EXTERNAL_SPOOLS: usize = 1;
/// Flattens the AMS report into trays with global slot numbers, plus the
/// total tray count `ams_mapping` must be padded to.
///
/// The count includes *empty* AMS slots and the external spool: the padding
/// rule is over every tray the printer has, not every tray with filament in
/// it. Getting that wrong produces an under-length array, which is one of
/// the quiet ways a mapping fails.
fn available_trays(ams: Option<&super::AmsState>) -> (Vec<super::AvailableTray>, usize) {
let Some(ams) = ams else {
return (Vec::new(), EXTERNAL_SPOOLS);
};
let mut trays = Vec::new();
let mut total = EXTERNAL_SPOOLS;
for unit in &ams.ams {
let unit_index: i32 = unit.id.parse().unwrap_or(0);
total += unit.tray.len();
for tray in &unit.tray {
// Only a loaded tray can be matched against; an empty slot still
// counts towards `total` above.
let super::AmsTray::Loaded(loaded) = tray else {
continue;
};
let Ok(slot_in_unit) = loaded.id.parse::<i32>() else {
continue;
};
trays.push(super::AvailableTray {
slot: unit_index * SLOTS_PER_AMS + slot_in_unit,
tray_info_idx: loaded.tray_info_idx.clone(),
filament_type: loaded.tray_type.clone(),
color: super::normalise_color(&loaded.tray_color),
});
}
}
(trays, total)
}
/// What a printer's light should be doing.
///
/// Only the two steady states. Bambu's `system.ledctrl` also has a
/// `flashing` mode taking `led_on_time`/`led_off_time`/`loop_times`/
/// `interval_time` (see
/// `schemas/bambulab/v1/request/system/ledctrl.schema.json`), and it is
/// deliberately not wired up: the meaning of `loop_times` versus
/// `interval_time` is not confirmed against hardware, so a caller asking for
/// "blink for ten seconds" cannot currently predict what the firmware will
/// do with it. Driving the blink from here out of `On` and `Off` gives an
/// exact duration out of two commands whose behaviour is obvious. Switch to
/// the firmware mode once someone has watched a real printer do it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LightState {
On,
Off,
}
impl LightState {
/// The wire word. The schema accepts upper and lower case; lower is what
/// Bambu Studio sends.
fn as_str(self) -> &'static str {
match self {
Self::On => "on",
Self::Off => "off",
}
}
}
/// The light every Bambu has. `work_light` and `chamber_light2` are in the
/// schema's enum but not on every model, so they are not assumed here.
pub const CHAMBER_LIGHT: &str = "chamber_light";
/// Where a project print draws its filament from.
///
/// This exists because the alternative - hardcoding `use_ams: false` -
@@ -540,6 +638,40 @@ impl BambuPrinter {
/// was arriving and being silently dropped by a `PrintReport`
/// deserialization failure (see `handle_incoming_report`). So there is
/// no missing "other trigger" to go looking for.
/// Turns one of the printer's lights on or off.
///
/// **Unverified against hardware.** Every other live-confirmed call in
/// this file says so explicitly; this one was written while the only
/// available printer was powered off. The command shape comes from
/// `schemas/bambulab/{v1,v2}/request/system/ledctrl.schema.json` and is
/// built from it rather than by hand, so the field names and types are
/// as good as the schema is - but whether the printer accepts
/// `chamber_light` and answers, and whether it replies at all, is
/// untested. It goes out as a correlated `request` so that a rejection
/// is visible rather than lost, the same reason `control_print` does.
///
/// If this turns out not to reply, `request` will time out after 10s and
/// this should become a `publish` - the symptom to look for.
pub async fn set_light(&self, node: &str, state: LightState) -> Result<(), PrinterError> {
let (session, version) = self
.connection
.as_ref()
.ok_or_else(|| PrinterError(format!("{} is not connected", self.base.name)))?;
let mut body = continuum_schema_instance::build_instance_from_file(&get_schema_for_command(
*version,
"system".into(),
"ledctrl".into(),
))
.map_err(|err| PrinterError(format!("failed to build ledctrl request: {err}")))?;
body["led_node"] = serde_json::Value::String(node.to_string());
body["led_mode"] = serde_json::Value::String(state.as_str().to_string());
let reply = session.request("system", body).await?;
Self::command_outcome(&self.base.name, "ledctrl", &reply)
}
pub async fn pushall(&self) -> Result<(), PrinterError> {
let (session, version) =
self.connection.as_ref().ok_or_else(|| PrinterError(format!("{} is not connected", self.base.name)))?;
@@ -617,6 +749,73 @@ impl BambuPrinter {
self.list_dir(super::bambu_ftps::CACHE_DIR).await
}
/// Works out where a project's filaments should come from, by reading
/// the project and comparing it with what the printer actually has
/// loaded.
///
/// This is the thing that `use_ams: false` was standing in for. The
/// archive's `Metadata/slice_info.config` names each filament's
/// `tray_info_idx`, type and colour; the AMS report names the same three
/// things per loaded tray; matching them is the whole job. See
/// `project_file::map_to_trays` for the tie-break order, and
/// `FilamentSource` for what getting this wrong does to a real print.
///
/// Requires a `print.push_status` to have arrived, because there is no
/// way to guess what is loaded. On v1 that means `pushall` must have
/// been called since connecting; `connect()` does.
///
/// ## What it will not do
///
/// It never falls back to the external spool to make an unmatched
/// project "work". An unmatched filament is an error, because every
/// alternative is a guess about what someone meant to print.
///
/// The one case it does answer `ExternalSpool` for is a printer
/// reporting no AMS trays at all, where there is nothing else it could
/// mean. Note the asymmetry that leaves: a printer *with* an AMS whose
/// project wants the external spool is an error here, not an
/// `ExternalSpool`, because `vt_tray` is not modelled on `PrintReport` -
/// so there is no way to check that the spool holds the right material,
/// and claiming a match without checking is how this went wrong the
/// first time. Modelling `vt_tray` is what fixes that.
pub async fn filament_source_for(
&self,
archive: &[u8],
plate: u32,
) -> Result<FilamentSource, PrinterError> {
let filaments = super::plate_filaments(archive, plate)
.map_err(|err| PrinterError(format!("{}: {err}", self.base.name)))?;
let report = self.print_status().await.ok_or_else(|| {
PrinterError(format!(
"{}: no print.push_status yet, so there is no way to know what filament is loaded",
self.base.name
))
})?;
let (trays, tray_count) = available_trays(report.ams.as_ref());
if trays.is_empty() {
tracing::info!(
printer = %self.base.name,
"no AMS trays reported; printing from the external spool"
);
return Ok(FilamentSource::ExternalSpool);
}
let mapping = super::map_to_trays(&filaments, &trays)
.map_err(|err| PrinterError(format!("{}: {err}", self.base.name)))?;
tracing::info!(
printer = %self.base.name,
?mapping,
tray_count,
"mapped the project's filaments onto AMS slots"
);
FilamentSource::ams(mapping, tray_count)
}
/// Reads one file back off the printer's SD card.
///
/// Read-only, and the counterpart to `upload_file`. Needed because a
@@ -686,8 +885,11 @@ impl BambuPrinter {
session.request(category, body).await
}
/// Interprets the `result`/`reason` pair a `print` command's reply
/// carries, turning a rejection into an `Err` instead of losing it.
/// Interprets the `result`/`reason` pair a command's reply carries,
/// turning a rejection into an `Err` instead of losing it.
///
/// Used for `print` and `system` commands alike - both answer in this
/// shape.
///
/// Confirmed live on a P1P (2026-10-11): `print.gcode_file` naming a
/// file that doesn't exist replies
@@ -704,7 +906,7 @@ impl BambuPrinter {
/// (it echoed our `sequence_id` back), and not every firmware/command
/// pair is known to include `result` - so this refuses to invent a
/// failure, while still not hiding an unrecognised shape.
fn print_command_outcome(
fn command_outcome(
printer: &str,
command: &str,
reply: &serde_json::Value,
@@ -728,7 +930,7 @@ impl BambuPrinter {
printer,
command,
reply = %reply,
"a print command's reply carried no 'result' field; treating it as accepted"
"a command's reply carried no 'result' field; treating it as accepted"
);
Ok(())
}
@@ -812,7 +1014,7 @@ impl BambuPrinter {
/// real print. If you are the one who tests it, record the answer here.
///
/// A rejection is at least no longer silent: both branches go out as
/// correlated requests and `print_command_outcome` turns a `fail` into
/// correlated requests and `command_outcome` turns a `fail` into
/// an `Err`. Before that, asking this printer to print a file it could
/// not find returned `Ok(())`.
///
@@ -871,9 +1073,9 @@ impl BambuPrinter {
// A correlated `request`, not a fire-and-forget publish. This was
// the latter until a P1P was asked to print a file it didn't have
// and happily returned `Ok(())` - see `print_command_outcome`.
// and happily returned `Ok(())` - see `command_outcome`.
let reply = session.request("print", body).await?;
Self::print_command_outcome(&self.base.name, command, &reply)
Self::command_outcome(&self.base.name, command, &reply)
}
/// The current in-memory `print.push_status` object - `None` until at
@@ -922,7 +1124,7 @@ impl GenericPrinter for BambuPrinter {
// read `gcode_state` back via `print_status()`. The value of the
// reply is the negative case: a rejection is no longer silent.
let reply = session.request("print", body).await?;
Self::print_command_outcome(&self.base.name, action.as_str(), &reply)
Self::command_outcome(&self.base.name, action.as_str(), &reply)
}
@@ -1010,31 +1212,25 @@ impl GenericPrinter for BambuPrinter {
file_name: String,
data: Vec<u8>,
) -> Result<(), PrinterError> {
// `data` is about to be moved, so take its length while it's here.
let byte_count = data.len();
// Worked out *before* the upload, from the bytes still in hand. If
// this project cannot be mapped to what the printer has loaded, the
// right outcome is to say so and not move a file onto the machine
// that cannot then be printed.
let filament = self.filament_source_for(&data, FIRST_PLATE).await?;
let written = self.upload_file(&file_name, data).await?;
println!(
"[{}] uploaded {file_name} ({written} of {byte_count} bytes) to {}/",
self.base.name,
super::bambu_ftps::CACHE_DIR
tracing::info!(
printer = %self.base.name,
file = %file_name,
written,
byte_count,
"uploaded a project file to the SD card root"
);
// The external spool, because this trait method has no way to know
// any better: `upload_project_file` is vendor-generic and an AMS
// mapping is a Bambu concept, so it cannot appear in the signature.
//
// That makes this the one remaining place that can silently pick the
// wrong filament source - see `FilamentSource` for what that does to
// a real print. It is not currently reachable: there is no
// start-print command on the uplink, so nothing calls this. The fix
// is for this method to *derive* the mapping from the `.3mf` it
// already holds in `data` - `Metadata/slice_info.config` lists each
// plate's filaments with their `tray_info_idx` and colour, which is
// exactly what has to be matched against the AMS report. That needs
// a zip reader, which is a dependency decision, which is why it is
// not done here yet.
self.start_uploaded_file(&file_name, &FilamentSource::ExternalSpool).await
self.start_uploaded_file(&file_name, &filament).await
}
/// Builds the real JSON payload straight from continuum-schemas'
@@ -1116,6 +1312,56 @@ mod tests {
assert_eq!(body["ams_mapping"], serde_json::json!(""));
}
/// The AMS half of the mapping, against the real P1P report in
/// `tests/fixtures/push_status_v1_p1p_idle.json`: one unit, slots 0 and
/// 1 empty, slots 2 and 3 holding PETG in two different colours.
#[test]
fn real_ams_report_flattens_to_the_loaded_slots() {
let raw = include_str!("../../tests/fixtures/push_status_v1_p1p_idle.json");
let value: serde_json::Value = serde_json::from_str(raw).expect("fixture is json");
let body = value.get("print").unwrap_or(&value);
let report: super::super::PrintReport =
serde_json::from_value(body.clone()).expect("fixture should parse");
let (trays, total) = available_trays(report.ams.as_ref());
assert_eq!(trays.len(), 2, "only slots 2 and 3 hold filament");
assert_eq!(trays[0].slot, 2);
assert_eq!(trays[1].slot, 3);
// The printer reports RGBA; the project writes RGB.
assert_eq!(trays[0].color, "161616");
assert_eq!(trays[1].color, "2850E0");
// 4 AMS slots - including the two empty ones - plus one external
// spool. The padding rule is over every tray, not every loaded tray,
// and an under-length array is one of the quiet failure modes.
assert_eq!(total, 5);
}
/// The whole thing, end to end, on the two real captures: the project
/// that cost a print and the AMS report of the printer it was sent to.
/// This is the case the old `use_ams: false` got wrong.
#[test]
fn the_real_project_and_the_real_printer_agree_on_slot_three() {
let raw = include_str!("../../tests/fixtures/push_status_v1_p1p_idle.json");
let value: serde_json::Value = serde_json::from_str(raw).expect("fixture is json");
let body = value.get("print").unwrap_or(&value);
let report: super::super::PrintReport =
serde_json::from_value(body.clone()).expect("fixture should parse");
let (trays, total) = available_trays(report.ams.as_ref());
let project = include_bytes!("../../tests/fixtures/cube_slice_info.3mf");
let filaments = super::super::plate_filaments(project, FIRST_PLATE).expect("should parse");
let mapping = super::super::map_to_trays(&filaments, &trays).expect("should match");
let source = FilamentSource::ams(mapping, total).expect("valid");
let mut sent = serde_json::json!({});
source.apply(&mut sent);
assert_eq!(sent["use_ams"], serde_json::json!(true));
assert_eq!(sent["ams_mapping"], serde_json::json!([3, -1, -1, -1, -1]));
}
#[test]
fn an_ams_source_sends_an_array_and_turns_use_ams_on() {
let mut body = serde_json::json!({});