Files
continuum-proxy/src/printer/bambu_ftps.rs
T
octoturgeandClaude Opus 5 3d907d0443 Confirm the light and the upload on a real P1P
Four things were shipped unverified because the printer was off when they
were written. Three are now confirmed first-hand (P1P, 2026-10-15), and one
is settled in the opposite direction from what was assumed.

**`set_light` had never once reached a printer**, and `completion::run`
blinks a finished print's chamber light with it. It works - but "accepted" is
not "it changed": `ledctrl` answers `result: "success"` as soon as the
command parses, exactly like the print-control verbs. So `lights_report` is
now modelled on `PrintReport`, which is the only read-back there is, and the
probe polls it rather than printing a reassuring message. Setting the light
on and off reads `[{"node":"chamber_light","mode":"on"}]` / `"off"` back,
about 3s later. The field was observed on v1 even though only the v2 schema
declares it.

**The blink itself works at the rate it ships at.** `completion::celebrate`
is now public so the probe runs the real function instead of a copy: twenty
`ledctrl` commands half a second apart, which a printer is entitled to
refuse. None were refused, it returned after 10.35s, and the light ended
`off` as specified - a blink that leaves the light on would look like a
printer still working.

**The firmware's own `flashing` mode is not the better option.** It is
accepted, and the reply echoes all four parameters back
(`led_on_time`, `led_off_time`, `loop_times`, `interval_time`), so the
printer does parse them. But `lights_report` never reports `flashing` - it
stayed on the previous mode for 12s of polling. A firmware blink is therefore
unobservable: nothing can confirm it happened or tell when it ended, while
the self-driven blink is built from a call that reads back. That is a better
reason to keep `celebrate` than the one in the comment it replaces, which was
that `loop_times` semantics were unconfirmed. Whether the light physically
blinks is still unwitnessed - nobody was watching the machine.

**Multi-MB FTPS upload works, and is slow.** A 4.96 MB project archive went
up with an exact byte count and came back byte-identical (sha256). But it
took 31.3s - about 0.2 MiB/s - so the 62 MB project already on that card
would take six and a half minutes. Anything that uploads a job needs a
timeout budgeted in minutes, not seconds. The claim in `download`'s comment
that these files are "a few hundred KB" is gone; the card disproves it.

Also adds `delete`, because the probe left a 5 MB file on a real SD card and
there was no way to remove it. One explicit path, no globbing: the convenient
version of this deletes something somebody printed from last week. Used to
clean up, and the card is back as it was.

New probe steps: `light on|off|flash`, `blink`, `upload <local> [name]`,
`delete <path>`. 51 tests still pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 22:23:41 +00:00

228 lines
9.2 KiB
Rust

//! 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 `<name>.gcode.3mf` files, the printable project
/// archives Bambu Studio sends when it uploads a plate;
/// - `cache/` holds 20 `<project>_plate_N.gcode` files with
/// `<N>_<project>.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<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)?;
// `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<Vec<u8>> {
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<Vec<String>> {
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)
}