From 431dd849da234903d7715838252590021ac560c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iwo=20Strzebo=C5=84ski?= Date: Sun, 11 Oct 2026 22:35:51 +0000 Subject: [PATCH] Make the filament source explicit, and print from the AMS `start_uploaded_file` hardcoded `use_ams: false` / `ams_mapping: ""`, which silently overrode whatever the slicer had chosen. On a P1P with an AMS and a project sliced for it, that is not a missing feature - it points the print at the external spool. Confirmed on 2026-10-11: the print started, heated to 250C, reached 1%, then the printer paused itself with `print_error: 50364420` and `tray_now: 255`. Nothing in the command's reply said so; `result` was `success`, because the command had been accepted. So `FilamentSource` is now a required argument with no default, because there is no safe default - picking wrong draws the wrong filament or none. The `ExternalSpool` variant sends the empty *string* the schema's trailing `oneOf` demands, and `ams` takes slots per project filament index and pads to every tray the printer has, per the padding rule in `schemas/bambulab/v1/common/ams/ams_mapping.schema.json`. It refuses an empty mapping (padding `[]` would mean `use_ams: true` with nothing mapped), one longer than the printer has trays, and any negative that is not -1. Confirmed live with the real mapping: `ams_mapping: [3,-1,-1,-1,-1]`, `use_ams: true` took the printer to RUNNING, `tray_now` became 3 - the slot the project was sliced for - and it ran past 1% to 13% with `print_error: 0`, where the external-spool attempt had paused. Finding the mapping needed reading the project back off the card, so FTPS gains `download`/`download_file` and the probe a `fetch` step. The archive's `Metadata/slice_info.config` lists each plate's filaments with `tray_info_idx` and colour - here one PETG GFG99 at `#2850E0`, matching AMS slot 3's `2850E0FF` - and `Metadata/plate_1.gcode` is present, which also confirms the hardcoded `param`. `upload_project_file` is left on the external spool and is the one remaining place that can pick wrong, because it is vendor-generic and an AMS mapping is a Bambu concept. It is unreachable - there is no start-print command on the uplink - and the fix is for it to derive the mapping from the `.3mf` it already holds, which needs a zip reader and so a dependency decision. --- examples/bambu_print_probe.rs | 53 ++++++++- src/printer/bambu.rs | 196 ++++++++++++++++++++++++++++++++-- src/printer/bambu_ftps.rs | 36 +++++++ src/printer/mod.rs | 4 +- 4 files changed, 278 insertions(+), 11 deletions(-) diff --git a/examples/bambu_print_probe.rs b/examples/bambu_print_probe.rs index 8d4ead7..971fad4 100644 --- a/examples/bambu_print_probe.rs +++ b/examples/bambu_print_probe.rs @@ -34,7 +34,7 @@ use std::time::Duration; use continuum_proxy::fleet; -use continuum_proxy::printer::{GenericPrinter, PrintControl, PrinterHandle}; +use continuum_proxy::printer::{FilamentSource, GenericPrinter, PrintControl, PrinterHandle}; const CONNECT_TIMEOUT: Duration = Duration::from_secs(20); const STATUS_TIMEOUT: Duration = Duration::from_secs(15); @@ -44,6 +44,10 @@ const WATCH: Duration = Duration::from_secs(20); /// A file name that cannot exist in `cache/`, for `probe-missing`. const MISSING_FILE: &str = "continuum-probe-no-such-file.gcode"; +/// Every tray a P1P with one AMS has: 4 AMS slots plus the external spool. +/// `ams_mapping` has to be padded to this length - see the schema. +const P1P_TRAY_COUNT: usize = 5; + #[tokio::main] async fn main() -> anyhow::Result<()> { // Without this there is no subscriber, so the `trace!` of every raw @@ -108,7 +112,10 @@ async fn main() -> anyhow::Result<()> { // whether the command shape is understood. println!("\n sending print.gcode_file for a file that does not exist:"); println!(" param -> /cache/{MISSING_FILE}"); - match bambu.start_uploaded_file(MISSING_FILE).await { + match bambu + .start_uploaded_file(MISSING_FILE, &FilamentSource::ExternalSpool) + .await + { Ok(()) => println!(" the printer ACCEPTED it (result: success)"), Err(err) => anyhow::bail!("the printer answered: {err}"), } @@ -139,8 +146,32 @@ async fn main() -> anyhow::Result<()> { This commits the machine to physical work. Be at the printer." ); } - println!("\n starting print of '{file}' ..."); - match bambu.start_uploaded_file(&file).await { + // `--ams=3` or `--ams=3,-1,0` picks AMS slots per project + // filament index; without it, the external spool. There is no + // default that is safe for both, so the flag has to be explicit. + let filament = match args.iter().find_map(|a| a.strip_prefix("--ams=")) { + Some(list) => { + let slots = list + .split(',') + .map(|slot| slot.trim().parse::()) + .collect::, _>>() + .map_err(|err| anyhow::anyhow!("--ams needs a comma-separated list of slot numbers: {err}"))?; + // 4 AMS slots + 1 external spool on a P1P with one AMS. + // See the padding rule in the ams_mapping schema. + FilamentSource::ams(slots, P1P_TRAY_COUNT) + .map_err(|err| anyhow::anyhow!("{err}"))? + } + None => FilamentSource::ExternalSpool, + }; + match &filament { + FilamentSource::ExternalSpool => { + println!("\n starting print of '{file}' from the EXTERNAL SPOOL ...") + } + FilamentSource::Ams(slots) => { + println!("\n starting print of '{file}' with ams_mapping {slots:?} ...") + } + } + match bambu.start_uploaded_file(&file, &filament).await { Ok(()) => println!(" accepted"), Err(err) => anyhow::bail!("the printer answered: {err}"), } @@ -175,6 +206,20 @@ async fn main() -> anyhow::Result<()> { } } + // `fetch ` copies one file off the card. Read-only. + "fetch" => { + let (Some(path), Some(dest)) = (args.get(3), args.get(4)) else { + anyhow::bail!("`fetch` needs a path on the printer and a local destination"); + }; + match bambu.download_file(path).await { + Ok(bytes) => { + std::fs::write(dest, &bytes)?; + println!(" {} bytes -> {dest}", bytes.len()); + } + Err(err) => anyhow::bail!("fetch failed: {err}"), + } + } + other => anyhow::bail!("unknown step '{other}'"), } diff --git a/src/printer/bambu.rs b/src/printer/bambu.rs index b22c960..ab49680 100644 --- a/src/printer/bambu.rs +++ b/src/printer/bambu.rs @@ -226,6 +226,82 @@ pub struct BambuPrinter { connection: Option<(super::bambu_mqtt::BambuMqttSession, BambuProtocolVersion)>, } +/// Where a project print draws its filament from. +/// +/// This exists because the alternative - hardcoding `use_ams: false` - +/// silently overrode the slicer's own choice. Confirmed on a P1P +/// (2026-10-11): a project sliced for AMS slot 3 started, heated to +/// 250C, reached 1% and then the printer paused itself with +/// `print_error: 50364420` and `tray_now: 255`, the external spool. The +/// print was never going to work, and nothing in the command's reply +/// said so - `result` was `success`, because the command was accepted. +/// +/// So this is a required argument rather than an option with a default. +/// There is no safe default: picking the wrong source draws the wrong +/// filament or none at all. +pub enum FilamentSource { + /// The external spool (`vt_tray`). `use_ams: false`, and + /// `ams_mapping` must be the empty *string* - the schema's trailing + /// `oneOf` ties those two together. + ExternalSpool, + /// The AMS, with a mapping from the project's filament indices to + /// AMS slots. Build it with `FilamentSource::ams`. + Ams(Vec), +} + +impl FilamentSource { + /// `slots[i]` is the AMS slot the project's filament index `i` should + /// draw from, or `-1` for an index this project does not use. + /// + /// `tray_count` is every tray the printer has, AMS slots *and* external + /// spools - 5 for a P1P with one AMS. The mapping is padded to that + /// length with `-1`, because + /// `schemas/bambulab/v1/common/ams/ams_mapping.schema.json` says to + /// always pad it and an under-length array is one of the ways this goes + /// wrong quietly. + pub fn ams(slots: impl Into>, tray_count: usize) -> Result { + let mut slots: Vec = slots.into(); + + if slots.is_empty() { + return Err(PrinterError( + "an AMS mapping needs at least one filament index; use ExternalSpool instead".into(), + )); + } + if slots.len() > tray_count { + return Err(PrinterError(format!( + "an AMS mapping of {} entries cannot fit a printer with {tray_count} trays", + slots.len() + ))); + } + if let Some(bad) = slots.iter().find(|slot| **slot < -1) { + return Err(PrinterError(format!( + "{bad} is not a valid AMS slot: use -1 for an unused filament index" + ))); + } + + slots.resize(tray_count, -1); + Ok(Self::Ams(slots)) + } + + /// Writes `use_ams` and `ams_mapping` into a `print.project_file` body. + /// + /// The two always move together: a real mapping requires + /// `use_ams: true`, and the no-AMS case requires `ams_mapping` to be the + /// empty string rather than an empty array. + fn apply(&self, body: &mut serde_json::Value) { + match self { + Self::ExternalSpool => { + body["use_ams"] = serde_json::Value::Bool(false); + body["ams_mapping"] = serde_json::Value::String(String::new()); + } + Self::Ams(slots) => { + body["use_ams"] = serde_json::Value::Bool(true); + body["ams_mapping"] = serde_json::json!(slots); + } + } + } +} + impl BambuPrinter { pub fn new( id: &str, @@ -541,6 +617,30 @@ impl BambuPrinter { self.list_dir(super::bambu_ftps::CACHE_DIR).await } + /// Reads one file back off the printer's SD card. + /// + /// Read-only, and the counterpart to `upload_file`. Needed because a + /// `.3mf` sitting on the card is the only record of which filament its + /// plates were sliced for, which is what `ams_mapping` has to agree + /// with. + pub async fn download_file(&self, path: &str) -> Result, PrinterError> { + let connector = self + .tls + .build_connector() + .map_err(|err| PrinterError(format!("{}: TLS setup failed: {err}", self.base.name)))?; + + let host = self.base.host.clone(); + let access_code = self.access_code.clone(); + let path = path.to_string(); + + tokio::task::spawn_blocking(move || { + super::bambu_ftps::download(&host, &access_code, connector, &path) + }) + .await + .map_err(|err| PrinterError(format!("{}: FTPS download thread failed: {err}", self.base.name)))? + .map_err(|err| PrinterError(format!("{}: FTPS download failed: {err}", self.base.name))) + } + /// `list_cache` for any directory on the printer's SD card. /// /// Read-only, and the way to answer "what is the path to this file?" @@ -726,7 +826,11 @@ impl BambuPrinter { /// check which the printer accepts before relying on that field, and /// fix continuum-schemas if it's wrong. The confirmed body above sets /// neither, so this is still open. - pub async fn start_uploaded_file(&self, file_name: &str) -> Result<(), PrinterError> { + pub async fn start_uploaded_file( + &self, + file_name: &str, + filament: &FilamentSource, + ) -> Result<(), PrinterError> { let (session, version) = self.connection.as_ref().ok_or_else(|| PrinterError(format!("{} is not connected", self.base.name)))?; @@ -755,10 +859,9 @@ impl BambuPrinter { } body["subtask_name"] = serde_json::Value::String(String::new()); - // See point 3 in this method's doc comment - these two are a - // pair, not independent switches. - body["use_ams"] = serde_json::Value::Bool(false); - body["ams_mapping"] = serde_json::Value::String(String::new()); + // These two are a pair, not independent switches - see + // `FilamentSource::apply`. + filament.apply(&mut body); } else { // The root, for the same reason: it is where `upload_file` puts // things. Unverified - see this method's doc comment, which @@ -917,7 +1020,21 @@ impl GenericPrinter for BambuPrinter { super::bambu_ftps::CACHE_DIR ); - self.start_uploaded_file(&file_name).await + // 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 } /// Builds the real JSON payload straight from continuum-schemas' @@ -943,3 +1060,70 @@ impl GenericPrinter for BambuPrinter { session.publish("print", &body).await } } + +#[cfg(test)] +mod tests { + use super::*; + + /// The padding rule from + /// `schemas/bambulab/v1/common/ams/ams_mapping.schema.json`: always pad + /// to every tray the printer has. An under-length array is one of the + /// ways a mapping goes wrong quietly. + #[test] + fn an_ams_mapping_is_padded_to_the_tray_count() { + let FilamentSource::Ams(slots) = FilamentSource::ams(vec![3], 5).expect("valid") else { + panic!("expected an AMS source"); + }; + assert_eq!(slots, vec![3, -1, -1, -1, -1]); + } + + #[test] + fn an_already_full_length_mapping_is_left_alone() { + let FilamentSource::Ams(slots) = + FilamentSource::ams(vec![3, -1, 0, -1, -1], 5).expect("valid") + else { + panic!("expected an AMS source"); + }; + assert_eq!(slots, vec![3, -1, 0, -1, -1]); + } + + #[test] + fn an_empty_mapping_is_refused_rather_than_padded_into_nonsense() { + // Padding `[]` would produce `[-1,-1,-1,-1,-1]`: `use_ams: true` with + // every filament unmapped, which is not a thing to send a printer. + assert!(FilamentSource::ams(Vec::new(), 5).is_err()); + } + + #[test] + fn a_mapping_longer_than_the_printer_has_trays_is_refused() { + assert!(FilamentSource::ams(vec![0, 1, 2, 3, 0, 1], 5).is_err()); + } + + #[test] + fn minus_one_is_the_only_negative_slot_allowed() { + assert!(FilamentSource::ams(vec![-1], 5).is_ok()); + assert!(FilamentSource::ams(vec![-2], 5).is_err()); + } + + /// The pair that cost a real print: `use_ams` and `ams_mapping` have to + /// move together, and the no-AMS case needs the empty *string* rather + /// than an empty array - the schema's trailing `oneOf` says so. + #[test] + fn the_external_spool_sends_an_empty_string_not_an_empty_array() { + let mut body = serde_json::json!({}); + FilamentSource::ExternalSpool.apply(&mut body); + assert_eq!(body["use_ams"], serde_json::json!(false)); + assert_eq!(body["ams_mapping"], serde_json::json!("")); + } + + #[test] + fn an_ams_source_sends_an_array_and_turns_use_ams_on() { + let mut body = serde_json::json!({}); + FilamentSource::ams(vec![3], 5).expect("valid").apply(&mut body); + assert_eq!(body["use_ams"], serde_json::json!(true)); + assert_eq!(body["ams_mapping"], serde_json::json!([3, -1, -1, -1, -1])); + // Never the external-spool spelling while use_ams is on: that + // combination is what silently printed from the wrong source. + assert!(!body["ams_mapping"].is_string()); + } +} diff --git a/src/printer/bambu_ftps.rs b/src/printer/bambu_ftps.rs index 9775517..4de31e3 100644 --- a/src/printer/bambu_ftps.rs +++ b/src/printer/bambu_ftps.rs @@ -116,6 +116,42 @@ pub fn upload( Ok(written) } +/// Blocking: reads one file off the printer's SD card. +/// +/// Read-only. The reason this exists: a `.3mf` already on the card is the +/// only record of which filament each of its plates was sliced for, and +/// `ams_mapping` cannot be built without that - see +/// `schemas/bambulab/v1/common/ams/ams_mapping.schema.json`. Printing a +/// project with the wrong mapping is not a cosmetic error; it draws the +/// wrong filament, or none. +pub fn download( + host: &str, + access_code: &str, + connector: native_tls::TlsConnector, + path: &str, +) -> anyhow::Result> { + let mut ftp = NativeTlsFtpStream::connect_secure_implicit( + (host, FTPS_PORT), + NativeTlsConnector::from(connector), + host, + )?; + + ftp.login(FTPS_USER, access_code)?; + // A `.3mf` is a zip archive; letting the server treat it as text is free + // corruption, exactly as on the way up. + ftp.transfer_type(FileType::Binary)?; + + // `retr_as_buffer` rather than `retr_as_stream`: suppaftp 12's + // `TransferStream` finalizes itself on drop, and the files being read + // here are project archives of a few hundred KB, so there is nothing to + // gain from streaming them. + let bytes = ftp.retr_as_buffer(path)?.into_inner(); + + let _ = ftp.quit(); + + Ok(bytes) +} + /// Blocking: lists `cache/` on the printer. /// /// Read-only, and the counterpart to `upload`. The previous version of this diff --git a/src/printer/mod.rs b/src/printer/mod.rs index 177953a..3bf72c3 100644 --- a/src/printer/mod.rs +++ b/src/printer/mod.rs @@ -35,7 +35,9 @@ mod klipper; mod marlin_serial; mod prusa; -pub use bambu::{BambuPrinter, BambuProtocolPreference, BambuProtocolVersion, BambuTls}; +pub use bambu::{ + BambuPrinter, BambuProtocolPreference, BambuProtocolVersion, BambuTls, FilamentSource, +}; pub use bambu_commands::{ AmsState, AmsTray, AmsUnit, BambuReport, BambuRequest, CommandEnvelope, EmptyTray, GetVersionRequest, GetVersionResponse, LoadedTray, ModuleInfo, PrintReport, PushallRequest, XcamSettings,