Files
continuum-proxy/examples/discover_printers.rs
T
octoturge 9061d39684 Don't let a missing serial listing discard an 'all' discovery on Windows
Windows CI (run 415) ran the discovery tests: 48 passed and one failed,
because port listing reads Linux sysfs and the test assumed it exists.
That is a real limitation, not just a bad test - and in the example it
meant `all` would listen and sweep and then abort on the serial step,
throwing the results away.

The serial step in `all` is now a printed note on failure; the standalone
`serial` command still errors. The Linux-only test is gated to Linux and
a counterpart asserts that off Linux the listing is an error, not a clean
empty result.
2026-10-29 11:17:01 +00:00

285 lines
13 KiB
Rust

//! 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 <host>... # one known address
//! cargo run --example discover_printers -- sweep <a.b.c.0/24> # every address on a segment
//! cargo run --example discover_printers -- serial # USB/serial ports, passive
//! cargo run --example discover_printers -- wire [subnet] [--ssdp] # the frame the daemon sends
//! cargo run --example discover_printers -- serial-probe <port> [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<String> = 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)"),
}
// Not `?`: serial listing reads Linux sysfs and fails on any other
// OS, and that must not throw away what the listen and sweep found.
match list_serial() {
Ok(ports) => found.extend(ports),
Err(err) => println!("(serial ports skipped: {err})"),
}
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?]
}
// Not merged with the others, because this one answers a different
// question: not "what is out there" but "what exactly would the
// daemon send about it". So it runs the real `scan` - the same
// function the background task calls, with the same fleet filtering
// and the same refusal to open a serial port - and prints the frame.
//
// The reason to have it: the gateway and the backend agree about this
// message only by hand, and a frame captured from real hardware is
// the only thing that proves the two halves match. See
// `continuum-backend/tests/edge_protocol.test.ts`, which validates
// captures like this one.
"wire" => {
let settings = continuum_proxy::config::DiscoverySettings {
enabled: true,
subnets: args.get(1).map(|s| vec![s.clone()]).unwrap_or_default(),
// On by default here and off in the daemon: a human asked for
// this one and is waiting for the output.
ssdp: args.iter().any(|a| a == "--ssdp"),
serial: true,
interval: Duration::from_secs(60),
};
let path = std::env::var("CONTINUUM_PRINTERS_PATH").unwrap_or_else(|_| "printers.toml".into());
let configured = continuum_proxy::fleet::configured(std::path::Path::new(&path))?;
let scan = discovery::scan::scan(&settings, &configured).await;
if !scan.looked() {
anyhow::bail!("every discovery source failed, so there is nothing to report");
}
let printers: Vec<_> = scan.candidates.iter().map(Candidate::to_wire).collect();
let count = printers.len();
// The real enum through the real serializer, not a hand-built
// JSON object. A hand-built one would agree with this file rather
// than with the daemon, which is the whole thing this subcommand
// exists to rule out.
let frame = continuum_proxy::uplink::ClientMessage::DiscoveredPrinters {
printers,
scanned_at: discovery::report::now_rfc3339(),
};
println!("{}", serde_json::to_string_pretty(&frame)?);
eprintln!(
"({count} candidate(s) after removing anything {path} already names; point \
CONTINUUM_PRINTERS_PATH at an empty file to see them all)"
);
return Ok(());
}
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 <host>... \
| sweep <a.b.c.0/24> | serial | wire [subnet] [--ssdp] \
| serial-probe <port> [baud] --yes-reset-the-printer"
.to_string()
}
async fn sweep(subnet: &str) -> anyhow::Result<Vec<Candidate>> {
// 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<Vec<Candidate>> {
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<Candidate> = 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<Vec<Candidate>> {
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 <port>`\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<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 <their subnet>/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<String> = 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."
);
}
}