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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 22:35:51 +00:00
co-authored by Claude Opus 5
parent 79df3e7153
commit 7d68e8e069
4 changed files with 278 additions and 11 deletions
+49 -4
View File
@@ -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::<i32>())
.collect::<Result<Vec<_>, _>>()
.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 <path> <dest>` 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}'"),
}
+190 -6
View File
@@ -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());
}
}
+36
View File
@@ -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
View File
@@ -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,