From 4d644937af74d48894483b5355bf7f43629d7bc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iwo=20Strzebo=C5=84ski?= Date: Sun, 11 Oct 2026 17:38:27 +0000 Subject: [PATCH] Confirm the print path on a real P1P, and stop discarding its answer Print control (pause/resume/stop) and print start are now both confirmed against the P1P on the LAN, and doing that turned up three real defects. `start_uploaded_file` published fire-and-forget, so the printer's reply was thrown away. Asking it to print a file it does not have returned `Ok(())`, and watching `print.push_status` could not tell that apart from a successful start, since both leave the machine IDLE for a while. The printer does in fact answer - `{"command":"gcode_file","param":"...","reason":"error string","result":"fail"}` - and it echoes the `sequence_id` we send, so `request` correlates it. Both this and `control_print` now go out as correlated requests and run the reply through `print_command_outcome`. The upload and the print command disagreed about where files live. `upload` cwd'd into `cache/` while the printable projects are in the card's root: the root holds 43 `.gcode.3mf` archives, `cache/` holds the printer's own extracted `_plate_N.gcode` working copies with `.gcode.bbl` sidecars. Those extracted copies are not independently printable - `gcode_file` with `param: "/cache/Cube_plate_1.gcode"` was rejected for a file the FTPS listing shows is right there - while `project_file` with `url: "ftp:///Cube.gcode.3mf"` started a print. So the destination is now a parameter and both halves point at `SD_ROOT`. What the hardware settled, recorded in the doc comments: the `ftp:///` url spelling is right, `param: "Metadata/plate_1.gcode"` works for a single-plate project, `use_ams: false` + `ams_mapping: ""` is accepted, and a stop during PREPARE/RUNNING moves the printer to FAILED, which is how Bambu reports an operator cancellation. What it did not settle: all three control verbs answer `result: "success"` on an *idle* printer, so the reply means "accepted", not "the state changed" - callers still have to read `gcode_state` back. `PrinterCommand::ControlPrint` makes this reachable from the cloud at all; until now pause/resume/stop existed in the proxy with no way to send them. Still no start-print variant, now for a different reason: the path around it is missing, not the hardware proof. Also: `list_dir` for any directory (how the layout above was found, without sending a print command to guess), `request_raw` for probing an unconfirmed command shape, and two examples - `bambu_cache_list` (read-only) and `bambu_print_probe` (gated steps, `start` needing both IDLE and --yes-start-a-real-print). Co-Authored-By: Claude Opus 5 --- examples/bambu_cache_list.rs | 89 +++++++++++ examples/bambu_print_probe.rs | 231 +++++++++++++++++++++++++++++ src/printer/bambu.rs | 270 +++++++++++++++++++++++++++++----- src/printer/bambu_ftps.rs | 64 +++++++- src/printer/klipper.rs | 14 ++ src/printer/mod.rs | 50 +++++++ src/printer/prusa.rs | 23 +++ src/uplink/mod.rs | 3 + src/uplink/protocol.rs | 23 ++- tests/uplink_protocol.rs | 58 +++++++- 10 files changed, 776 insertions(+), 49 deletions(-) create mode 100644 examples/bambu_cache_list.rs create mode 100644 examples/bambu_print_probe.rs diff --git a/examples/bambu_cache_list.rs b/examples/bambu_cache_list.rs new file mode 100644 index 0000000..b3b34d2 --- /dev/null +++ b/examples/bambu_cache_list.rs @@ -0,0 +1,89 @@ +//! Run with: cargo run --example bambu_cache_list -- [dir] +//! +//! `dir` defaults to `cache`; pass `/` to see the SD card root, which is +//! how to find out what a file's real absolute path is. +//! +//! **Read-only.** Connects, reads one `print.push_status`, and lists the +//! printer's `cache/` directory over FTPS. It sends no commands that change +//! anything - the `pushall` it issues is a request for a status report, which +//! is the one write-shaped thing a read-only tool has to do on a v1 printer +//! to learn anything at all. +//! +//! The point of it: `start_uploaded_file` can only print a file that is +//! already in `cache/`, so this answers "is there anything there, and what is +//! it called?" before any print command is sent. It is also how to find a +//! real slicer-produced `.3mf` to test that command against, which is worth +//! a great deal more than a hand-built one - if a hand-built file fails there +//! is no way to tell whether the command was wrong or the file was. + +use std::time::Duration; + +use continuum_proxy::fleet; +use continuum_proxy::printer::{GenericPrinter, PrinterHandle}; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(20); +const STATUS_TIMEOUT: Duration = Duration::from_secs(15); + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let Some(target) = std::env::args().nth(1) else { + anyhow::bail!("usage: cargo run --example bambu_cache_list -- [dir]"); + }; + let dir = std::env::args().nth(2).unwrap_or_else(|| "cache".to_string()); + + let mut printers = fleet::load(std::path::Path::new("printers.toml"))?; + let printer = printers + .iter_mut() + .find(|p| p.base().id == target) + .ok_or_else(|| anyhow::anyhow!("no printer with id '{target}' in printers.toml"))?; + + let PrinterHandle::Bambu(bambu) = printer else { + anyhow::bail!("'{target}' is not a Bambu printer - this example is Bambu-specific"); + }; + + println!("=== {} ===", bambu.display_name()); + + match tokio::time::timeout(CONNECT_TIMEOUT, bambu.connect()).await { + Ok(Ok(())) => {} + Ok(Err(err)) => anyhow::bail!("connect failed: {err}"), + Err(_) => anyhow::bail!("connect timed out - printer off or unreachable?"), + } + + bambu.pushall().await.ok(); + + let deadline = tokio::time::Instant::now() + STATUS_TIMEOUT; + loop { + if let Some(report) = bambu.print_status().await { + if let Some(state) = report.gcode_state.clone() { + println!(" gcode_state: {state}"); + println!(" subtask_name: {}", report.subtask_name.clone().unwrap_or_default()); + println!( + " layer: {}/{}", + report.layer_num.unwrap_or_default(), + report.total_layer_num.unwrap_or_default() + ); + println!(" percent: {}", report.mc_percent.unwrap_or_default()); + break; + } + } + if tokio::time::Instant::now() >= deadline { + println!(" (no gcode_state within {STATUS_TIMEOUT:?} - listing cache/ anyway)"); + break; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + + println!("\n {dir} over FTPS:"); + match bambu.list_dir(&dir).await { + Ok(entries) if entries.is_empty() => println!(" (empty)"), + Ok(entries) => { + for entry in &entries { + println!(" {entry}"); + } + println!("\n {} entr(ies)", entries.len()); + } + Err(err) => println!(" FAILED: {err}"), + } + + Ok(()) +} diff --git a/examples/bambu_print_probe.rs b/examples/bambu_print_probe.rs new file mode 100644 index 0000000..8d9fd82 --- /dev/null +++ b/examples/bambu_print_probe.rs @@ -0,0 +1,231 @@ +//! Run with: cargo run --example bambu_print_probe -- [arg] +//! +//! Probes the print-start and print-control commands against a real printer, +//! one deliberate step at a time. **Some of these steps commit a physical +//! machine to work**, so nothing here happens implicitly: every step is named +//! on the command line, and the ones that can start a print additionally +//! require `--yes-start-a-real-print`. +//! +//! Steps, in the order worth running them: +//! +//! - `status` read-only. Connect, print state, list `cache/`. +//! - `probe-missing` **zero physical risk.** Sends `print.gcode_file` +//! pointing at a file that does not exist. The printer +//! cannot start anything, so this isolates one +//! question: does it accept and parse the command shape +//! at all? A clean rejection naming the file means the +//! shape is right and only the target was wrong. A +//! parse error, or silence, means the shape is wrong. +//! - `start ` sends `print.gcode_file`/`print.project_file` for a +//! file in `cache/`. Gated on `gcode_state: IDLE` and +//! on the explicit flag. +//! - `pause` / `resume` / `stop` +//! the control commands. `stop` cancels - it is the +//! undo for `start`, and the reason `start` is worth +//! testing at all: without it a cloud-initiated print +//! could only be ended at the machine. +//! +//! After whatever it sends, it watches the `print.push_status` stream and +//! reports every change to the fields that matter. Run with +//! `RUST_LOG=continuum_proxy::printer::bambu_mqtt=trace` to see the raw +//! payloads too - which is where a rejection that `PrintReport` does not +//! model will show up. + +use std::time::Duration; + +use continuum_proxy::fleet; +use continuum_proxy::printer::{GenericPrinter, PrintControl, PrinterHandle}; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(20); +const STATUS_TIMEOUT: Duration = Duration::from_secs(15); +/// How long to watch the report stream after sending something. +const WATCH: Duration = Duration::from_secs(20); + +/// A file name that cannot exist in `cache/`, for `probe-missing`. +const MISSING_FILE: &str = "continuum-probe-no-such-file.gcode"; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Without this there is no subscriber, so the `trace!` of every raw + // report in bambu_mqtt.rs goes nowhere and RUST_LOG appears to do + // nothing - which is exactly how the first run of this probe came back + // with no evidence either way. + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + let args: Vec = std::env::args().collect(); + let (Some(target), Some(step)) = (args.get(1).cloned(), args.get(2).cloned()) else { + anyhow::bail!( + "usage: cargo run --example bambu_print_probe -- [arg]\n\ + steps: status | probe-missing | start | pause | resume | stop" + ); + }; + let confirmed = args.iter().any(|a| a == "--yes-start-a-real-print"); + + let mut printers = fleet::load(std::path::Path::new("printers.toml"))?; + let printer = printers + .iter_mut() + .find(|p| p.base().id == target) + .ok_or_else(|| anyhow::anyhow!("no printer with id '{target}' in printers.toml"))?; + + let PrinterHandle::Bambu(bambu) = printer else { + anyhow::bail!("'{target}' is not a Bambu printer - this example is Bambu-specific"); + }; + + println!("=== {} / step: {step} ===", bambu.display_name()); + + match tokio::time::timeout(CONNECT_TIMEOUT, bambu.connect()).await { + Ok(Ok(())) => {} + Ok(Err(err)) => anyhow::bail!("connect failed: {err}"), + Err(_) => anyhow::bail!("connect timed out - printer off or unreachable?"), + } + + // v1 answers only when asked; v2 pushes unprompted. + bambu.pushall().await.ok(); + let state = wait_for_state(bambu).await?; + println!(" state before: {state}"); + + match step.as_str() { + "status" => { + match bambu.list_cache().await { + Ok(entries) => { + println!("\n cache/ ({} entries):", entries.len()); + for entry in &entries { + println!(" {entry}"); + } + } + Err(err) => println!(" cache list FAILED: {err}"), + } + return Ok(()); + } + + "probe-missing" => { + // Cannot start anything: the file is not there. This asks only + // whether the command shape is understood. + println!("\n sending print.gcode_file for a file that does not exist:"); + println!(" param -> /cache/{MISSING_FILE}"); + match bambu.start_uploaded_file(MISSING_FILE).await { + Ok(()) => println!(" the printer ACCEPTED it (result: success)"), + Err(err) => anyhow::bail!("the printer answered: {err}"), + } + } + + "start" => { + let Some(file) = args.get(3).filter(|a| !a.starts_with("--")).cloned() else { + anyhow::bail!("`start` needs a file name that is already in cache/ - run `status` to list them"); + }; + if state != "IDLE" { + anyhow::bail!( + "printer reports '{state}', not IDLE - refusing to start a print on a machine \ + that may already be working" + ); + } + if !confirmed { + anyhow::bail!( + "refusing to start a real print without --yes-start-a-real-print.\n\ + This commits the machine to physical work. Be at the printer." + ); + } + println!("\n starting print of '{file}' ..."); + match bambu.start_uploaded_file(&file).await { + Ok(()) => println!(" accepted"), + Err(err) => anyhow::bail!("the printer answered: {err}"), + } + } + + "pause" | "resume" | "stop" => { + let action = match step.as_str() { + "pause" => PrintControl::Pause, + "resume" => PrintControl::Resume, + _ => PrintControl::Stop, + }; + println!("\n sending print.{} ...", action.as_str()); + match bambu.control_print(action).await { + Ok(()) => println!(" accepted"), + Err(err) => anyhow::bail!("the printer answered: {err}"), + } + } + + // `shape ` sends one `print` command verbatim as a correlated + // request and prints the reply. For settling an unconfirmed command + // shape against the hardware - see `BambuPrinter::request_raw`. + "shape" => { + let Some(raw) = args.get(3) else { + anyhow::bail!("`shape` needs a JSON body for the `print` category"); + }; + let body: serde_json::Value = serde_json::from_str(raw) + .map_err(|err| anyhow::anyhow!("that is not valid JSON: {err}"))?; + println!("\n sending print {} ...", body["command"]); + match bambu.request_raw("print", body).await { + Ok(reply) => println!(" reply: {reply}"), + Err(err) => println!(" no usable reply: {err}"), + } + } + + other => anyhow::bail!("unknown step '{other}'"), + } + + watch(bambu, state).await; + Ok(()) +} + +async fn wait_for_state(bambu: &continuum_proxy::printer::BambuPrinter) -> anyhow::Result { + let deadline = tokio::time::Instant::now() + STATUS_TIMEOUT; + loop { + if let Some(report) = bambu.print_status().await { + if let Some(state) = report.gcode_state.clone() { + return Ok(state); + } + } + if tokio::time::Instant::now() >= deadline { + anyhow::bail!( + "no print.push_status with a gcode_state within {STATUS_TIMEOUT:?} - refusing to \ + go further, because there is no way to tell what this printer is doing" + ); + } + tokio::time::sleep(Duration::from_millis(500)).await; + } +} + +/// Reports every change to the fields that would show a command landing. +async fn watch(bambu: &continuum_proxy::printer::BambuPrinter, before: String) { + println!("\n watching the report stream for {WATCH:?} ..."); + let deadline = tokio::time::Instant::now() + WATCH; + let mut last = before; + let mut last_error: Option = None; + let mut last_task = String::new(); + + while tokio::time::Instant::now() < deadline { + // v1 needs asking each time for a complete object. + bambu.pushall().await.ok(); + tokio::time::sleep(Duration::from_millis(1500)).await; + + if let Some(report) = bambu.print_status().await { + if let Some(state) = report.gcode_state.clone() { + if state != last { + println!(" gcode_state: {last} -> {state}"); + last = state; + } + } + if report.print_error != last_error { + println!(" print_error: {:?} -> {:?}", last_error, report.print_error); + last_error = report.print_error; + } + let task = report.subtask_name.clone().unwrap_or_default(); + if task != last_task { + println!(" subtask_name: '{last_task}' -> '{task}'"); + last_task = task; + } + if let Some(hms) = &report.hms { + if !hms.is_empty() { + println!(" hms (printer alerts): {}", serde_json::to_string(hms).unwrap_or_default()); + } + } + } + } + println!(" final state: {last}"); +} diff --git a/src/printer/bambu.rs b/src/printer/bambu.rs index d221952..b0d8789 100644 --- a/src/printer/bambu.rs +++ b/src/printer/bambu.rs @@ -497,6 +497,10 @@ impl BambuPrinter { /// /// Returns however many bytes the printer's FTPS server accepted, so a /// caller can check that against what it sent. + /// + /// Lands in the SD card's root, not in `cache/` - it used to be the + /// latter, which put the file somewhere `start_uploaded_file` could not + /// then print from. See `bambu_ftps::SD_ROOT` for the evidence. 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. @@ -510,7 +514,14 @@ impl BambuPrinter { let name = file_name.to_string(); tokio::task::spawn_blocking(move || { - super::bambu_ftps::upload(&host, &access_code, connector, &name, &data) + super::bambu_ftps::upload( + &host, + &access_code, + connector, + super::bambu_ftps::SD_ROOT, + &name, + &data, + ) }) .await // The outer error is `spawn_blocking`'s own: the thread panicked or @@ -521,42 +532,178 @@ impl BambuPrinter { }) } - /// 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. + /// What is currently staged in the printer's `cache/` directory. + /// + /// Read-only. Worth having on its own because `start_uploaded_file` can + /// only print what is already in there, so this answers "will that + /// command find anything?" without sending it. + pub async fn list_cache(&self) -> Result, PrinterError> { + self.list_dir(super::bambu_ftps::CACHE_DIR).await + } + + /// `list_cache` for any directory on the printer's SD card. + /// + /// Read-only, and the way to answer "what is the path to this file?" + /// without sending a print command to find out - which matters because + /// `print.gcode_file` answers a wrong path and a missing file with the + /// same `result: "fail"`, `reason: "error string"`. + pub async fn list_dir(&self, dir: &str) -> Result, PrinterError> { + 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 dir = dir.to_string(); + + tokio::task::spawn_blocking(move || { + super::bambu_ftps::list(&host, &access_code, connector, &dir) + }) + .await + .map_err(|err| PrinterError(format!("{}: FTPS list thread failed: {err}", self.base.name)))? + .map_err(|err| PrinterError(format!("{}: FTPS list failed: {err}", self.base.name))) + } + + /// Sends `body` in `category` as a correlated request and hands back the + /// printer's reply verbatim. + /// + /// An escape hatch for probing a command shape against real hardware, + /// which is how every "confirmed live" note in this file was earned. Not + /// for ordinary use - a command worth sending twice deserves a typed + /// method next to the others. Kept public because the alternative is + /// reshaping an API around a guess and then discovering the guess was + /// wrong, which is exactly what `start_uploaded_file` did. + pub async fn request_raw( + &self, + category: &str, + body: serde_json::Value, + ) -> Result { + let (session, _) = self + .connection + .as_ref() + .ok_or_else(|| PrinterError(format!("{} is not connected", self.base.name)))?; + session.request(category, body).await + } + + /// Interprets the `result`/`reason` pair a `print` command's reply + /// carries, turning a rejection into an `Err` instead of losing it. + /// + /// Confirmed live on a P1P (2026-10-11): `print.gcode_file` naming a + /// file that doesn't exist replies + /// `{"command":"gcode_file","param":"/cache/...","sequence_id":"", + /// "reason":"error string","result":"fail"}`. Before this, that reply + /// was thrown away - `start_uploaded_file` published fire-and-forget, + /// so asking the printer to print a file it doesn't have returned + /// `Ok(())` and the print simply never happened. There is no + /// distinguishing it from a successful start by watching + /// `print.push_status` either: both leave the printer IDLE for a while. + /// + /// A reply with no `result` field at all is accepted, with a warning. + /// A correlated reply arriving is itself proof the command was parsed + /// (it echoed our `sequence_id` back), and not every firmware/command + /// pair is known to include `result` - so this refuses to invent a + /// failure, while still not hiding an unrecognised shape. + fn print_command_outcome( + printer: &str, + command: &str, + reply: &serde_json::Value, + ) -> Result<(), PrinterError> { + match reply.get("result").and_then(serde_json::Value::as_str) { + Some("success") => Ok(()), + Some(other) => { + let reason = reply + .get("reason") + .and_then(serde_json::Value::as_str) + .filter(|reason| !reason.is_empty()) + .map(|reason| format!(" (reason: {reason})")) + .unwrap_or_default(); + Err(PrinterError(format!( + "{printer} rejected print.{command}: result '{other}'{reason}" + ))) + } + None => { + tracing::warn!( + target: "continuum_proxy::printer::bambu", + printer, + command, + reply = %reply, + "a print command's reply carried no 'result' field; treating it as accepted" + ); + Ok(()) + } + } + } + + /// Starts printing a file that's already on the printer's SD card. + /// `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: + /// kind of file it is, which is why this isn't one uniform command: /// /// - 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). + /// 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. + /// `print.gcode_file` just takes `param`: the 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: + /// ## The `.3mf` branch is confirmed live /// - /// 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. + /// On a P1P, 2026-10-11, this exact body started a real print + /// (`gcode_state: IDLE -> PREPARE -> RUNNING`, then stopped from + /// `control_print`): + /// + /// ```json + /// {"command":"project_file","param":"Metadata/plate_1.gcode", + /// "url":"ftp:///Cube.gcode.3mf","project_id":"0","profile_id":"0", + /// "task_id":"0","subtask_id":"0","subtask_name":"", + /// "use_ams":false,"ams_mapping":""} + /// ``` + /// + /// That settles two of the three things this comment used to flag as + /// guesses: + /// + /// - **`url` spelling.** `ftp:///` - empty host, triple slash, + /// resolved against the card's root - is accepted. `file:///` was + /// never needed and remains untested. + /// - **`use_ams: false` / `ams_mapping: ""`.** Accepted, and the print + /// ran. Note the limit of that evidence: the print was stopped during + /// `PREPARE`/early `RUNNING`, so the pair was accepted but filament + /// was never actually drawn. Real AMS support still means filling + /// both fields together, not flipping the flag. + /// + /// Still a guess: **`param` is hardcoded to `Metadata/plate_1.gcode`**, + /// i.e. always the first plate. Correct for the single-plate slice this + /// was confirmed with, which is the common case, but a multi-plate + /// project needs the caller to choose - a parameter to add once there's + /// a caller that cares, not a default to guess harder at. + /// + /// ## The `.gcode` branch is not, and the obvious path for it is wrong + /// + /// The command *shape* is understood - the printer parses it and echoes + /// `param` back - but both spellings tried against a file that + /// demonstrably exists were rejected with `result: "fail"`, + /// `reason: "error string"`: + /// + /// - `param: "/cache/Cube_plate_1.gcode"`, for a file the FTPS listing + /// shows is right there. + /// + /// The likely reason is in `bambu_ftps::SD_ROOT`: `cache/` holds the + /// printer's own extracted per-plate gcode, with `.gcode.bbl` sidecars, + /// and those extracted copies appear not to be independently printable. + /// What this code now sends is `/` against the root, for + /// consistency with where `upload_file` puts things - **untested**, + /// because testing it means uploading a bare `.gcode` and starting a + /// real print. If you are the one who tests it, record the answer here. + /// + /// A rejection is at least no longer silent: both branches go out as + /// correlated requests and `print_command_outcome` turns a `fail` into + /// an `Err`. Before that, asking this printer to print a file it could + /// not find returned `Ok(())`. /// /// The optional calibration flags (`bed_levelling`, `flow_cali`, /// `timelapse`, ...) are deliberately left unset rather than defaulted @@ -566,7 +713,8 @@ impl BambuPrinter { /// 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. + /// fix continuum-schemas if it's wrong. The confirmed body above sets + /// neither, so this is still open. 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)))?; @@ -581,10 +729,11 @@ impl BambuPrinter { )) .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}")); + // `ftp:///` against the card's root - confirmed live, this + // exact spelling, see this method's doc comment. `upload_file` + // puts the file there, so the two agree by construction. + body["url"] = serde_json::Value::String(format!("ftp:///{file_name}")); body["param"] = serde_json::Value::String("Metadata/plate_1.gcode".into()); // Local print, no Bambu-cloud job behind it: these are all the @@ -600,14 +749,17 @@ impl BambuPrinter { 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}")); + // The root, for the same reason: it is where `upload_file` puts + // things. Unverified - see this method's doc comment, which + // records what the hardware said about the `cache/` spelling. + body["param"] = serde_json::Value::String(format!("/{file_name}")); } - // 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 + // A correlated `request`, not a fire-and-forget publish. This was + // the latter until a P1P was asked to print a file it didn't have + // and happily returned `Ok(())` - see `print_command_outcome`. + let reply = session.request("print", body).await?; + Self::print_command_outcome(&self.base.name, command, &reply) } /// The current in-memory `print.push_status` object - `None` until at @@ -622,6 +774,44 @@ impl BambuPrinter { } impl GenericPrinter for BambuPrinter { + /// OVERRIDE: one MQTT publish in the `print` category. Bambu's command + /// names are exactly `pause`/`resume`/`stop`, and all three schemas take + /// nothing but the envelope plus an empty `param`, so the schema-driven + /// builder produces the whole body. + async fn control_print(&mut self, action: super::PrintControl) -> Result<(), PrinterError> { + let (session, version) = self + .connection + .as_ref() + .ok_or_else(|| PrinterError(format!("{} is not connected", self.base.name)))?; + + let body = continuum_schema_instance::build_instance_from_file(&get_schema_for_command( + *version, + "print".into(), + action.as_str().into(), + )) + .map_err(|err| PrinterError(format!("failed to build print {} request: {err}", action.as_str())))?; + + // Correlated, for the same reason as `start_uploaded_file`: a + // pause that the printer refused must not read as a pause that + // happened. + // + // Confirmed live on a P1P (2026-10-11): all three reply, echoing + // the `sequence_id` this crate generated, as + // `{"command":"pause","sequence_id":"cxproxy-1", + // "reason":"success","result":"success"}`. + // + // Note what that reply does *not* mean. All three answered + // `result: "success"` while the printer was IDLE with nothing to + // pause, resume or stop. So `Ok(())` here means the printer + // accepted the command, not that the print state changed - a + // caller that needs to know the printer actually paused has to + // read `gcode_state` back via `print_status()`. The value of the + // reply is the negative case: a rejection is no longer silent. + let reply = session.request("print", body).await?; + Self::print_command_outcome(&self.base.name, action.as_str(), &reply) + } + + fn base(&self) -> &PrinterBase { &self.base } diff --git a/src/printer/bambu_ftps.rs b/src/printer/bambu_ftps.rs index 3e7199d..9775517 100644 --- a/src/printer/bambu_ftps.rs +++ b/src/printer/bambu_ftps.rs @@ -46,6 +46,26 @@ const FTPS_USER: &str = "bblp"; /// 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. @@ -58,6 +78,7 @@ pub fn upload( host: &str, access_code: &str, connector: native_tls::TlsConnector, + dir: &str, file_name: &str, data: &[u8], ) -> anyhow::Result { @@ -73,7 +94,12 @@ pub fn upload( // either way, letting the server treat the stream as text is free // corruption. ftp.transfer_type(FileType::Binary)?; - ftp.cwd(CACHE_DIR)?; + // `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 @@ -89,3 +115,39 @@ pub fn upload( Ok(written) } + +/// 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) +} diff --git a/src/printer/klipper.rs b/src/printer/klipper.rs index 0faf4be..b9c28fc 100644 --- a/src/printer/klipper.rs +++ b/src/printer/klipper.rs @@ -13,6 +13,20 @@ impl KlipperPrinter { } impl GenericPrinter for KlipperPrinter { + /// OVERRIDE: real version is `POST /printer/print/pause`, `/resume` or + /// `/cancel` on Moonraker - note Klipper calls it "cancel" where Bambu + /// calls it "stop". + async fn control_print(&mut self, action: super::PrintControl) -> Result<(), PrinterError> { + let endpoint = match action { + super::PrintControl::Pause => "pause", + super::PrintControl::Resume => "resume", + super::PrintControl::Stop => "cancel", + }; + println!("[{}] POST /printer/print/{endpoint}", self.base.name); + Ok(()) + } + + fn base(&self) -> &PrinterBase { &self.base } diff --git a/src/printer/mod.rs b/src/printer/mod.rs index b4c5600..177953a 100644 --- a/src/printer/mod.rs +++ b/src/printer/mod.rs @@ -111,6 +111,17 @@ pub trait GenericPrinter { /// difference lives, same division of labor as every other method here. async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError>; + /// REQUIRED, no default body: pause, resume or cancel the print that is + /// already running. Each vendor has one mechanism for all three - an + /// MQTT `print` command, a PrusaLink job endpoint, a Klipper macro, a + /// Marlin gcode word - so they arrive here as one call taking which. + /// + /// There is deliberately no "start a print" counterpart on this trait. + /// Starting one needs a file that is already on the printer, which is + /// `upload_project_file`'s job, and the two have very different + /// consequences; see `BambuPrinter::start_uploaded_file`. + async fn control_print(&mut self, action: PrintControl) -> Result<(), PrinterError>; + /// OPTIONAL: a default body every printer gets for free unless it /// writes its own. None of ours do below, so all three vendors share /// this exact implementation - the trait-method equivalent of @@ -131,6 +142,36 @@ fn percent_to_pwm(percent: u8) -> u8 { } +/// The three things you can do to a print that is already running. +/// +/// One enum and one trait method rather than three separate methods: every +/// vendor implements all three the same way it implements any of them (one +/// MQTT command for Bambu, one REST call for PrusaLink, one macro for +/// Klipper, one gcode word for Marlin), so splitting them would triple the +/// delegation boilerplate without separating anything that differs. +/// +/// `Stop` is Bambu's own word for it and the one the schemas use. It +/// cancels the print - it is not a pause you can come back from. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PrintControl { + Pause, + Resume, + Stop, +} + +impl PrintControl { + /// The vendor command name. Bambu's `print` category happens to use + /// exactly these three words, so this doubles as the MQTT command. + pub fn as_str(self) -> &'static str { + match self { + Self::Pause => "pause", + Self::Resume => "resume", + Self::Stop => "stop", + } + } +} + /// The closed set of printer kinds this fleet can talk to. pub enum PrinterHandle { Bambu(BambuPrinter), @@ -190,6 +231,15 @@ impl PrinterHandle { // only "boilerplate tax" for not having inheritance - everything else reads // like normal code. impl GenericPrinter for PrinterHandle { + async fn control_print(&mut self, action: PrintControl) -> Result<(), PrinterError> { + match self { + PrinterHandle::Bambu(p) => p.control_print(action).await, + PrinterHandle::PrusaLink(p) => p.control_print(action).await, + PrinterHandle::PrusaSerial(p) => p.control_print(action).await, + PrinterHandle::Klipper(p) => p.control_print(action).await, + } + } + fn base(&self) -> &PrinterBase { match self { PrinterHandle::Bambu(p) => p.base(), diff --git a/src/printer/prusa.rs b/src/printer/prusa.rs index ff635e0..283b009 100644 --- a/src/printer/prusa.rs +++ b/src/printer/prusa.rs @@ -17,6 +17,16 @@ impl PrusaLinkPrinter { } impl GenericPrinter for PrusaLinkPrinter { + /// OVERRIDE: real version is `PUT /api/v1/job/{id}/pause`, `/resume`, or + /// `DELETE /api/v1/job/{id}` to cancel - PrusaLink addresses the running + /// job by id rather than the printer, so a real implementation has to + /// fetch the current job first. + async fn control_print(&mut self, action: super::PrintControl) -> Result<(), PrinterError> { + println!("[{}] PrusaLink job control: {}", self.base.name, action.as_str()); + Ok(()) + } + + fn base(&self) -> &PrinterBase { &self.base } @@ -86,6 +96,19 @@ impl PrusaSerialPrinter { } impl GenericPrinter for PrusaSerialPrinter { + /// OVERRIDE: Marlin has a gcode word for each - `M25` pause, `M24` + /// resume, `M524` abort - so this goes out over the port like any other + /// line, the same way `set_fan_speed` does. + async fn control_print(&mut self, action: super::PrintControl) -> Result<(), PrinterError> { + let word = match action { + super::PrintControl::Pause => "M25", + super::PrintControl::Resume => "M24", + super::PrintControl::Stop => "M524", + }; + self.send_gcode_line(word.to_string()).await + } + + fn base(&self) -> &PrinterBase { &self.base } diff --git a/src/uplink/mod.rs b/src/uplink/mod.rs index 7226913..73634c4 100644 --- a/src/uplink/mod.rs +++ b/src/uplink/mod.rs @@ -224,5 +224,8 @@ async fn dispatch(fleet: &SharedFleet, printer_id: &str, command: PrinterCommand PrinterCommand::SendGcodeLine { line } => { printer.send_gcode_line(line).await.map_err(|err| err.to_string()) } + PrinterCommand::ControlPrint { control } => { + printer.control_print(control).await.map_err(|err| err.to_string()) + } } } diff --git a/src/uplink/protocol.rs b/src/uplink/protocol.rs index 2a00090..37e3fe2 100644 --- a/src/uplink/protocol.rs +++ b/src/uplink/protocol.rs @@ -1,5 +1,7 @@ use serde::{Deserialize, Serialize}; +use crate::printer::PrintControl; + /// What this gateway sends up to `continuum-backend`'s `/ws/edge/v1` route. #[derive(Debug, Clone, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] @@ -105,10 +107,12 @@ pub enum ServerMessage { /// The closed set of things the cloud is allowed to ask a printer to do. /// /// Deliberately small, and deliberately missing the obvious one: there is no -/// "start a print" variant. `BambuPrinter::start_uploaded_file` exists but -/// has never been run against real hardware, and the first thing to exercise -/// it should be a human watching the machine - not a message arriving from -/// the internet. Add it here once it's been confirmed by hand. +/// "start a print" variant. `BambuPrinter::start_uploaded_file` has now been +/// confirmed by hand on a real P1P (2026-10-11), so the original reason for +/// leaving it out - never having been run against hardware - is spent. What +/// is still missing is the path around it: nothing uploads a sliced file to a +/// gateway, and nothing decides which printer should run which job. A start +/// variant belongs here once there is something to start. #[derive(Debug, Clone, Deserialize)] #[serde(tag = "action", rename_all = "snake_case")] pub enum PrinterCommand { @@ -118,4 +122,15 @@ pub enum PrinterCommand { /// A single raw gcode line. The most powerful variant by far, and the /// one to be most careful about ever exposing to a UI. SendGcodeLine { line: String }, + /// Pause, resume or stop whatever the printer is currently running. + /// + /// One variant carrying an action rather than three variants, matching + /// `PrintControl` and `GenericPrinter::control_print` - the alternative + /// was three near-identical arms here and three near-identical methods + /// across four vendor implementations. + /// + /// `Stop` is a cancel and is not undoable. Confirmed live on a P1P: a + /// stop during `PREPARE`/`RUNNING` moves the printer to `FAILED`, which + /// is how Bambu reports an operator-cancelled print. + ControlPrint { control: PrintControl }, } diff --git a/tests/uplink_protocol.rs b/tests/uplink_protocol.rs index 4b73bda..1e09dad 100644 --- a/tests/uplink_protocol.rs +++ b/tests/uplink_protocol.rs @@ -12,6 +12,7 @@ //! mirrors; the canonical shape is //! `continuum-schemas/schemas/continuum/v1/edge/`. +use continuum_proxy::printer::PrintControl; use continuum_proxy::uplink::{ClientMessage, PrinterCommand, PrinterTelemetry, ServerMessage}; /// Verbatim from the backend's `src/routes/ws/edge.ts`. If the backend @@ -22,6 +23,9 @@ const BACKEND_MESSAGES: &[&str] = &[ r#"{"type":"heartbeat_ack","seq":7}"#, r#"{"type":"command","command_id":"9a7d2f10-0000-4000-8000-000000000000","printer_id":"p1p","command":{"action":"set_fan_speed","percent":40}}"#, r#"{"type":"command","command_id":"9a7d2f11-0000-4000-8000-000000000000","printer_id":"p1p","command":{"action":"send_gcode_line","line":"M106 S0"}}"#, + r#"{"type":"command","command_id":"9a7d2f12-0000-4000-8000-000000000000","printer_id":"p1p","command":{"action":"control_print","control":"pause"}}"#, + r#"{"type":"command","command_id":"9a7d2f13-0000-4000-8000-000000000000","printer_id":"p1p","command":{"action":"control_print","control":"resume"}}"#, + r#"{"type":"command","command_id":"9a7d2f14-0000-4000-8000-000000000000","printer_id":"p1p","command":{"action":"control_print","control":"stop"}}"#, ]; #[test] @@ -50,15 +54,61 @@ fn a_command_arrives_with_its_payload_intact() { } } -/// The variant that is deliberately absent. When this test starts failing -/// because someone added it, that is the moment to check the print-start path -/// has actually been run against real hardware first. +/// Each control verb has to arrive as the right `PrintControl`, not merely +/// parse. A silent mis-mapping here would send a stop where a pause was +/// asked for, which on a real machine is the difference between a print that +/// can be resumed and one that is over. +#[test] +fn each_print_control_verb_maps_to_its_own_action() { + for (verb, expected) in [ + ("pause", PrintControl::Pause), + ("resume", PrintControl::Resume), + ("stop", PrintControl::Stop), + ] { + let raw = format!( + r#"{{"type":"command","command_id":"c","printer_id":"p1p","command":{{"action":"control_print","control":"{verb}"}}}}"# + ); + match serde_json::from_str::(&raw).expect("should parse") { + ServerMessage::Command { command: PrinterCommand::ControlPrint { control }, .. } => { + assert_eq!(control, expected, "'{verb}' mapped to the wrong action"); + // And the wire word the vendor layer sends has to match the + // wire word that arrived - `as_str` is what builds the Bambu + // command name. + assert_eq!(control.as_str(), verb); + } + other => panic!("wrong variant for '{verb}': {other:?}"), + } + } +} + +/// "cancel" is the trap: it is Moonraker's word, and the Klipper +/// implementation behind `control_print` really does send a cancel - but the +/// wire word is "stop". A verb only one vendor recognises must be rejected +/// here rather than reaching a gateway that cannot map it. +#[test] +fn a_vendor_specific_control_verb_is_rejected() { + for verb in ["cancel", "abort", "Pause", ""] { + let raw = format!( + r#"{{"type":"command","command_id":"c","printer_id":"p1p","command":{{"action":"control_print","control":"{verb}"}}}}"# + ); + assert!( + serde_json::from_str::(&raw).is_err(), + "'{verb}' parsed as a print control verb and should not have" + ); + } +} + +/// The variant that is deliberately absent - still, but no longer for the +/// original reason. The print-start path *has* now been confirmed on a real +/// P1P (2026-10-11); what is missing is the path around it, since nothing +/// uploads a sliced file to a gateway and nothing decides which printer runs +/// which job. Delete this test when there is something to start. #[test] fn there_is_no_start_print_command() { let raw = r#"{"type":"command","command_id":"x","printer_id":"p1p","command":{"action":"start_print","file_name":"plate_1.3mf"}}"#; assert!( serde_json::from_str::(raw).is_err(), - "a start-print command parsed - has the hardware test been done?" + "a start-print command parsed - is there a job path to drive it yet?" ); }