Windows discovery tool / windows (push) Successful in 53s
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. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
285 lines
13 KiB
Rust
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."
|
|
);
|
|
}
|
|
}
|