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
+230 -40
View File
@@ -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<u8>) -> Result<u64, PrinterError> {
// 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<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
/// 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/<file>`. 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:///<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`,
/// `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:///<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());
// 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
}