Files
continuum-proxy/examples/bambu_print_probe.rs
T
octoturgeandClaude Opus 5 6766ebbe80 Clear the clippy warnings, except the two that are not cosmetic
Down from 29 warnings to 2, in the spirit of 4ab95ff.

The doc warnings were all one mistake made repeatedly: a paragraph wrapped so
that a line *began* with `- ` or `+ `, which rustdoc reads as a markdown list
marker and the following lines as unindented continuations. The prose always
meant an em-dash. Fixed by moving the dash to the end of the previous line and
re-wrapping; not one word changed, which is the point - these comments record
things confirmed against real printers and are worth more than the warning.
The genuine lists just had their continuations de-indented and stayed lists.

The code ones are `match x { V(v) => .., _ => {} }` to `if let`, and nested
`if let Some(x) = .. { if cond { .. } }` to a let-chain. None had an `else`,
so each is the same control flow written once instead of twice.

Two warnings are left deliberately, because fixing them is not cosmetic:
`AmsTray` (544 bytes against 32) and `BambuReport` (984 against 120) both have
one variant far larger than the rest. The fix clippy wants is a `Box`, which
changes how every reader of those enums is written and puts an allocation on
the push_status path that currently has none. Worth measuring before doing,
not worth doing to silence a warning.

51 tests still pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 21:43:06 +00:00

286 lines
12 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::{FilamentSource, 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";
/// Every tray a P1P with one AMS has: 4 AMS slots plus the external spool.
/// `ams_mapping` has to be padded to this length - see the schema.
const P1P_TRAY_COUNT: usize = 5;
#[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, &FilamentSource::ExternalSpool)
.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."
);
}
// `--ams=3` or `--ams=3,-1,0` picks AMS slots per project
// filament index; without it, the external spool. There is no
// default that is safe for both, so the flag has to be explicit.
let filament = match args.iter().find_map(|a| a.strip_prefix("--ams=")) {
Some(list) => {
let slots = list
.split(',')
.map(|slot| slot.trim().parse::<i32>())
.collect::<Result<Vec<_>, _>>()
.map_err(|err| anyhow::anyhow!("--ams needs a comma-separated list of slot numbers: {err}"))?;
// 4 AMS slots + 1 external spool on a P1P with one AMS.
// See the padding rule in the ams_mapping schema.
FilamentSource::ams(slots, P1P_TRAY_COUNT)
.map_err(|err| anyhow::anyhow!("{err}"))?
}
None => FilamentSource::ExternalSpool,
};
match &filament {
FilamentSource::ExternalSpool => {
println!("\n starting print of '{file}' from the EXTERNAL SPOOL ...")
}
FilamentSource::Ams(slots) => {
println!("\n starting print of '{file}' with ams_mapping {slots:?} ...")
}
}
match bambu.start_uploaded_file(&file, &filament).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}"),
}
}
// `fetch <path> <dest>` copies one file off the card. Read-only.
"fetch" => {
let (Some(path), Some(dest)) = (args.get(3), args.get(4)) else {
anyhow::bail!("`fetch` needs a path on the printer and a local destination");
};
match bambu.download_file(path).await {
Ok(bytes) => {
std::fs::write(dest, &bytes)?;
println!(" {} bytes -> {dest}", bytes.len());
}
Err(err) => anyhow::bail!("fetch failed: {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
&& 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()
&& 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
&& !hms.is_empty()
{
println!(" hms (printer alerts): {}", serde_json::to_string(hms).unwrap_or_default());
}
}
}
println!(" final state: {last}");
}