A second live run, this time against a project the owner sliced in Bambu Studio rather than a file already on the card, and through `start_uploaded_file` itself rather than a hand-built body - so the root-vs-cache fix is confirmed end to end. `gcode_file` in the printer's own status came back as `cube-test-2026-10-11.gcode.3mf`, 150 layers, print_type `local`. It also found a defect the first run could not. The print heated to 250C, reached 1%, and then the printer paused itself: `print_error: 50364420` (`0x03008004`), `hms: []`, `tray_now: 255`. 255 is the external spool, which is exactly what the hardcoded `use_ams: false` selects - so that pair overrode the slicer's own filament choice and pointed an AMS-sliced project at an external spool. The first run looked fine only because it was stopped during PREPARE, before filament mattered. The doc comment said that pair was "accepted, and the print ran"; it now says what it actually does, because silently changing which filament a print uses is worse than not supporting AMS at all. The `start` guard demanded IDLE, which meant the harness refused every print following a cancelled one - a Bambu reports an operator cancellation as FAILED. IDLE, FINISH and FAILED are all terminal; PREPARE, RUNNING, PAUSE and SLICING mean the machine is committed, and anything unrecognised is still refused rather than assumed idle. `bambu_cache_list` now prints nozzle/bed temperatures and remaining time. State plus layer number cannot tell a print that is working from one that is stalled: a P1P sits at layer 0 for minutes during bed levelling and purge, and the only thing moving in that window is the heat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
241 lines
11 KiB
Rust
241 lines
11 KiB
Rust
//! 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");
|
|
};
|
|
// The guard's job is "don't start a print on a machine that may
|
|
// already be working", and IDLE is not the only state that
|
|
// satisfies it. FINISH and FAILED are both terminal - FAILED is
|
|
// what a Bambu reports after an operator cancellation, so
|
|
// demanding IDLE made the harness refuse every print following a
|
|
// cancelled one, which is the normal case when testing. PREPARE,
|
|
// RUNNING, PAUSE and SLICING all mean the machine is committed,
|
|
// and anything unrecognised is refused rather than assumed idle.
|
|
const READY_TO_START: [&str; 3] = ["IDLE", "FINISH", "FAILED"];
|
|
if !READY_TO_START.contains(&state.as_str()) {
|
|
anyhow::bail!(
|
|
"printer reports '{state}', which is not one of {READY_TO_START:?} - \
|
|
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}");
|
|
}
|