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:
+162
-17
@@ -454,14 +454,16 @@ impl BambuPrinter {
|
||||
/// Call this periodically for v1 printers specifically - v2 has no
|
||||
/// `pushing` category at all (checked: `schemas/bambulab/v2/
|
||||
/// request.schema.json` only wires camera/info/print/system/upgrade/
|
||||
/// xcam). Whether v2 needs some *other* trigger to get
|
||||
/// `print.push_status` flowing at all is still an open question (a
|
||||
/// live test meant to check that lost its connection entirely, an
|
||||
/// unrelated failure, not real evidence either way) - if it does,
|
||||
/// that's a different method to add once identified, not a variant
|
||||
/// of this one. A v2 connection makes this call a no-op rather than
|
||||
/// an error, since `pushing.pushall` specifically is confirmed not to
|
||||
/// exist for it regardless.
|
||||
/// xcam). A v2 connection makes this call a no-op rather than an error.
|
||||
///
|
||||
/// **Resolved 2026-10-03, live against a real H2C mid-print:** v2 needs
|
||||
/// no trigger whatsoever. It pushes a *full* `print.push_status` (97
|
||||
/// top-level fields, `msg: 0`) roughly once a second, unprompted, from
|
||||
/// the moment you subscribe. The earlier reading that it sent nothing
|
||||
/// was a local bug, not printer behaviour - every one of those pushes
|
||||
/// 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> {
|
||||
let (session, version) =
|
||||
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
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// least one has arrived (which, on v1, requires having called
|
||||
/// `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
|
||||
}
|
||||
|
||||
/// Real version FTPS-`PUT`s `data` to the printer's own `/cache/`
|
||||
/// directory (that's storage the printer's firmware owns, same idea
|
||||
/// as PrusaLink's SD card), then publishes a `project_file` MQTT
|
||||
/// command referencing it by name so the printer actually starts
|
||||
/// printing it - two separate steps, both stubbed here as one line
|
||||
/// for now.
|
||||
/// Real, both halves: FTPS-`PUT`s `data` into the printer's own
|
||||
/// `cache/` directory (storage the printer's firmware owns, same idea
|
||||
/// as PrusaLink's SD card), then publishes the MQTT command that starts
|
||||
/// printing it. Two genuinely separate protocols - see
|
||||
/// `bambu_ftps.rs` for the transfer and `start_uploaded_file` below for
|
||||
/// 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(
|
||||
&mut self,
|
||||
file_name: String,
|
||||
data: Vec<u8>,
|
||||
) -> 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!(
|
||||
"[{}] FTPS upload {file_name} ({} bytes) to /cache/",
|
||||
"[{}] uploaded {file_name} ({written} of {byte_count} bytes) to {}/",
|
||||
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'
|
||||
|
||||
Reference in New Issue
Block a user