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).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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(())
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 `<name>.gcode.3mf` files, the printable project
|
||||
/// archives Bambu Studio sends when it uploads a plate;
|
||||
/// - `cache/` holds 20 `<project>_plate_N.gcode` files with
|
||||
/// `<N>_<project>.gcode.bbl` sidecars - the printer's own extracted
|
||||
/// per-plate working copies.
|
||||
///
|
||||
/// Those extracted copies look like the obvious thing to print and are not:
|
||||
/// `print.gcode_file` with `param: "/cache/Cube_plate_1.gcode"` was
|
||||
/// rejected (`result: "fail"`) for a file that demonstrably exists, while
|
||||
/// `print.project_file` with `url: "ftp:///Cube.gcode.3mf"` against the
|
||||
/// root started a print. So uploads go here, not into `cache/`.
|
||||
pub const SD_ROOT: &str = "/";
|
||||
|
||||
/// Blocking: opens an implicit-FTPS session to `host`, logs in with
|
||||
/// `access_code`, and writes `data` to `cache/{file_name}`. Returns however
|
||||
/// many bytes the server reported accepting.
|
||||
@@ -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<u64> {
|
||||
@@ -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<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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
-4
@@ -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 },
|
||||
}
|
||||
|
||||
@@ -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::<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]
|
||||
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::<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?"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user