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:
+274
-28
@@ -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!({});
|
||||
|
||||
Reference in New Issue
Block a user