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 `<name>.gcode.3mf` archives, `cache/` holds the printer's own
extracted `<project>_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).
This commit is contained in:
2026-10-11 17:38:27 +00:00
parent 588ca85842
commit 939c35fb91
10 changed files with 776 additions and 49 deletions
+89
View File
@@ -0,0 +1,89 @@
//! Run with: cargo run --example bambu_cache_list -- <printer-id> [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 -- <printer-id> [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(())
}
+231
View File
@@ -0,0 +1,231 @@
//! Run with: cargo run --example bambu_print_probe -- <printer-id> <step> [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 <file>` 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<String> = 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 -- <printer-id> <step> [arg]\n\
steps: status | probe-missing | start <file-in-cache> | 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 <json>` 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<String> {
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<i32> = 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}");
}
+230 -40
View File
@@ -497,6 +497,10 @@ impl BambuPrinter {
/// ///
/// Returns however many bytes the printer's FTPS server accepted, so a /// Returns however many bytes the printer's FTPS server accepted, so a
/// caller can check that against what it sent. /// 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<u8>) -> Result<u64, PrinterError> { pub async fn upload_file(&mut self, file_name: &str, data: Vec<u8>) -> Result<u64, PrinterError> {
// Trust gets resolved out here, on the async side, so the blocking // Trust gets resolved out here, on the async side, so the blocking
// closure below owns everything it needs and borrows nothing. // closure below owns everything it needs and borrows nothing.
@@ -510,7 +514,14 @@ impl BambuPrinter {
let name = file_name.to_string(); let name = file_name.to_string();
tokio::task::spawn_blocking(move || { 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 .await
// The outer error is `spawn_blocking`'s own: the thread panicked or // 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 /// What is currently staged in the printer's `cache/` directory.
/// `cache/` directory. `upload_project_file` calls this straight after ///
/// its FTPS transfer; it's also public on its own, so a file uploaded /// Read-only. Worth having on its own because `start_uploaded_file` can
/// on some earlier run can be reprinted without sending the bytes /// only print what is already in there, so this answers "will that
/// again. /// command find anything?" without sending it.
pub async fn list_cache(&self) -> Result<Vec<String>, 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<Vec<String>, 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<serde_json::Value, PrinterError> {
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 /// 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, /// - A `.3mf` is a project archive - a container with slicer settings,
/// plate layout and one or more gcode files inside it. `print. /// plate layout and one or more gcode files inside it.
/// project_file` takes `url` (where the archive is) and `param` (which /// `print.project_file` takes `url` (where the archive is) and `param`
/// gcode file *inside* the archive to run). /// (which gcode file *inside* the archive to run).
/// - A bare `.gcode` has no container and nothing to select, so /// - A bare `.gcode` has no container and nothing to select, so
/// `print.gcode_file` just takes `param`: the full path on the /// `print.gcode_file` just takes `param`: the path on the printer's
/// printer's filesystem. /// filesystem.
/// ///
/// **Not yet confirmed against a physical printer** - unlike almost /// ## The `.3mf` branch is confirmed live
/// 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 /// On a P1P, 2026-10-11, this exact body started a real print
/// `Metadata/plate_1.gcode`, i.e. always the first plate. Correct for /// (`gcode_state: IDLE -> PREPARE -> RUNNING`, then stopped from
/// a single-plate slice, which is the common case, but a multi-plate /// `control_print`):
/// 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. /// ```json
/// 2. The `url` form, `ftp:///cache/<file>`. It satisfies the schema's /// {"command":"project_file","param":"Metadata/plate_1.gcode",
/// `^(?:file|ftp)://` pattern, and the empty-host triple slash is /// "url":"ftp:///Cube.gcode.3mf","project_id":"0","profile_id":"0",
/// what Bambu Studio has been observed to send, but `file:///` over /// "task_id":"0","subtask_id":"0","subtask_name":"",
/// an absolute printer path is the other plausible spelling. /// "use_ams":false,"ams_mapping":""}
/// 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* /// That settles two of the three things this comment used to flag as
/// `use_ams: true`), so AMS support means filling both, not just /// guesses:
/// flipping the flag. ///
/// - **`url` spelling.** `ftp:///<name>` - 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 `/<file_name>` 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`, /// The optional calibration flags (`bed_levelling`, `flow_cali`,
/// `timelapse`, ...) are deliberately left unset rather than defaulted /// `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` /// 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` - /// (two l's) and Bambu's firmware has been seen using `bed_leveling` -
/// check which the printer accepts before relying on that field, and /// 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> { pub async fn start_uploaded_file(&self, file_name: &str) -> Result<(), PrinterError> {
let (session, version) = let (session, version) =
self.connection.as_ref().ok_or_else(|| PrinterError(format!("{} is not connected", self.base.name)))?; 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}")))?; .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 { if is_project {
body["url"] = serde_json::Value::String(format!("ftp:///{cache_path}")); // `ftp:///<name>` 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()); body["param"] = serde_json::Value::String("Metadata/plate_1.gcode".into());
// Local print, no Bambu-cloud job behind it: these are all the // 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["use_ams"] = serde_json::Value::Bool(false);
body["ams_mapping"] = serde_json::Value::String(String::new()); body["ams_mapping"] = serde_json::Value::String(String::new());
} else { } 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 // A correlated `request`, not a fire-and-forget publish. This was
// acknowledges by starting to print, which shows up in the ordinary // the latter until a P1P was asked to print a file it didn't have
// `print.push_status` stream - read that via `print_status()` // and happily returned `Ok(())` - see `print_command_outcome`.
// rather than waiting on a correlated reply here. let reply = session.request("print", body).await?;
session.publish("print", &body).await Self::print_command_outcome(&self.base.name, command, &reply)
} }
/// The current in-memory `print.push_status` object - `None` until at /// The current in-memory `print.push_status` object - `None` until at
@@ -622,6 +774,44 @@ impl BambuPrinter {
} }
impl GenericPrinter for 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 { fn base(&self) -> &PrinterBase {
&self.base &self.base
} }
+63 -1
View File
@@ -46,6 +46,26 @@ const FTPS_USER: &str = "bblp";
/// print something that already lives here. /// print something that already lives here.
pub const CACHE_DIR: &str = "cache"; 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 /// Blocking: opens an implicit-FTPS session to `host`, logs in with
/// `access_code`, and writes `data` to `cache/{file_name}`. Returns however /// `access_code`, and writes `data` to `cache/{file_name}`. Returns however
/// many bytes the server reported accepting. /// many bytes the server reported accepting.
@@ -58,6 +78,7 @@ pub fn upload(
host: &str, host: &str,
access_code: &str, access_code: &str,
connector: native_tls::TlsConnector, connector: native_tls::TlsConnector,
dir: &str,
file_name: &str, file_name: &str,
data: &[u8], data: &[u8],
) -> anyhow::Result<u64> { ) -> anyhow::Result<u64> {
@@ -73,7 +94,12 @@ pub fn upload(
// either way, letting the server treat the stream as text is free // either way, letting the server treat the stream as text is free
// corruption. // corruption.
ftp.transfer_type(FileType::Binary)?; 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 // `put_file` wants something that implements `Read`. `data` is already
// bytes in memory (see `GenericPrinter::upload_project_file`'s doc // bytes in memory (see `GenericPrinter::upload_project_file`'s doc
@@ -89,3 +115,39 @@ pub fn upload(
Ok(written) 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<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)
}
+14
View File
@@ -13,6 +13,20 @@ impl KlipperPrinter {
} }
impl GenericPrinter for 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 { fn base(&self) -> &PrinterBase {
&self.base &self.base
} }
+50
View File
@@ -111,6 +111,17 @@ pub trait GenericPrinter {
/// difference lives, same division of labor as every other method here. /// difference lives, same division of labor as every other method here.
async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError>; 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 /// OPTIONAL: a default body every printer gets for free unless it
/// writes its own. None of ours do below, so all three vendors share /// writes its own. None of ours do below, so all three vendors share
/// this exact implementation - the trait-method equivalent of /// 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. /// The closed set of printer kinds this fleet can talk to.
pub enum PrinterHandle { pub enum PrinterHandle {
Bambu(BambuPrinter), Bambu(BambuPrinter),
@@ -190,6 +231,15 @@ impl PrinterHandle {
// only "boilerplate tax" for not having inheritance - everything else reads // only "boilerplate tax" for not having inheritance - everything else reads
// like normal code. // like normal code.
impl GenericPrinter for PrinterHandle { 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 { fn base(&self) -> &PrinterBase {
match self { match self {
PrinterHandle::Bambu(p) => p.base(), PrinterHandle::Bambu(p) => p.base(),
+23
View File
@@ -17,6 +17,16 @@ impl PrusaLinkPrinter {
} }
impl GenericPrinter for 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 { fn base(&self) -> &PrinterBase {
&self.base &self.base
} }
@@ -86,6 +96,19 @@ impl PrusaSerialPrinter {
} }
impl GenericPrinter for 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 { fn base(&self) -> &PrinterBase {
&self.base &self.base
} }
+3
View File
@@ -224,5 +224,8 @@ async fn dispatch(fleet: &SharedFleet, printer_id: &str, command: PrinterCommand
PrinterCommand::SendGcodeLine { line } => { PrinterCommand::SendGcodeLine { line } => {
printer.send_gcode_line(line).await.map_err(|err| err.to_string()) 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())
}
} }
} }
+19 -4
View File
@@ -1,5 +1,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::printer::PrintControl;
/// What this gateway sends up to `continuum-backend`'s `/ws/edge/v1` route. /// What this gateway sends up to `continuum-backend`'s `/ws/edge/v1` route.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[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. /// 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 /// Deliberately small, and deliberately missing the obvious one: there is no
/// "start a print" variant. `BambuPrinter::start_uploaded_file` exists but /// "start a print" variant. `BambuPrinter::start_uploaded_file` has now been
/// has never been run against real hardware, and the first thing to exercise /// confirmed by hand on a real P1P (2026-10-11), so the original reason for
/// it should be a human watching the machine - not a message arriving from /// leaving it out - never having been run against hardware - is spent. What
/// the internet. Add it here once it's been confirmed by hand. /// 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)] #[derive(Debug, Clone, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")] #[serde(tag = "action", rename_all = "snake_case")]
pub enum PrinterCommand { pub enum PrinterCommand {
@@ -118,4 +122,15 @@ pub enum PrinterCommand {
/// A single raw gcode line. The most powerful variant by far, and the /// A single raw gcode line. The most powerful variant by far, and the
/// one to be most careful about ever exposing to a UI. /// one to be most careful about ever exposing to a UI.
SendGcodeLine { line: String }, 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 },
} }
+54 -4
View File
@@ -12,6 +12,7 @@
//! mirrors; the canonical shape is //! mirrors; the canonical shape is
//! `continuum-schemas/schemas/continuum/v1/edge/`. //! `continuum-schemas/schemas/continuum/v1/edge/`.
use continuum_proxy::printer::PrintControl;
use continuum_proxy::uplink::{ClientMessage, PrinterCommand, PrinterTelemetry, ServerMessage}; use continuum_proxy::uplink::{ClientMessage, PrinterCommand, PrinterTelemetry, ServerMessage};
/// Verbatim from the backend's `src/routes/ws/edge.ts`. If the backend /// 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":"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":"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":"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] #[test]
@@ -50,15 +54,61 @@ fn a_command_arrives_with_its_payload_intact() {
} }
} }
/// The variant that is deliberately absent. When this test starts failing /// Each control verb has to arrive as the right `PrintControl`, not merely
/// because someone added it, that is the moment to check the print-start path /// parse. A silent mis-mapping here would send a stop where a pause was
/// has actually been run against real hardware first. /// 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::<ServerMessage>(&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::<ServerMessage>(&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] #[test]
fn there_is_no_start_print_command() { 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"}}"#; let raw = r#"{"type":"command","command_id":"x","printer_id":"p1p","command":{"action":"start_print","file_name":"plate_1.3mf"}}"#;
assert!( assert!(
serde_json::from_str::<ServerMessage>(raw).is_err(), serde_json::from_str::<ServerMessage>(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?"
); );
} }