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.
This commit is contained in:
+190
-6
@@ -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<i32>),
|
||||
}
|
||||
|
||||
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<Vec<i32>>, tray_count: usize) -> Result<Self, PrinterError> {
|
||||
let mut slots: Vec<i32> = 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<Vec<u8>, 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Vec<u8>> {
|
||||
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
|
||||
|
||||
+3
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user