//! Finds printers and prints `printers.toml` blocks for them. //! //! ```text //! cargo run --example discover_printers -- all [a.b.c.0/24] # everything safe //! cargo run --example discover_printers -- listen [seconds] # passive SSDP (Bambu) //! cargo run --example discover_printers -- raw [seconds] # ... and dump every datagram //! cargo run --example discover_printers -- identify ... # one known address //! cargo run --example discover_printers -- sweep # every address on a segment //! cargo run --example discover_printers -- serial # USB/serial ports, passive //! cargo run --example discover_printers -- serial-probe [baud] --yes-reset-the-printer //! ``` //! //! Everything except `serial-probe` is **read-only and sends no credentials**. //! `listen` only receives. `identify` and `sweep` open a connection and read //! what the far end volunteers - a TLS certificate, an HTTP identity endpoint //! - without a session or a key. `serial` reads sysfs and opens no device. //! //! **`serial-probe` resets the printer**, because opening a serial port //! toggles DTR on an Arduino-based board. That ruins a print in progress, so //! it refuses to run without `--yes-reset-the-printer`, the same shape as //! `bambu_print_probe`'s `--yes-start-a-real-print`. //! //! Which network subcommand to use depends on where the gateway sits. //! `listen` learns the most about a Bambu printer - the model and the //! operator's own name come only from SSDP - but multicast does not cross a //! router, so it finds nothing unless this host is on the printers' segment. //! `sweep` works from anywhere that can route to them, and covers Bambu, //! Klipper and PrusaLink. `all` does both and merges the result. //! //! It deliberately does not write `printers.toml`: a Bambu access code is on //! the printer's screen and a PrusaLink API key is in its web UI, so every //! block printed here is incomplete by construction. `fleet::load` rejects an //! entry whose credential is empty, which means pasting one in and forgetting //! to finish it fails loudly at the next start rather than quietly. use std::collections::HashSet; use std::time::Duration; use continuum_proxy::discovery::{self, Candidate, net, serial}; /// Long enough to catch Bambu's announcement interval a few times over. const LISTEN_SECONDS: u64 = 15; /// Per-host probe budget. Generous for a LAN, but a sweep spends it on every /// address that is not there, so the batching in `sweep_hosts` matters more. const PROBE_TIMEOUT: Duration = Duration::from_secs(3); /// A serial handshake has to wait out a board reset before it can even ask. const SERIAL_TIMEOUT: Duration = Duration::from_secs(10); #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) .init(); let args: Vec = std::env::args().skip(1).collect(); let Some(command) = args.first().map(String::as_str) else { anyhow::bail!(usage()); }; let found = match command { "all" => { let mut found = listen(LISTEN_SECONDS, false).await?; match args.get(1) { Some(subnet) => found.extend(sweep(subnet).await?), // Without a subnet the sweep is skipped rather than guessed: // picking one would mean probing a network nobody named. None => println!("(no subnet given, so no sweep - pass one like 192.168.0.0/24 to include it)"), } found.extend(list_serial()?); found } "listen" | "raw" => { let seconds = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(LISTEN_SECONDS); listen(seconds, command == "raw").await? } "identify" => { let hosts = &args[1..]; if hosts.is_empty() { anyhow::bail!("identify needs at least one host"); } let mut found = Vec::new(); for host in hosts { let candidates = net::identify_any(host, PROBE_TIMEOUT).await; if candidates.is_empty() { // Named explicitly, unlike in a sweep: the operator asked // about this address, so silence is the wrong answer. eprintln!("{host}: nothing answered as a Bambu, Klipper or PrusaLink printer"); } for candidate in &candidates { println!("{host}: {} - {}", candidate.vendor.toml_value(), describe(candidate)); } found.extend(candidates); } found } "sweep" => { let subnet = args.get(1).ok_or_else(|| anyhow::anyhow!("sweep needs a subnet, e.g. 192.168.0.0/24"))?; sweep(subnet).await? } "serial" => list_serial()?, "serial-probe" => { let port = args.get(1).ok_or_else(|| anyhow::anyhow!("serial-probe needs a port, e.g. /dev/ttyACM0"))?; let baud = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(serial::DEFAULT_BAUD_RATE); if !args.iter().any(|a| a == "--yes-reset-the-printer") { anyhow::bail!( "opening {port} will RESET the printer on it - on an Arduino-based board the \ port open toggles DTR, which reboots the machine and ruins any print in \ progress. Re-run with --yes-reset-the-printer if nothing is printing. \ `serial` (no probe) lists ports without opening any." ); } println!("opening {port} at {baud} baud - this resets the board; waiting for it to boot ..."); vec![serial::probe_m115(port, baud, SERIAL_TIMEOUT).await?] } other => anyhow::bail!("unknown command {other:?}\n{}", usage()), }; report(discovery::merge(found)); Ok(()) } fn usage() -> String { "usage: discover_printers all [subnet] | listen [seconds] | raw [seconds] | identify ... \ | sweep | serial | serial-probe [baud] --yes-reset-the-printer" .to_string() } async fn sweep(subnet: &str) -> anyhow::Result> { // Expanded before anything is printed, so a refused subnet says so // instead of first claiming to be probing it. let hosts = net::hosts_in_subnet(subnet)?; println!( "probing {} addresses in {subnet} - Bambu on {}, Klipper on {}, PrusaLink on {} ...", hosts.len(), net::BAMBU_MQTT_PORT, net::MOONRAKER_PORT, net::PRUSALINK_PORT ); Ok(net::sweep_hosts(&hosts, PROBE_TIMEOUT).await) } async fn listen(seconds: u64, raw: bool) -> anyhow::Result> { let window = Duration::from_secs(seconds); println!("listening {seconds}s on {:?} for {} ...", discovery::SSDP_PORTS, discovery::SSDP_GROUP); // Both ports at once, because which one a printer announces on depends on // its firmware and the operator has no way to know which. let listeners = discovery::SSDP_PORTS.map(|port| discovery::listen_ssdp(port, window, raw)); let mut found: Vec = Vec::new(); for result in futures_util::future::join_all(listeners).await { match result { Ok(candidates) => found.extend(candidates), // One port being unavailable - held by another process, or a // kernel that will not join the group - must not lose the other. Err(err) => eprintln!("warning: {err}"), } } Ok(found) } fn list_serial() -> anyhow::Result> { let ports = serial::list_ports()?; if ports.is_empty() { println!("no USB/serial ports found."); return Ok(Vec::new()); } println!("{} serial port(s):", ports.len()); for port in &ports { let confidence = if port.is_probably_a_printer() { "printer" } else { "maybe" }; println!(" {} [{confidence}] {}", port.path, port.to_candidate().detail.unwrap_or_default()); } // USB descriptors describe the bridge chip, so for anything but a Prusa // this is as far as passive detection goes - and the operator should know // that the next step has a cost. if ports.iter().any(|port| !port.is_probably_a_printer()) { println!( "\nUSB alone cannot tell a printer from any other serial device. `serial-probe `\n\ asks the board directly with M115, but opening the port RESETS it - don't while printing." ); } Ok(ports.iter().map(serial::SerialDevice::to_candidate).collect()) } fn describe(candidate: &Candidate) -> String { let mut parts = Vec::new(); if let Some(serial) = &candidate.serial { parts.push(format!("serial {serial}")); } if let Some(name) = &candidate.name { parts.push(name.clone()); } if let Some(detail) = &candidate.detail { parts.push(detail.clone()); } if parts.is_empty() { "no details available".to_string() } else { parts.join(", ") } } fn report(found: Vec) { if found.is_empty() { println!( "\nNo printers found.\n\ If that was `listen`: multicast does not cross a router, so a gateway on a\n\ different subnet from its printers will never hear them - use\n\ `sweep /24` instead. If it was `sweep`: check the subnet, and\n\ that the printers have LAN mode (Bambu) or their API enabled." ); return; } println!("\n{} printer(s) found. Paste into printers.toml and add each credential:\n", found.len()); let mut used: HashSet = HashSet::new(); for candidate in &found { // Two of the same model suggest the same id, and a duplicate `id` // would silently give the fleet two printers it cannot tell apart. let base = candidate.suggested_id(); let mut id = base.clone(); let mut n = 2; while !used.insert(id.clone()) { id = format!("{base}-{n}"); n += 1; } println!("{}", candidate.to_toml_entry(&id)); } if found.iter().any(|c| !c.vendor.driver_is_implemented()) { println!( "Note: some of these vendors' clients in printer/ are still stubs - those entries\n\ are marked, and a gateway will connect to them and do nothing useful yet." ); } }