//! Run with: cargo run --example bambu_print_probe -- [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 ` 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 = 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 -- [arg]\n\ steps: status | probe-missing | start | 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 ` 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 { 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 = 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}"); }