From 6ff0c9a649d5c499538aa2675bf581f3d88f65d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iwo=20Strzebo=C5=84ski?= Date: Thu, 15 Oct 2026 21:27:24 +0000 Subject: [PATCH] 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 `` 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. Co-Authored-By: Claude Opus 5 --- Cargo.toml | 2 + src/completion.rs | 249 ++++++++++++++ src/lib.rs | 1 + src/main.rs | 25 +- src/printer/bambu.rs | 302 +++++++++++++++-- src/printer/mod.rs | 9 + src/printer/project_file.rs | 523 +++++++++++++++++++++++++++++ tests/fixtures/cube_slice_info.3mf | Bin 0 -> 748 bytes 8 files changed, 1075 insertions(+), 36 deletions(-) create mode 100644 src/completion.rs create mode 100644 src/printer/project_file.rs create mode 100644 tests/fixtures/cube_slice_info.3mf diff --git a/Cargo.toml b/Cargo.toml index 7dbc706..5e481cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/completion.rs b/src/completion.rs new file mode 100644 index 0000000..0e645ec --- /dev/null +++ b/src/completion.rs @@ -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, +} + +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(&mut self, states: I) -> Vec + where + I: IntoIterator, + S: AsRef, + { + 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); + } +} diff --git a/src/lib.rs b/src/lib.rs index 1f9c07e..7694c2b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/main.rs b/src/main.rs index f57fb09..7053fc8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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(()) diff --git a/src/printer/bambu.rs b/src/printer/bambu.rs index ab49680..6748207 100644 --- a/src/printer/bambu.rs +++ b/src/printer/bambu.rs @@ -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, 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::() 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 { + 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, ) -> 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!({}); diff --git a/src/printer/mod.rs b/src/printer/mod.rs index 3bf72c3..eed40ee 100644 --- a/src/printer/mod.rs +++ b/src/printer/mod.rs @@ -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, diff --git a/src/printer/project_file.rs b/src/printer/project_file.rs new file mode 100644 index 0000000..ca07ef5 --- /dev/null +++ b/src/printer/project_file.rs @@ -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 `` 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`, ``). +/// +/// 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, 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 `` 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 = tags(block, "filament") + .into_iter() + .map(|tag| { + let missing = |what: &str| ProjectFileError::MalformedFilament { + plate: plate_index, + detail: format!("{what} in ", 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::>()?; + filaments.sort_by_key(|f| f.index); + + if filaments.is_empty() { + return Err(ProjectFileError::NoFilaments(plate_index)); + } + Ok(filaments) +} + +/// Each `` element's text, up to the start of the next one. +fn plate_blocks(xml: &str) -> Vec<&str> { + xml.split("` 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 { + tags(block, "metadata") + .into_iter() + .find(|tag| attr(tag, "key") == Some("index")) + .and_then(|tag| attr(tag, "value")?.parse().ok()) +} + +/// Every `` start tag in `text`, without the angle brackets. +/// Requires whitespace after the name so `filament` does not match +/// ``. +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, 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::>() + .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 { + 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##" + +
+ +
+ + + + + + + + + + +"##; + + const TWO_PLATES: &str = r##" + + + + + + + + + + +"##; + + fn zip_with(name: &str, body: &str) -> Vec { + 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#""#; + 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)) + )); + } +} diff --git a/tests/fixtures/cube_slice_info.3mf b/tests/fixtures/cube_slice_info.3mf new file mode 100644 index 0000000000000000000000000000000000000000..ab69df2cb971563546346c18644063898bfea6c4 GIT binary patch literal 748 zcmWIWW@Zs#U|`^2IK9e0_B5-=+b|{u1_w3<1}UJZZ)!)g=a_=XdnH7^O_nyOZ zn}W)Y%jdrx2yJNEbI@>UJzMwD?+1gl4H7q4O>;Qu_kt_v)z(u7E+*W&B*U-zv}Mth!2oD#*7QNqpP)TNLWn5rTZHFZbtKS|d12dje39$F<{Yc%`zHIYNzJSbjhTar*4I_(;3 zT;+tl98qg7R_*@B9r+;PNYrQ7o?a~2da6S4RNSAnAH+A^Up*oBqvV&w zZ8|(p|63o464m-}d(LT&D{KN=Zn8|3FBA*5nj73?s+gzPRq8tJs?_WU_d_C^Y?gbR z<9g2V#HCl0({Dk~N!={jpkKc}S-J1M%(Cpu@z1ZOPCAqII_Ouf$?o*?I}-eCAG7*J zm;Apl$Ly@j#NN%JKPQSZ?cdYbfAje3)zhb6n(ru~IVY#3==ChM$JGU*F|p>#pRa!2 zTfnwmR?|jpqnl3N5}`R9Qx8jA`=rZf#~+;-Rjhe&1M}QVhI!u41EkH>1hzCAZcE{b z&3wBmTfx`$?Xt`>yF(Mh8`F>eO5NIf?G$@_{jR?gYpiOQ1TLOqApN1j@zKdYmmVaB zH>BLqxs_7!&;AE{fHxzP2s7^V2uzY-(7*_yaHq}yZ&qLmW?*0hLJuI_2Taop3;?CJ BNLT;> literal 0 HcmV?d00001