Add real FTPS upload to Bambu printers

`upload_project_file` was a `println!`. MQTT can start a print but can
only name a file the printer already has, so moving the bytes there is a
separate protocol: implicit FTPS on port 990, user `bblp`, password the
same access code MQTT uses, into the printer's own `cache/` directory.

Confirmed live against a P1P: 102 bytes uploaded, byte count matched
exactly, first attempt.

Deliberately suppaftp's *blocking* client on `tokio::task::spawn_blocking`
rather than its async one. The blocking client takes a plain
`native_tls::TlsConnector`, so this reuses `BambuTls::build_connector()`
unchanged and every TLS trust decision for a printer stays in that one
method; the async client needs a second TLS stack (`async-native-tls`)
whose futures-vs-tokio `AsyncRead` mismatch has already cost this project
a debugging session. `connect()`'s doc comment already pointed at
`spawn_blocking` as the eventual answer for blocking work, and a
multi-megabyte upload earns it more obviously than a handshake does.

`connect_secure_implicit` sits behind suppaftp's `deprecated` feature
because implicit FTPS is what the RFCs deprecated in favour of explicit
AUTH TLS. The printers only speak implicit, so the feature is on.

Split into `upload_file` (FTPS only) and `start_uploaded_file` (the MQTT
command that begins printing), because the two halves have very different
consequences: moving a file onto a printer is inert and reversible, while
starting a print commits the machine to hours of physical work. That split
is what made the transfer testable against real hardware at all.

`start_uploaded_file` dispatches on file type - a `.3mf` is a container so
`print.project_file` needs both a `url` and a `param` naming the gcode
inside it, while a bare `.gcode` has nothing to select and
`print.gcode_file` takes just a path. It has deliberately never been
fired; its doc comment lists the three guesses to confirm first.

suppaftp is MIT OR Apache-2.0, checked against docs/LICENSES.md before
being added, along with all 22 crates it pulled in.
This commit is contained in:
2026-10-03 10:11:36 +00:00
parent f3f6e21012
commit 0ab602ed79
4 changed files with 271 additions and 20 deletions
+13
View File
@@ -32,7 +32,13 @@ path = "examples/auto_connect_bambu.rs"
name = "bambu_commands_demo" name = "bambu_commands_demo"
path = "examples/bambu_commands_demo.rs" path = "examples/bambu_commands_demo.rs"
[[example]]
name = "live_reports"
path = "examples/live_reports.rs"
[[example]]
name = "bambu_idle_checks"
path = "examples/bambu_idle_checks.rs"
[dependencies] [dependencies]
# Real printer protocol clients (MQTT for Bambu, HTTP for PrusaLink/ # Real printer protocol clients (MQTT for Bambu, HTTP for PrusaLink/
@@ -62,3 +68,10 @@ thiserror = "1"
dotenvy = "0.15" dotenvy = "0.15"
tokio-serial = { version = "5.5.0", default-features = false } tokio-serial = { version = "5.5.0", default-features = false }
rumqttc = { version = "0.25.1", default-features = false, features = ["use-native-tls"] } rumqttc = { version = "0.25.1", default-features = false, features = ["use-native-tls"] }
# Blocking FTPS client for pushing project files to a printer's own
# storage (see src/printer/bambu_ftps.rs for why the blocking one, not
# the async one). `deprecated` is what gates `connect_secure_implicit`:
# Bambu speaks implicit FTPS on 990, which the RFCs deprecated in favour
# 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"] }
+162 -17
View File
@@ -454,14 +454,16 @@ impl BambuPrinter {
/// Call this periodically for v1 printers specifically - v2 has no /// Call this periodically for v1 printers specifically - v2 has no
/// `pushing` category at all (checked: `schemas/bambulab/v2/ /// `pushing` category at all (checked: `schemas/bambulab/v2/
/// request.schema.json` only wires camera/info/print/system/upgrade/ /// request.schema.json` only wires camera/info/print/system/upgrade/
/// xcam). Whether v2 needs some *other* trigger to get /// xcam). A v2 connection makes this call a no-op rather than an error.
/// `print.push_status` flowing at all is still an open question (a ///
/// live test meant to check that lost its connection entirely, an /// **Resolved 2026-10-03, live against a real H2C mid-print:** v2 needs
/// unrelated failure, not real evidence either way) - if it does, /// no trigger whatsoever. It pushes a *full* `print.push_status` (97
/// that's a different method to add once identified, not a variant /// top-level fields, `msg: 0`) roughly once a second, unprompted, from
/// of this one. A v2 connection makes this call a no-op rather than /// the moment you subscribe. The earlier reading that it sent nothing
/// an error, since `pushing.pushall` specifically is confirmed not to /// was a local bug, not printer behaviour - every one of those pushes
/// exist for it regardless. /// 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.
pub async fn pushall(&self) -> Result<(), PrinterError> { pub async fn pushall(&self) -> Result<(), PrinterError> {
let (session, version) = let (session, version) =
self.connection.as_ref().ok_or_else(|| PrinterError(format!("{} is not connected", self.base.name)))?; self.connection.as_ref().ok_or_else(|| PrinterError(format!("{} is not connected", self.base.name)))?;
@@ -481,6 +483,133 @@ impl BambuPrinter {
session.publish("pushing", &body).await session.publish("pushing", &body).await
} }
/// Just the FTPS transfer: puts `data` in the printer's `cache/`
/// directory and stops there, without telling it to print anything.
///
/// Split out from `upload_project_file` (which is this plus
/// `start_uploaded_file`) because the two halves have very different
/// consequences and testing them together is awkward: moving a file onto
/// a printer is inert and reversible, while starting a print commits the
/// machine to hours of physical work. Being able to validate the
/// transfer against real hardware without that second part is worth the
/// extra method - and a caller that wants to stage a file now and print
/// it later wants exactly this too.
///
/// Returns however many bytes the printer's FTPS server accepted, so a
/// caller can check that against what it sent.
pub async fn upload_file(&mut self, file_name: &str, data: Vec<u8>) -> Result<u64, PrinterError> {
// Trust gets resolved out here, on the async side, so the blocking
// closure below owns everything it needs and borrows nothing.
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 name = file_name.to_string();
tokio::task::spawn_blocking(move || {
super::bambu_ftps::upload(&host, &access_code, connector, &name, &data)
})
.await
// The outer error is `spawn_blocking`'s own: the thread panicked or
// was cancelled. The inner one is the upload's.
.map_err(|err| PrinterError(format!("{}: FTPS upload thread failed: {err}", self.base.name)))?
.map_err(|err| {
PrinterError(format!("{}: FTPS upload of {file_name} failed: {err}", self.base.name))
})
}
/// Starts printing a file that's already sitting in the printer's
/// `cache/` directory. `upload_project_file` calls this straight after
/// its FTPS transfer; it's also public on its own, so a file uploaded
/// on some earlier run can be reprinted without sending the bytes
/// again.
///
/// Bambu has *two* commands here and which one is right depends on what
/// kind of file it is, which is why this isn't one uniform publish:
///
/// - A `.3mf` is a project archive - a container with slicer settings,
/// plate layout and one or more gcode files inside it. `print.
/// project_file` takes `url` (where the archive is) and `param` (which
/// gcode file *inside* the archive to run).
/// - A bare `.gcode` has no container and nothing to select, so
/// `print.gcode_file` just takes `param`: the full path on the
/// printer's filesystem.
///
/// **Not yet confirmed against a physical printer** - unlike almost
/// everything else in this file, which carries "confirmed live" notes.
/// Three specific things to check on the first real run, because they
/// are where the guesses are:
///
/// 1. `param` for the `.3mf` path is hardcoded to
/// `Metadata/plate_1.gcode`, i.e. always the first plate. Correct for
/// a single-plate slice, which is the common case, but a multi-plate
/// project needs the caller to choose - that's a parameter to add
/// once there's a caller that cares, not a default to guess harder at.
/// 2. The `url` form, `ftp:///cache/<file>`. It satisfies the schema's
/// `^(?:file|ftp)://` pattern, and the empty-host triple slash is
/// what Bambu Studio has been observed to send, but `file:///` over
/// an absolute printer path is the other plausible spelling.
/// 3. `use_ams: false` / `ams_mapping: ""` - the no-AMS, single-filament
/// case, and the only one wired up. The schema's trailing `oneOf`
/// says these two move together (a real mapping object *requires*
/// `use_ams: true`), so AMS support means filling both, not just
/// flipping the flag.
///
/// The optional calibration flags (`bed_levelling`, `flow_cali`,
/// `timelapse`, ...) are deliberately left unset rather than defaulted
/// here: omitted, the printer applies its own settings, which is a
/// better default than this method inventing one. They become real
/// parameters when a caller actually wants to control them. Worth
/// noting while you're in there: the schema spells it `bed_levelling`
/// (two l's) and Bambu's firmware has been seen using `bed_leveling` -
/// check which the printer accepts before relying on that field, and
/// fix continuum-schemas if it's wrong.
pub async fn start_uploaded_file(&self, file_name: &str) -> Result<(), PrinterError> {
let (session, version) =
self.connection.as_ref().ok_or_else(|| PrinterError(format!("{} is not connected", self.base.name)))?;
let is_project = file_name.to_ascii_lowercase().ends_with(".3mf");
let command = if is_project { "project_file" } else { "gcode_file" };
let mut body = continuum_schema_instance::build_instance_from_file(&get_schema_for_command(
*version,
"print".into(),
command.into(),
))
.map_err(|err| PrinterError(format!("failed to build {command} request: {err}")))?;
let cache_path = format!("{}/{}", super::bambu_ftps::CACHE_DIR, file_name);
if is_project {
body["url"] = serde_json::Value::String(format!("ftp:///{cache_path}"));
body["param"] = serde_json::Value::String("Metadata/plate_1.gcode".into());
// Local print, no Bambu-cloud job behind it: these are all the
// literal string "0" rather than absent, per the schema's own
// field descriptions.
for key in ["project_id", "profile_id", "task_id", "subtask_id"] {
body[key] = serde_json::Value::String("0".into());
}
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());
} else {
body["param"] = serde_json::Value::String(format!("/{cache_path}"));
}
// Fire-and-forget, same as `send_gcode_line`: the printer
// acknowledges by starting to print, which shows up in the ordinary
// `print.push_status` stream - read that via `print_status()`
// rather than waiting on a correlated reply here.
session.publish("print", &body).await
}
/// The current in-memory `print.push_status` object - `None` until at /// The current in-memory `print.push_status` object - `None` until at
/// least one has arrived (which, on v1, requires having called /// least one has arrived (which, on v1, requires having called
/// `pushall` at least once since connecting; v2 pushes on its own). /// `pushall` at least once since connecting; v2 pushes on its own).
@@ -555,23 +684,39 @@ impl GenericPrinter for BambuPrinter {
self.send_gcode_line(format!("M106 P1 S{pwm}")).await self.send_gcode_line(format!("M106 P1 S{pwm}")).await
} }
/// Real version FTPS-`PUT`s `data` to the printer's own `/cache/` /// Real, both halves: FTPS-`PUT`s `data` into the printer's own
/// directory (that's storage the printer's firmware owns, same idea /// `cache/` directory (storage the printer's firmware owns, same idea
/// as PrusaLink's SD card), then publishes a `project_file` MQTT /// as PrusaLink's SD card), then publishes the MQTT command that starts
/// command referencing it by name so the printer actually starts /// printing it. Two genuinely separate protocols - see
/// printing it - two separate steps, both stubbed here as one line /// `bambu_ftps.rs` for the transfer and `start_uploaded_file` below for
/// for now. /// the command.
///
/// The FTPS half is blocking code (suppaftp's sync client, so it can
/// reuse `BambuTls::build_connector()` - see `bambu_ftps.rs`'s doc
/// comment), so it runs on `tokio::task::spawn_blocking`. That's
/// tokio's "this call parks a whole thread, keep it off the async
/// worker threads" escape hatch; the nearest TS equivalent is moving
/// work to a worker thread so it can't stall the event loop. Note this
/// is the *right* fix for the problem `connect()`'s doc comment above
/// flags about `ensure_trusted` still being blocking inline - a
/// multi-megabyte upload is far more obviously worth getting off the
/// runtime than a handshake is.
async fn upload_project_file( async fn upload_project_file(
&mut self, &mut self,
file_name: String, file_name: String,
data: Vec<u8>, data: Vec<u8>,
) -> Result<(), PrinterError> { ) -> Result<(), PrinterError> {
// `data` is about to be moved, so take its length while it's here.
let byte_count = data.len();
let written = self.upload_file(&file_name, data).await?;
println!( println!(
"[{}] FTPS upload {file_name} ({} bytes) to /cache/", "[{}] uploaded {file_name} ({written} of {byte_count} bytes) to {}/",
self.base.name, self.base.name,
data.len() super::bambu_ftps::CACHE_DIR
); );
Ok(())
self.start_uploaded_file(&file_name).await
} }
/// Builds the real JSON payload straight from continuum-schemas' /// Builds the real JSON payload straight from continuum-schemas'
+91
View File
@@ -0,0 +1,91 @@
//! Getting a file *onto* a Bambu printer - the other half of
//! `upload_project_file`, which `bambu.rs` stubbed with a `println!` until
//! now. MQTT can start a print, but it can only name a file the printer
//! already has; moving the bytes there is a completely separate protocol.
//!
//! This is deliberately suppaftp's **blocking** client, not its async one,
//! driven from `tokio::task::spawn_blocking` at the single call site in
//! `bambu.rs`. Two reasons:
//!
//! 1. The blocking client takes a plain `native_tls::TlsConnector`, so this
//! module reuses `BambuTls::build_connector()` exactly as-is and every
//! TLS trust decision for a printer stays in that one method (which has
//! a long doc comment explaining some hard-won specifics - duplicating
//! that reasoning against a second TLS stack is how it drifts).
//! 2. suppaftp's async client needs that second TLS stack
//! (`async-native-tls`), whose futures-vs-tokio `AsyncRead` mismatch has
//! already cost this project one debugging session.
//!
//! **Unverified against real hardware.** The port, the username, and
//! implicit TLS are well-established for Bambu LAN mode, and the TLS layer
//! itself is the same one `test_tls_handshake` already proves against a
//! real printer on 8883. But no physical printer has accepted an upload
//! from *this* code yet. Expect the first live run to be where the
//! remaining details get settled - see `bambu.rs::upload_project_file` for
//! the list of specific things to watch.
use std::io::Cursor;
use suppaftp::types::FileType;
use suppaftp::{NativeTlsConnector, NativeTlsFtpStream};
/// Bambu LAN mode speaks *implicit* FTPS: TLS starts the instant the socket
/// opens, with no plaintext `AUTH TLS` negotiation first. That's the older
/// of the two FTPS styles and formally deprecated, which is exactly why
/// suppaftp hides `connect_secure_implicit` behind a `deprecated` cargo
/// feature. The printers only speak this, so the feature is switched on in
/// `Cargo.toml`.
const FTPS_PORT: u16 = 990;
/// The same for every Bambu printer in LAN mode. The password is the same
/// access code MQTT authenticates with - one shared secret, two protocols.
const FTPS_USER: &str = "bblp";
/// Storage the printer's own firmware owns, and the directory Bambu Studio
/// uploads to as well. A `project_file`/`gcode_file` MQTT command can only
/// print something that already lives here.
pub const CACHE_DIR: &str = "cache";
/// Blocking: opens an implicit-FTPS session to `host`, logs in with
/// `access_code`, and writes `data` to `cache/{file_name}`. Returns however
/// many bytes the server reported accepting.
///
/// Takes an already-built `connector` rather than a `&BambuTls` so the
/// caller resolves trust on the async side and this function can be moved
/// wholesale onto a blocking thread - `native_tls::TlsConnector` is `Send`,
/// which is what makes that legal.
pub fn upload(
host: &str,
access_code: &str,
connector: native_tls::TlsConnector,
file_name: &str,
data: &[u8],
) -> anyhow::Result<u64> {
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 and a .gcode has meaningful line endings -
// either way, letting the server treat the stream as text is free
// corruption.
ftp.transfer_type(FileType::Binary)?;
ftp.cwd(CACHE_DIR)?;
// `put_file` wants something that implements `Read`. `data` is already
// bytes in memory (see `GenericPrinter::upload_project_file`'s doc
// comment for why it's bytes and not a path), and `Cursor` is the
// standard way to let something read from a slice - roughly wrapping a
// Buffer in a stream in Node.
let mut reader = Cursor::new(data);
let written = ftp.put_file(file_name, &mut reader)?;
// Best-effort: the bytes are already delivered and acknowledged, so
// failing to say goodbye politely isn't worth failing the upload over.
let _ = ftp.quit();
Ok(written)
}
+5 -3
View File
@@ -22,12 +22,14 @@
//! Run `cargo run --example printer_polymorphism` to see it in action. //! Run `cargo run --example printer_polymorphism` to see it in action.
//! //!
//! Real MQTT is built now - `bambu.rs`/`bambu_mqtt.rs` for Bambu's //! Real MQTT is built now - `bambu.rs`/`bambu_mqtt.rs` for Bambu's
//! protocol, `marlin_serial.rs` for a real Marlin serial connection - see //! protocol, `bambu_ftps.rs` for getting files onto a Bambu printer,
//! each module's own doc comment. PrusaLink/Klipper's HTTP clients are //! `marlin_serial.rs` for a real Marlin serial connection - see each
//! still stubs; that's the next piece to build past this module. //! module's own doc comment. PrusaLink/Klipper's HTTP clients are still
//! stubs; that's the next piece to build past this module.
mod bambu; mod bambu;
mod bambu_commands; mod bambu_commands;
mod bambu_ftps;
mod bambu_mqtt; mod bambu_mqtt;
mod klipper; mod klipper;
mod marlin_serial; mod marlin_serial;