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:
+230
-40
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user