//! 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"; /// The SD card's root, which is where an FTPS login lands and - confirmed /// live on a P1P, 2026-10-11 - the only directory a `print.project_file` /// command has been seen to print from. /// /// The card's two interesting directories hold different things, and a /// listing of the real P1P is the clearest way to say which: /// /// - the root holds 43 `.gcode.3mf` files, the printable project /// archives Bambu Studio sends when it uploads a plate; /// - `cache/` holds 20 `_plate_N.gcode` files with /// `_.gcode.bbl` sidecars - the printer's own extracted /// per-plate working copies. /// /// Those extracted copies look like the obvious thing to print and are not: /// `print.gcode_file` with `param: "/cache/Cube_plate_1.gcode"` was /// rejected (`result: "fail"`) for a file that demonstrably exists, while /// `print.project_file` with `url: "ftp:///Cube.gcode.3mf"` against the /// root started a print. So uploads go here, not into `cache/`. pub const SD_ROOT: &str = "/"; /// 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, dir: &str, 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)?; // `SD_ROOT` means "don't change directory at all" - the FTPS login // already lands in the card's root, and `cwd("/")` is a needless round // trip that some servers answer less predictably than no request. if dir != SD_ROOT { ftp.cwd(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) } /// 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> { 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 a project archive is // read whole anyway - `plate_filaments` needs the central directory at // its end before it can read anything. // // Confirmed fine at 4.96 MB on the P1P (2026-10-15, byte-identical // round trip). The qualifier here used to be "a few hundred KB", which // the card itself disproves - the largest project on it is 62 MB, and // that one would be held in memory twice during a round trip. Streaming // is the answer if that ever matters; it has not yet. let bytes = ftp.retr_as_buffer(path)?.into_inner(); let _ = ftp.quit(); Ok(bytes) } /// Blocking: removes one file from the card. /// /// The counterpart to `upload`, and the only reason it did not exist before /// is that nothing had uploaded anything worth removing. It is needed now: /// the P1P's card already holds 45 project archives and 47 extracted /// `cache/` entries, so a gateway that uploads a project per job and never /// removes one is a gateway that eventually fills a customer's SD card and /// starts failing prints for a reason nobody will connect to this code. /// /// Deliberately takes one explicit path and does no globbing or recursion: /// the obvious convenience here ("clear the old ones") is also the obvious /// way to delete a file somebody printed from last week. pub fn delete( host: &str, access_code: &str, connector: native_tls::TlsConnector, path: &str, ) -> anyhow::Result<()> { let mut ftp = NativeTlsFtpStream::connect_secure_implicit( (host, FTPS_PORT), NativeTlsConnector::from(connector), host, )?; ftp.login(FTPS_USER, access_code)?; ftp.rm(path)?; let _ = ftp.quit(); Ok(()) } /// Blocking: lists `cache/` on the printer. /// /// Read-only, and the counterpart to `upload`. The previous version of this /// comment said a `project_file` command "can only print something that is /// already in this directory", which is wrong twice over: the printable /// projects are in the root (see `SD_ROOT`), and this directory's contents /// were rejected when asked for directly. It is still how you find a real /// slicer-produced `.3mf` /// to test against, instead of hand-building one and then not knowing /// whether a failure was the command or the file. /// /// Returns the server's raw LIST lines rather than parsed entries: Bambu's /// FTPS server formats them in the usual unix style, but the parsing is the /// caller's problem until something needs more than the file names. pub fn list( host: &str, access_code: &str, connector: native_tls::TlsConnector, dir: &str, ) -> anyhow::Result> { let mut ftp = NativeTlsFtpStream::connect_secure_implicit( (host, FTPS_PORT), NativeTlsConnector::from(connector), host, )?; ftp.login(FTPS_USER, access_code)?; ftp.transfer_type(FileType::Binary)?; let entries = ftp.list(Some(dir))?; let _ = ftp.quit(); Ok(entries) }