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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user