From 704b65ac261bdbed688820485fa8783e5d5cd632 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iwo=20Strzebo=C5=84ski?= Date: Sat, 3 Oct 2026 10:11:36 +0000 Subject: [PATCH] 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. Co-Authored-By: Claude Opus 5 --- Cargo.toml | 13 +++ src/printer/bambu.rs | 179 ++++++++++++++++++++++++++++++++++---- src/printer/bambu_ftps.rs | 91 +++++++++++++++++++ src/printer/mod.rs | 8 +- 4 files changed, 271 insertions(+), 20 deletions(-) create mode 100644 src/printer/bambu_ftps.rs diff --git a/Cargo.toml b/Cargo.toml index 174b9a4..7dbc706 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,13 @@ path = "examples/auto_connect_bambu.rs" name = "bambu_commands_demo" 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] # Real printer protocol clients (MQTT for Bambu, HTTP for PrusaLink/ @@ -62,3 +68,10 @@ thiserror = "1" dotenvy = "0.15" tokio-serial = { version = "5.5.0", default-features = false } 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"] } diff --git a/src/printer/bambu.rs b/src/printer/bambu.rs index 40baa43..d221952 100644 --- a/src/printer/bambu.rs +++ b/src/printer/bambu.rs @@ -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) -> Result { + // 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/`. 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, ) -> 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' diff --git a/src/printer/bambu_ftps.rs b/src/printer/bambu_ftps.rs new file mode 100644 index 0000000..3e7199d --- /dev/null +++ b/src/printer/bambu_ftps.rs @@ -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 { + 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) +} diff --git a/src/printer/mod.rs b/src/printer/mod.rs index 7d5a347..cadb128 100644 --- a/src/printer/mod.rs +++ b/src/printer/mod.rs @@ -22,12 +22,14 @@ //! Run `cargo run --example printer_polymorphism` to see it in action. //! //! Real MQTT is built now - `bambu.rs`/`bambu_mqtt.rs` for Bambu's -//! protocol, `marlin_serial.rs` for a real Marlin serial connection - see -//! each module's own doc comment. PrusaLink/Klipper's HTTP clients are -//! still stubs; that's the next piece to build past this module. +//! protocol, `bambu_ftps.rs` for getting files onto a Bambu printer, +//! `marlin_serial.rs` for a real Marlin serial connection - see each +//! 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_commands; +mod bambu_ftps; mod bambu_mqtt; mod klipper; mod marlin_serial;