//! Finds printers on the network and prints `printers.toml` blocks for them. //! //! ```text //! cargo run --example discover_printers -- listen [seconds] # passive SSDP //! 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 //! ``` //! //! **Read-only, and sends no credentials.** `listen` only receives. `identify` //! and `sweep` open a TLS connection and read the certificate the printer //! presents, without an MQTT session and without an access code - a printer //! cannot tell the difference between this and a port scan, because that is //! all it is. //! //! Which subcommand to use depends on where the gateway sits. `listen` learns //! the most - the model and the operator's own name for the printer come only //! from SSDP - but multicast does not cross a router, so it finds nothing //! unless this host is on the printers' own segment. `sweep` works from //! anywhere that can route to them, and gets the serial, which is the field //! worth not retyping. Try `listen` first and fall back. //! //! It deliberately does not write `printers.toml`: the access code is on the //! printer's screen and nothing on the network can discover it, so every //! block printed here is incomplete by construction. `fleet::load` rejects an //! entry with an empty `access_code`, 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}; /// Long enough to catch Bambu's announcement interval a few times over. const LISTEN_SECONDS: u64 = 15; /// Per-host TLS probe budget. Generous for a LAN, but a sweep spends it on /// every address that is not there, so the batching in `sweep` matters more. const PROBE_TIMEOUT: Duration = Duration::from_secs(3); #[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: discover_printers listen [seconds] | raw [seconds] | identify ... | sweep " ); }; let found = match command { "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 { match discovery::identify(host, PROBE_TIMEOUT).await { Ok(candidate) => { println!("{host}: serial {}", candidate.serial.as_deref().unwrap_or("?")); found.push(candidate); } // Named explicitly, unlike in a sweep: the operator asked // about this address, so why it did not answer is the // answer they wanted. Err(err) => eprintln!("{host}: {err}"), } } found } "sweep" => { let subnet = args.get(1).ok_or_else(|| anyhow::anyhow!("sweep needs a subnet, e.g. 192.168.0.0/24"))?; // Expanded before anything is printed, so a refused subnet says // so instead of first claiming to be probing it. let hosts = discovery::hosts_in_subnet(subnet)?; println!("probing {} addresses in {subnet} on port {} ...", hosts.len(), discovery::BAMBU_MQTT_PORT); discovery::sweep_hosts(&hosts, PROBE_TIMEOUT).await } other => anyhow::bail!("unknown command {other:?} - try listen, raw, identify or sweep"), }; report(&found); Ok(()) } 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) => { for candidate in candidates { if !found.iter().any(|existing| existing.host == candidate.host) { found.push(candidate); } } } // 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 report(found: &[Candidate]) { 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 on." ); return; } println!("\n{} printer(s) found. Paste into printers.toml and add each access code:\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)); } }