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
+2
View File
@@ -75,3 +75,5 @@ rumqttc = { version = "0.25.1", default-features = false, features = ["use-nativ
# of explicit AUTH TLS. The printers do not care.
# MIT OR Apache-2.0 - checked against docs/LICENSES.md before adding.
suppaftp = { version = "12.1.1", features = ["native-tls", "deprecated"] }
zip = { version = "9.0.3", default-features = false, features = ["deflate-flate2"] }
flate2 = "1.1.10"
+249
View File
@@ -0,0 +1,249 @@
//! Noticing that a print has finished, and saying so at the machine.
//!
//! Its own task, polling the fleet, rather than a branch inside the uplink's
//! telemetry loop. The uplink's loop only ticks while the cloud connection is
//! up, and a light that only comes on when the internet is working is not
//! much of a notification: the person this is for is standing next to the
//! printer, and the printer is on the same LAN as this process. Nothing about
//! telling them should route through a datacentre.
use std::collections::HashMap;
use std::time::Duration;
use tracing::{info, warn};
use crate::printer::{GenericPrinter, LightState, PrinterHandle, CHAMBER_LIGHT};
use crate::uplink::SharedFleet;
/// How long the lights blink for when a print finishes.
const BLINK_FOR: Duration = Duration::from_secs(10);
/// On for this long, then off for this long. 500ms reads as a deliberate
/// blink from across a room; much faster looks like a fault.
const BLINK_HALF_CYCLE: Duration = Duration::from_millis(500);
/// How often to look for a print that has just finished.
///
/// A notification a few seconds late is fine. What matters more is that this
/// is frequent enough not to miss a state: `FINISH` persists until the next
/// print starts, so there is no race to lose - but the first poll that sees
/// it is the one that fires.
const POLL_INTERVAL: Duration = Duration::from_secs(5);
/// Bambu's word for a print that ran to completion.
///
/// Deliberately only this one. `FAILED` is what a Bambu reports for a print
/// that was cancelled or went wrong - including every `print.stop` this
/// software sends - and celebrating a cancellation would be worse than
/// staying dark.
const FINISHED: &str = "FINISH";
/// Remembers each printer's last known state, so a finish can be told from a
/// printer that has simply been sitting in `FINISH` for an hour.
#[derive(Default)]
pub struct CompletionWatcher {
last_state: HashMap<String, String>,
}
impl CompletionWatcher {
pub fn new() -> Self {
Self::default()
}
/// The printers that have *just* finished a print.
///
/// Two rules, both of which exist to avoid a false celebration:
///
/// - It must be a transition. `FINISH` persists until the next print
/// starts, so without this every poll for the next hour would fire.
/// - A printer seen for the first time never fires, even if it is
/// already `FINISH`. Otherwise restarting the gateway blinks every
/// printer that happens to be sitting in `FINISH`, announcing prints
/// that finished yesterday.
pub fn newly_finished<I, S>(&mut self, states: I) -> Vec<String>
where
I: IntoIterator<Item = (String, S)>,
S: AsRef<str>,
{
let mut finished = Vec::new();
for (printer_id, state) in states {
let state = state.as_ref();
let previous = self.last_state.insert(printer_id.clone(), state.to_string());
if state == FINISHED && previous.is_some_and(|prev| prev != FINISHED) {
finished.push(printer_id);
}
}
finished
}
}
/// Watches the fleet and blinks a printer's lights when it finishes a print.
pub async fn run(fleet: SharedFleet) {
let mut watcher = CompletionWatcher::new();
let mut ticker = tokio::time::interval(POLL_INTERVAL);
loop {
ticker.tick().await;
let states = {
let fleet = fleet.lock().await;
// `print_status` is a cached read, but `refresh_status` below
// publishes, so this does hold the lock across one MQTT publish
// per printer. Same shape as the uplink's own `refresh_fleet`,
// and at a handful of printers it is not worth more machinery;
// the blink itself is what had to stay out of the lock, because
// that one takes ten seconds.
let mut states = Vec::new();
for printer in fleet.iter() {
// v1 printers only push a status when asked. Doing it here as
// well as in the uplink means this keeps working with the
// cloud connection down, which is the whole point of the
// separate task.
if let Err(err) = printer.refresh_status().await {
warn!(printer = %printer.base().id, %err, "could not request a status refresh");
}
if let Some(state) = printer.print_status().await.and_then(|r| r.gcode_state) {
states.push((printer.base().id.clone(), state));
}
}
states
};
for printer_id in watcher.newly_finished(states) {
info!(printer = %printer_id, "print finished; blinking the lights");
// Spawned, not awaited: this takes ten seconds, and nothing else
// in the fleet should wait for it.
tokio::spawn(celebrate(fleet.clone(), printer_id));
}
}
}
/// Blinks one printer's chamber light for `BLINK_FOR`, then leaves it off.
///
/// The light is left off whatever happens, including when a blink fails
/// part-way: a printer abandoned mid-blink with its light on looks like a
/// machine still working.
async fn celebrate(fleet: SharedFleet, printer_id: String) {
let deadline = tokio::time::Instant::now() + BLINK_FOR;
let mut state = LightState::On;
while tokio::time::Instant::now() < deadline {
if let Err(err) = set_light(&fleet, &printer_id, state).await {
// Stop at the first failure rather than logging twenty times,
// but still fall through to the final Off below.
warn!(printer = %printer_id, %err, "stopped blinking after a failed light command");
break;
}
state = match state {
LightState::On => LightState::Off,
LightState::Off => LightState::On,
};
tokio::time::sleep(BLINK_HALF_CYCLE).await;
}
if let Err(err) = set_light(&fleet, &printer_id, LightState::Off).await {
warn!(printer = %printer_id, %err, "could not turn the light back off");
}
}
/// One light command, holding the fleet lock only for as long as it takes.
///
/// Locking per command rather than once around the whole blink: the fleet
/// mutex is also what telemetry and cloud command dispatch need, and holding
/// it for ten seconds would stall both for every printer on the farm.
async fn set_light(
fleet: &SharedFleet,
printer_id: &str,
state: LightState,
) -> Result<(), String> {
let fleet = fleet.lock().await;
let printer = fleet
.iter()
.find(|p| p.base().id == printer_id)
.ok_or_else(|| format!("no printer with id '{printer_id}' in this gateway's fleet"))?;
// Bambu-only for now. A Prusa has no addressable light and Klipper's
// depends entirely on the user's own config, so there is nothing
// vendor-neutral to put on `GenericPrinter` yet - and inventing a
// trait method that three of four implementations refuse is worse than
// matching here.
match printer {
PrinterHandle::Bambu(bambu) => {
bambu.set_light(CHAMBER_LIGHT, state).await.map_err(|err| err.to_string())
}
other => Err(format!(
"{} has no light this software knows how to control",
other.display_name()
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn states(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
pairs.iter().map(|(id, s)| (id.to_string(), s.to_string())).collect()
}
/// The regression that matters most: a gateway restarting while a
/// printer sits in FINISH must not announce a print that finished
/// yesterday.
#[test]
fn a_printer_first_seen_already_finished_does_not_fire() {
let mut watcher = CompletionWatcher::new();
assert!(watcher.newly_finished(states(&[("p1p", "FINISH")])).is_empty());
}
#[test]
fn a_transition_into_finish_fires_once_and_only_once() {
let mut watcher = CompletionWatcher::new();
watcher.newly_finished(states(&[("p1p", "RUNNING")]));
assert_eq!(watcher.newly_finished(states(&[("p1p", "FINISH")])), vec!["p1p"]);
// FINISH persists until the next print starts; without the
// transition check this would fire every poll for an hour.
assert!(watcher.newly_finished(states(&[("p1p", "FINISH")])).is_empty());
}
/// A Bambu reports an operator cancellation as FAILED - including every
/// `print.stop` this software sends. Celebrating that would be worse
/// than staying dark.
#[test]
fn a_cancelled_print_never_fires() {
let mut watcher = CompletionWatcher::new();
watcher.newly_finished(states(&[("p1p", "RUNNING")]));
assert!(watcher.newly_finished(states(&[("p1p", "FAILED")])).is_empty());
}
#[test]
fn a_second_print_fires_again() {
let mut watcher = CompletionWatcher::new();
watcher.newly_finished(states(&[("p1p", "RUNNING")]));
watcher.newly_finished(states(&[("p1p", "FINISH")]));
watcher.newly_finished(states(&[("p1p", "RUNNING")]));
assert_eq!(watcher.newly_finished(states(&[("p1p", "FINISH")])), vec!["p1p"]);
}
#[test]
fn printers_are_tracked_independently() {
let mut watcher = CompletionWatcher::new();
watcher.newly_finished(states(&[("p1p", "RUNNING"), ("h2c", "RUNNING")]));
let finished = watcher.newly_finished(states(&[("p1p", "FINISH"), ("h2c", "RUNNING")]));
assert_eq!(finished, vec!["p1p"]);
}
/// Ten seconds of 500ms half-cycles is 20 commands, ending on Off. Worth
/// pinning: the ask was "blink for ten seconds then turn the lights off",
/// and a half-cycle that does not divide the duration would end mid-blink.
#[test]
fn the_blink_divides_evenly_into_the_duration() {
assert_eq!(BLINK_FOR.as_millis() % BLINK_HALF_CYCLE.as_millis(), 0);
assert_eq!(BLINK_FOR.as_millis() / BLINK_HALF_CYCLE.as_millis(), 20);
}
}
+1
View File
@@ -6,6 +6,7 @@
//! compiled once. `go2rtc` is the one exception: the camera-restreaming
//! watchdog is the daemon's own concern and nothing else calls it.
pub mod completion;
pub mod config;
pub mod fleet;
pub mod printer;
+17 -8
View File
@@ -10,7 +10,7 @@ use tracing_subscriber::EnvFilter;
use continuum_proxy::config::Config;
use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
use continuum_proxy::{fleet, uplink};
use continuum_proxy::{completion, fleet, uplink};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
@@ -36,16 +36,25 @@ async fn main() -> anyhow::Result<()> {
// see `uplink::SharedFleet` for why that type and what it costs.
let fleet: uplink::SharedFleet = std::sync::Arc::new(tokio::sync::Mutex::new(fleet));
// Two long-running tasks: the cloud uplink and the go2rtc
// camera-restreaming watchdog. Every printer's MQTT session is a third
// thing running concurrently, but implicitly - each one has its own
// background poll task spawned by `connect()`, kept alive by the fleet
// above. More will join these later (printer discovery, the plate
// changer). `tokio::select!` stops as soon as either returns; both loop
// forever, so in practice this runs until the process is killed.
// Three long-running tasks: the cloud uplink, the go2rtc
// camera-restreaming watchdog, and the print-completion watcher that
// blinks a printer's lights when it finishes. Every printer's MQTT
// session is a fourth thing running concurrently, but implicitly - each
// one has its own background poll task spawned by `connect()`, kept
// alive by the fleet above. More will join these later (printer
// discovery, the plate changer).
//
// The completion watcher is deliberately its own task rather than part
// of the uplink: it must keep working with the cloud connection down,
// because the person it is for is standing next to the printer. See
// `completion::run`.
//
// `tokio::select!` stops as soon as any returns; all three loop forever,
// so in practice this runs until the process is killed.
tokio::select! {
_ = uplink::run(config.clone(), fleet.clone()) => {}
_ = go2rtc::watchdog(config.go2rtc_bin.clone(), config.go2rtc_config.clone()) => {}
_ = completion::run(fleet.clone()) => {}
}
Ok(())
+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!({});
+9
View File
@@ -35,8 +35,17 @@ mod klipper;
mod marlin_serial;
mod prusa;
// Reading a `.3mf` well enough to know which filament a plate wants. Kept
// free of printer types so it is testable without a printer.
mod project_file;
pub use project_file::{
map_to_trays, normalise_color, plate_filaments, AvailableTray, ProjectFilament,
ProjectFileError,
};
pub use bambu::{
BambuPrinter, BambuProtocolPreference, BambuProtocolVersion, BambuTls, FilamentSource,
LightState, CHAMBER_LIGHT,
};
pub use bambu_commands::{
AmsState, AmsTray, AmsUnit, BambuReport, BambuRequest, CommandEnvelope, EmptyTray, GetVersionRequest,
+523
View File
@@ -0,0 +1,523 @@
//! Reading a Bambu `.3mf` project archive well enough to know which
//! filament each plate wants.
//!
//! This exists because `use_ams: false` is not a safe default. Hardcoding it
//! overrode the slicer's own choice and pointed an AMS-sliced project at the
//! external spool; on a real P1P the print started, heated, reached 1% and
//! then the printer paused itself with `print_error: 50364420` and
//! `tray_now: 255`. See `FilamentSource` in `bambu.rs`.
//!
//! Deliberately free of any MQTT or printer types: it takes archive bytes and
//! a plain list of loaded trays, and returns an `ams_mapping`. That keeps it
//! unit-testable without a printer, which is the only way this gets tested at
//! all - the alternative is discovering a mapping bug by watching a machine
//! print in the wrong colour.
use std::io::Read;
/// One filament a plate was sliced for.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectFilament {
/// 0-based. This is the position in `ams_mapping`, so it has to come
/// from the filament's own `id` minus one and not from the order the
/// entries happen to appear in the XML.
pub index: usize,
/// Bambu's filament identifier, e.g. `GFG99`. The strongest signal for
/// matching, because it names the exact product.
pub tray_info_idx: String,
/// e.g. `PETG`.
pub filament_type: String,
/// Six uppercase hex digits, no `#`. Normalised on the way in, because
/// the project writes `#2850E0` and the printer reports `2850E0FF` - the
/// same colour with a trailing alpha byte.
pub color: String,
}
/// A tray the printer actually has filament in, flattened to a single slot
/// number.
///
/// Deliberately not `bambu_commands::LoadedTray`, which is the wire shape:
/// that carries an `id` scoped to its own AMS unit, so slot 2 of unit 1 and
/// slot 2 of unit 0 are both `"2"`. Flattening to a global slot is the
/// caller's job, because only the caller knows how many units there are.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AvailableTray {
/// The AMS slot number, as it appears in `ams_mapping`.
pub slot: i32,
pub tray_info_idx: String,
pub filament_type: String,
/// Same normalisation as `ProjectFilament::color`.
pub color: String,
}
#[derive(Debug, thiserror::Error)]
pub enum ProjectFileError {
#[error("the project archive could not be opened: {0}")]
NotAnArchive(String),
#[error("the archive has no {0} - it may not be a Bambu project")]
MissingEntry(String),
#[error("{0} could not be read: {1}")]
UnreadableEntry(String, String),
#[error("the archive describes no plate {0}")]
NoSuchPlate(u32),
#[error("plate {0} lists no filaments")]
NoFilaments(u32),
/// A `<filament>` element that could not be read. An error rather than a
/// skip: see the comment at the parse site.
#[error("plate {plate} has an unreadable filament: {detail}")]
MalformedFilament { plate: u32, detail: String },
/// Deliberately an error and never a silent fallback to the external
/// spool: an unmatched filament means the print would draw the wrong
/// material or none.
#[error("no loaded tray matches {wanted} (filament index {index}); loaded: {loaded}")]
NoMatchingTray { index: usize, wanted: String, loaded: String },
}
/// Normalises a colour to six uppercase hex digits.
///
/// Accepts `#2850E0`, `2850E0`, `2850E0FF` and lowercase, because the
/// project archive and the printer's own report disagree on all three
/// points.
///
/// Input with fewer than six hex digits comes back uppercased and stripped
/// but otherwise untouched. It will not equal any real colour, so the matcher
/// fails to match - the safe outcome - rather than this panicking.
pub fn normalise_color(raw: &str) -> String {
let stripped = raw.trim().trim_start_matches('#').to_ascii_uppercase();
let head: String = stripped.chars().take(6).collect();
if head.len() == 6 && head.chars().all(|c| c.is_ascii_hexdigit()) {
head
} else {
stripped
}
}
const SLICE_INFO: &str = "Metadata/slice_info.config";
/// Reads the filaments one plate was sliced for out of a `.3mf`.
///
/// `plate_index` is 1-based, matching the archive's own numbering
/// (`Metadata/plate_1.gcode`, `<metadata key="index" value="1"/>`).
///
/// There is no XML crate in the dependency tree, so this scans the text.
/// That is tolerable only because `slice_info.config` is machine-written by
/// one program in a stable shape; the scanning is still defensive (tag names
/// must be followed by whitespace, attribute names must start at a word
/// boundary) so that a neighbouring element cannot be mistaken for the one
/// we want. Anything unexpected yields a typed error, never a guess.
pub fn plate_filaments(
archive: &[u8],
plate_index: u32,
) -> Result<Vec<ProjectFilament>, ProjectFileError> {
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(archive))
.map_err(|e| ProjectFileError::NotAnArchive(e.to_string()))?;
let mut entry = zip
.by_name(SLICE_INFO)
.map_err(|_| ProjectFileError::MissingEntry(SLICE_INFO.to_string()))?;
let mut xml = String::new();
entry
.read_to_string(&mut xml)
.map_err(|e| ProjectFileError::UnreadableEntry(SLICE_INFO.to_string(), e.to_string()))?;
let block = plate_blocks(&xml)
.into_iter()
.find(|block| plate_number(block) == Some(plate_index))
.ok_or(ProjectFileError::NoSuchPlate(plate_index))?;
// Every `<filament>` must parse. A malformed one used to be dropped
// here, which is the worst available outcome: a dropped filament leaves
// a hole at its index, `map_to_trays` fills the hole with `-1`, and the
// printer is told that material is unused - so the plate prints in
// whatever remains mapped. Refusing is the only safe answer, because
// there is nothing to fall back to that is not a guess about what
// someone meant to print.
let mut filaments: Vec<ProjectFilament> = tags(block, "filament")
.into_iter()
.map(|tag| {
let missing = |what: &str| ProjectFileError::MalformedFilament {
plate: plate_index,
detail: format!("{what} in <filament {}>", tag.trim()),
};
// Ids are 1-based, so 0 or a non-number would underflow into a
// bogus position.
let raw_id = attr(tag, "id").ok_or_else(|| missing("no id"))?;
let id: usize = raw_id.parse().map_err(|_| missing("id is not a number"))?;
let index = id.checked_sub(1).ok_or_else(|| missing("id is 0, but ids are 1-based"))?;
Ok(ProjectFilament {
index,
tray_info_idx: attr(tag, "tray_info_idx")
.ok_or_else(|| missing("no tray_info_idx"))?
.to_string(),
filament_type: attr(tag, "type").ok_or_else(|| missing("no type"))?.to_string(),
color: normalise_color(attr(tag, "color").ok_or_else(|| missing("no color"))?),
})
})
.collect::<Result<_, ProjectFileError>>()?;
filaments.sort_by_key(|f| f.index);
if filaments.is_empty() {
return Err(ProjectFileError::NoFilaments(plate_index));
}
Ok(filaments)
}
/// Each `<plate>` element's text, up to the start of the next one.
fn plate_blocks(xml: &str) -> Vec<&str> {
xml.split("<plate")
.skip(1)
// `<plate_x` would be a different element; `</plate>` never matches
// the split at all because its `<` is followed by `/`.
.filter(|rest| rest.starts_with(|c: char| c == '>' || c.is_whitespace()))
.collect()
}
fn plate_number(block: &str) -> Option<u32> {
tags(block, "metadata")
.into_iter()
.find(|tag| attr(tag, "key") == Some("index"))
.and_then(|tag| attr(tag, "value")?.parse().ok())
}
/// Every `<name ...>` start tag in `text`, without the angle brackets.
/// Requires whitespace after the name so `filament` does not match
/// `<filament_settings>`.
fn tags<'a>(text: &'a str, name: &str) -> Vec<&'a str> {
let open = format!("<{name}");
let mut found = Vec::new();
let mut rest = text;
while let Some(at) = rest.find(&open) {
let after = &rest[at + open.len()..];
let Some(end) = after.find('>') else { break };
if after.starts_with(char::is_whitespace) {
found.push(&after[..end]);
}
rest = &after[end..];
}
found
}
/// The value of attribute `name` in a start tag.
///
/// The name must begin right after whitespace, so asking for `id` cannot
/// land inside `identify_id` or `group_id`, and asking for `type` cannot
/// land inside `volume_type`. Whitespace before the `=` is tolerated
/// (`error_code ="..."` occurs in real files).
fn attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
let mut from = 0;
while let Some(rel) = tag[from..].find(name) {
let at = from + rel;
from = at + name.len();
let at_boundary = tag[..at].ends_with(char::is_whitespace);
let after = tag[from..].trim_start();
if at_boundary && let Some(value) = after.strip_prefix('=') {
let value = value.trim_start().strip_prefix('"')?;
return value.split('"').next();
}
}
None
}
/// Builds the `ams_mapping` body for a plate: position = project filament
/// index, value = the AMS slot to draw it from.
///
/// Unused positions are `-1`. The caller pads to the printer's full tray
/// count - see `FilamentSource::ams`.
///
/// Each filament takes the best tray by this order:
/// 1. same `tray_info_idx` and same colour - the exact product, as sliced.
/// 2. same `tray_info_idx`, different colour - a deliberate "right product,
/// wrong colour" allowance.
/// 3. same `filament_type` and same colour - "right material", a different
/// product.
///
/// Tiers 2 and 3 are better than refusing a print, but they are not what the
/// slicer asked for, so a caller may want to warn. Ties within a tier go to
/// the lowest slot so the result never depends on tray ordering. A filament
/// matching no tier is an error, never a fallback.
pub fn map_to_trays(
filaments: &[ProjectFilament],
trays: &[AvailableTray],
) -> Result<Vec<i32>, ProjectFileError> {
let Some(max) = filaments.iter().map(|f| f.index).max() else {
return Ok(Vec::new());
};
let mut mapping = vec![-1; max + 1];
for filament in filaments {
let best = trays
.iter()
.filter_map(|tray| Some((tier(filament, tray)?, tray.slot)))
.min();
let Some((_, slot)) = best else {
let loaded = trays
.iter()
.map(|t| format!("slot {}: {} {} #{}", t.slot, t.filament_type, t.tray_info_idx, t.color))
.collect::<Vec<_>>()
.join(", ");
return Err(ProjectFileError::NoMatchingTray {
index: filament.index,
wanted: format!(
"{} {} #{}",
filament.filament_type, filament.tray_info_idx, filament.color
),
loaded,
});
};
mapping[filament.index] = slot;
}
Ok(mapping)
}
/// Match quality, lower is better; `None` means no match.
fn tier(filament: &ProjectFilament, tray: &AvailableTray) -> Option<u8> {
let same_product = filament.tray_info_idx.eq_ignore_ascii_case(&tray.tray_info_idx);
let same_color = filament.color == tray.color;
let same_type = filament.filament_type.eq_ignore_ascii_case(&tray.filament_type);
match (same_product, same_color, same_type) {
(true, true, _) => Some(1),
(true, false, _) => Some(2),
(false, true, true) => Some(3),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
const REAL: &str = r##"<?xml version="1.0" encoding="UTF-8"?>
<config>
<header>
<header_item key="X-BBL-Client-Type" value="slicer"/>
</header>
<plate>
<metadata key="index" value="1"/>
<metadata key="nozzle_diameters" value="0.4"/>
<metadata key="prediction" value="2408"/>
<object identify_id="69" name="Bambu Cube" skipped="false" />
<filament id="1" tray_info_idx="GFG99" type="PETG" color="#2850E0" used_m="2.57" used_g="7.86" group_id="0" nozzle_diameter="0.40" volume_type="Standard" used_for_object="true" used_for_support="false"/>
<warning msg="bed_temperature_too_high_than_filament" level="3" error_code ="1000C001" />
<nozzle id="0" extruder_id="1" nozzle_diameter="0.4" volume_type="Standard"/>
</plate>
</config>
"##;
const TWO_PLATES: &str = r##"<config>
<plate>
<metadata key="index" value="1"/>
<filament id="1" tray_info_idx="GFA00" type="PLA" color="#FF0000" group_id="0"/>
</plate>
<plate>
<metadata key="index" value="2"/>
<filament id="2" tray_info_idx="GFB00" type="ABS" color="#00ff00" group_id="1"/>
<filament id="1" tray_info_idx="GFG99" type="PETG" color="#0000FFFF" group_id="0"/>
</plate>
</config>
"##;
fn zip_with(name: &str, body: &str) -> Vec<u8> {
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let opts = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored);
w.start_file(name, opts).unwrap();
w.write_all(body.as_bytes()).unwrap();
w.finish().unwrap().into_inner()
}
fn filament(index: usize, idx: &str, ty: &str, color: &str) -> ProjectFilament {
ProjectFilament {
index,
tray_info_idx: idx.into(),
filament_type: ty.into(),
color: color.into(),
}
}
fn tray(slot: i32, idx: &str, ty: &str, color: &str) -> AvailableTray {
AvailableTray {
slot,
tray_info_idx: idx.into(),
filament_type: ty.into(),
color: color.into(),
}
}
#[test]
fn normalises_every_spelling() {
assert_eq!(normalise_color("#2850E0"), "2850E0");
assert_eq!(normalise_color("2850e0"), "2850E0");
assert_eq!(normalise_color("2850E0FF"), "2850E0");
assert_eq!(normalise_color("#2850e0ff"), "2850E0");
}
#[test]
fn short_or_garbage_colour_does_not_panic() {
assert_eq!(normalise_color("#abc"), "ABC");
assert_eq!(normalise_color(""), "");
assert_eq!(normalise_color("zzzzzzzz"), "ZZZZZZZZ");
}
#[test]
fn reads_the_real_single_filament_plate() {
let got = plate_filaments(&zip_with(SLICE_INFO, REAL), 1).unwrap();
assert_eq!(got, vec![filament(0, "GFG99", "PETG", "2850E0")]);
}
#[test]
fn picks_the_right_plate_and_converts_ids() {
let archive = zip_with(SLICE_INFO, TWO_PLATES);
let got = plate_filaments(&archive, 2).unwrap();
assert_eq!(
got,
vec![
filament(0, "GFG99", "PETG", "0000FF"),
filament(1, "GFB00", "ABS", "00FF00"),
]
);
let first = plate_filaments(&archive, 1).unwrap();
assert_eq!(first, vec![filament(0, "GFA00", "PLA", "FF0000")]);
}
#[test]
fn archive_errors() {
assert!(matches!(
plate_filaments(b"not a zip", 1),
Err(ProjectFileError::NotAnArchive(_))
));
assert!(matches!(
plate_filaments(&zip_with("other.txt", "x"), 1),
Err(ProjectFileError::MissingEntry(_))
));
assert!(matches!(
plate_filaments(&zip_with(SLICE_INFO, REAL), 7),
Err(ProjectFileError::NoSuchPlate(7))
));
}
#[test]
fn plate_without_filaments_is_an_error() {
let xml = r#"<config><plate><metadata key="index" value="1"/></plate></config>"#;
assert!(matches!(
plate_filaments(&zip_with(SLICE_INFO, xml), 1),
Err(ProjectFileError::NoFilaments(1))
));
}
#[test]
fn attr_does_not_match_longer_names() {
let tag = r#"filament group_id="9" identify_id="8" volume_type="Std" id="1" type="PLA""#;
assert_eq!(attr(tag, "id"), Some("1"));
assert_eq!(attr(tag, "type"), Some("PLA"));
assert_eq!(attr(r#"x error_code ="E1""#, "error_code"), Some("E1"));
}
#[test]
fn same_product_different_colour_picks_the_colour_match() {
let filaments = [filament(0, "GFG99", "PETG", "2850E0")];
let trays = [
tray(2, "GFG99", "PETG", "161616"),
tray(3, "GFG99", "PETG", "2850E0"),
];
assert_eq!(map_to_trays(&filaments, &trays).unwrap(), vec![3]);
}
#[test]
fn tiers_and_tie_break() {
// Tier 2 (right product, wrong colour) beats nothing, lowest slot wins.
let f = [filament(0, "GFG99", "PETG", "2850E0")];
let t = [tray(5, "GFG99", "PETG", "111111"), tray(1, "gfg99", "PETG", "222222")];
assert_eq!(map_to_trays(&f, &t).unwrap(), vec![1]);
// Tier 3 (right material and colour, different product).
let t = [tray(4, "GFA00", "petg", "2850E0")];
assert_eq!(map_to_trays(&f, &t).unwrap(), vec![4]);
// Tier 1 outranks a lower-numbered tier 2.
let t = [tray(0, "GFG99", "PETG", "111111"), tray(6, "GFG99", "PETG", "2850E0")];
assert_eq!(map_to_trays(&f, &t).unwrap(), vec![6]);
}
#[test]
fn unmatched_filament_is_an_error() {
let f = [filament(0, "GFG99", "PETG", "2850E0")];
let t = [tray(1, "GFA00", "PLA", "FF0000")];
match map_to_trays(&f, &t) {
Err(ProjectFileError::NoMatchingTray { index, wanted, loaded }) => {
assert_eq!(index, 0);
assert_eq!(wanted, "PETG GFG99 #2850E0");
assert_eq!(loaded, "slot 1: PLA GFA00 #FF0000");
}
other => panic!("expected NoMatchingTray, got {other:?}"),
}
}
#[test]
fn gaps_become_minus_one() {
let f = [filament(0, "GFA00", "PLA", "FF0000"), filament(2, "GFG99", "PETG", "2850E0")];
let t = [tray(1, "GFA00", "PLA", "FF0000"), tray(3, "GFG99", "PETG", "2850E0")];
assert_eq!(map_to_trays(&f, &t).unwrap(), vec![1, -1, 3]);
}
}
/// Tests against a real project, not just hand-built XML.
///
/// `tests/fixtures/cube_slice_info.3mf` holds the `slice_info.config` of a
/// project read off a P1P over FTPS on 2026-10-11 - the one that exposed the
/// `use_ams: false` bug - re-zipped with **deflate**. The compression matters
/// as much as the content: every other test here builds a `Stored` archive,
/// so without this the decompression path (and the flate2 backend wired up
/// behind `zip`'s `deflate-flate2` feature) is never exercised, and a real
/// archive is always deflated.
#[cfg(test)]
mod real_project_tests {
use super::*;
const REAL_PROJECT: &[u8] = include_bytes!("../../tests/fixtures/cube_slice_info.3mf");
#[test]
fn reads_a_deflated_archive_from_a_real_printer() {
let filaments = plate_filaments(REAL_PROJECT, 1).expect("the real project should parse");
assert_eq!(filaments.len(), 1, "the cube is a single-filament plate");
assert_eq!(filaments[0].index, 0, "id=\"1\" is 1-based, so index 0");
assert_eq!(filaments[0].tray_info_idx, "GFG99");
assert_eq!(filaments[0].filament_type, "PETG");
// `#2850E0` in the project; the printer calls the same colour
// `2850E0FF`.
assert_eq!(filaments[0].color, "2850E0");
}
/// End to end on the exact case that cost a print: both AMS slots hold
/// the same product (PETG, GFG99) and only the colour distinguishes
/// them, so a matcher that stopped at `tray_info_idx` would pick slot 2
/// and print the cube in black.
#[test]
fn the_real_project_maps_to_the_slot_it_was_sliced_for() {
let filaments = plate_filaments(REAL_PROJECT, 1).expect("should parse");
let trays = vec![
AvailableTray {
slot: 2,
tray_info_idx: "GFG99".into(),
filament_type: "PETG".into(),
color: "161616".into(),
},
AvailableTray {
slot: 3,
tray_info_idx: "GFG99".into(),
filament_type: "PETG".into(),
color: "2850E0".into(),
},
];
assert_eq!(map_to_trays(&filaments, &trays).expect("should match"), vec![3]);
}
#[test]
fn the_real_project_has_no_second_plate() {
assert!(matches!(
plate_filaments(REAL_PROJECT, 2),
Err(ProjectFileError::NoSuchPlate(2))
));
}
}
Binary file not shown.