Discovery found only Bambu printers, so a mixed farm still meant typing most of printers.toml by hand. It now covers the four vendors fleet.rs accepts, over both transports. Detection splits in two, and keeping the halves apart is what makes this honest. "Something at this address speaks X" needs no credential from any vendor, and is most of the value - it yields the address and the vendor, which is two of the three or four fields an entry needs. "What is it called, what firmware does it run" only has an answer where the vendor publishes one unauthenticated: Bambu always, Moonraker usually, PrusaLink never. So an HTTP 401 counts as a find rather than a failure. A password prompt from /api/version is proof that something is serving /api/version, which is the question being asked. It also makes the Moonraker probe correct without having to know how a given host configured [authorization], which is not knowable from outside. Klipper is `GET :7125/printer/info`, Moonraker's own identity endpoint, which returns hostname and software_version. PrusaLink is `GET :80/api/version`; its OpenAPI specification puts digestAuth on every operation including /api/v1/info, so there is no unauthenticated identity endpoint at all and a 401 is the best available answer - which is enough, since the only field still missing is the API key no probe could supply. This cannot distinguish PrusaLink from OctoPrint, and that is not fixable from outside: PrusaLink deliberately implements OctoPrint's API. A wrong guess an operator can see and correct beats skipping a real printer. Two guards against claiming things that are not printers, both now tested against what a real LAN actually answers. Three devices on the test segment answer /api/version with HTTP 200 and a body that is not JSON - a router, a device with a logon page, and one serving gzip - so a 200 must carry OctoPrint's guaranteed `api` field, and Moonraker's must carry the `result` wrapper every Moonraker reply has. And a bare 401 is not enough either: routers, NASes and cameras all password protect port 80, so a 401 must carry a *Digest* challenge, which PrusaLink's spec guarantees and the Basic-auth crowd does not send. A sweep of the real /24 still finds exactly the two Bambu printers and nothing else. Serial detection is passive by default, and that is a safety property rather than a limitation. Opening a serial port toggles DTR, which reboots an Arduino-based board - marlin_serial.rs documents the same thing from the other direction - so a firmware handshake against a machine that happens to be printing ruins the print, and discovery is exactly what someone runs while a farm is busy. So list_ports opens nothing: it reads USB descriptors out of sysfs, which is where udev reads them from, and gets the manufacturer, product and USB serial without touching the device. That is enough to render an entry, since a serial printer's block needs only a path and a baud rate. probe_m115 is the opt-in extra that learns the firmware and the exact machine type, and it refuses to run without --yes-reset-the-printer, the same shape as bambu_print_probe's --yes-start-a-real-print. sysfs rather than tokio-serial's libudev feature: a gateway appliance should not need libudev-dev at build time to list its own ports, and reading the four files udev would read is thirty lines and no dependency. The walk climbs to the USB device holding idVendor rather than counting parents, because a ttyACM link points at an interface and a ttyUSB link at a usb-serial port, each a different depth down. Only Prusa's own USB vendor id counts as evidence of a printer. Everything else in the table is a generic bridge chip - FTDI, CH340, CP210x - that a printer is likely to be behind but that a GPS or an Arduino project could equally be, so those are reported with "could be a printer or any other serial device" rather than asserted. Entries now carry exactly the fields each vendor's loader demands and no others, with a test that fails if to_toml_entry and fleet.rs drift apart, and a rendered entry for a vendor whose client is still a stub says so - otherwise an operator pastes in a Klipper printer and gets a gateway that connects and does nothing. Found while testing the M115 parser: a value ran to end-of-input instead of end-of-line, so the last field on a line swallowed what followed and UUID read as "cede2a2f-...\nok". Unverified, and untestable here: the Klipper and PrusaLink probes (no such hardware) and all serial detection (no serial device attached, and /sys/class/tty holds only virtual consoles). Every parser those depend on is unit tested against recorded protocol text, including the real non-printer responses above.
234 lines
10 KiB
Rust
234 lines
10 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 -- 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)"),
|
|
}
|
|
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 <host>... \
|
|
| sweep <a.b.c.0/24> | serial | 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."
|
|
);
|
|
}
|
|
}
|