The gateway could already find printers; only a human reading a terminal could see the result. So adding a printer in the web console still began with walking to a machine to read its IP address off the screen. Now a background task scans on a timer and sends a `discovered_printers` frame. It cannot finish the job - the credential is on that screen and no probe can read it - but it removes the address, the serial and the guesswork about which vendor the thing is. OFF by default, which is the load-bearing part of this commit. A /24 sweep is 254 TLS handshakes and up to three HTTP requests per host, and nothing watching can tell that from a port scan; on a corporate network, being indistinguishable from a port scan is itself the problem. A gateway that did this by default would scan networks its operator never meant to point it at, and the first they would hear of it is from their own security team. So: - `discovery.enabled` is false unless set, per gateway, in that gateway's own config. The cloud cannot switch it on. A setting whose whole purpose is to bound what this process does to the local network has to be decided where that network is understood, which is not in a data centre. - `subnets` is explicit, never derived from this host's interfaces, so a second NIC, a VPN or a container bridge cannot widen the sweep. Anything wider than a /24 is still refused, now at config-import time rather than when the timer first fires. - A flag that is neither `true` nor `false` is an error, not a shrug. The two readings of a typo are not equally safe. - Naming a subnet without setting `enabled` scans nothing - the safe reading of an incomplete instruction - and `enabled` with nowhere to look is refused as the mistake it is. - The env var can switch it *off* over a file that enables it, which is the direction that has to work: a Proxy Box's config is signed in the cloud, so an operator who finds their gateway probing cannot just edit it. What a scheduled scan will not do, unlike the example CLI: send `M115`. Opening a serial port toggles DTR, which reboots an Arduino-based board and ruins a print in progress. The example can do it behind `--yes-reset-the-printer` because a human is standing there; a timer has nobody to ask, so it does not get the option and reads sysfs instead. Two things that took more care than expected: `Scan::looked()`. An empty report means "this gateway looked and its network has no unconfigured printers", so sending one after every probe failed would state something untrue about the farm. A partial failure is still evidence, though - withholding a good sweep because a container can never bind SSDP would suppress it forever - so the test is "did anything succeed", not "did everything". `fleet::configured()` reads the fleet file at parse level rather than through `load`, because the entry it most needs to recognise is the one `load` refuses: a printer discovery suggested, pasted in, with the access code not yet typed in. Re-offering that printer as a fresh find is noise at the one moment the operator is mid-way through adding it. Verified against the real LAN: a sweep found both printers by TLS certificate with correct serials and no credentials, and the frame it produced validates against the backend's EdgeClientMessageSchema. The `wire` subcommand on the example prints exactly that frame, and the capture is pinned as a backend fixture. SSDP remains untestable here - this container is a router hop from the printers and multicast does not cross one.
819 lines
37 KiB
Rust
819 lines
37 KiB
Rust
//! Finding printers, so setting up a farm does not mean reading five fields
|
|
//! off each machine's screen and retyping them into `printers.toml`.
|
|
//!
|
|
//! `printers.toml` stays the source of truth. Discovery deliberately does not
|
|
//! write it and does not add printers to a running fleet, because **it cannot
|
|
//! learn a credential** - a Bambu access code is on the printer's own screen
|
|
//! and a PrusaLink API key is in its web UI, which is the point of both. What
|
|
//! discovery produces is a *candidate*: everything except the secret,
|
|
//! rendered as a `[[printer]]` block for a human to complete. `fleet::load`
|
|
//! rejects an entry whose credential is empty, which is why this prints
|
|
//! rather than appends - writing a half-finished entry into the file would
|
|
//! stop the gateway starting.
|
|
//!
|
|
//! # What can be found, and how much
|
|
//!
|
|
//! Detection splits into two tiers, and keeping them apart is what makes this
|
|
//! honest rather than optimistic:
|
|
//!
|
|
//! - **"something at this address speaks X"** needs no credential for 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.
|
|
//! - **the details** - serial, model, firmware, the operator's own name for
|
|
//! the machine - only come from vendors that publish them unauthenticated.
|
|
//!
|
|
//! | Vendor | Probe | Tier 1 | Details |
|
|
//! |---|---|---|---|
|
|
//! | Bambu | TLS certificate on 8883 | yes | **serial number**, free |
|
|
//! | Bambu | SSDP on 2021/1990 | yes | model, name, bind state |
|
|
//! | Klipper | `GET :7125/printer/info` | yes | hostname + Klipper version, if its authorization allows |
|
|
//! | PrusaLink | `GET :80/api/version` | yes | none - every identity endpoint needs digest auth |
|
|
//! | Marlin over serial | USB descriptors in sysfs | yes | vendor/product strings, USB serial |
|
|
//! | Marlin over serial | `M115` handshake | yes | firmware and machine type - **but see the warning below** |
|
|
//!
|
|
//! # The three things worth knowing before extending this
|
|
//!
|
|
//! **Multicast does not cross a router.** SSDP finds the most about a Bambu
|
|
//! printer, but a gateway on a different VLAN from its printers hears
|
|
//! nothing, which is an ordinary way to run a workshop. Active probing is
|
|
//! what works from anywhere that can route to the machines, and that is why
|
|
//! the certificate read - not SSDP - is the dependable Bambu mechanism.
|
|
//!
|
|
//! **Opening a serial port resets the board.** On Arduino-based boards
|
|
//! toggling DTR, which opening the port does, reboots the printer -
|
|
//! `printer/marlin_serial.rs` documents this too. So a `M115` handshake on a
|
|
//! machine that happens to be printing *ruins the print*. Serial detection is
|
|
//! therefore passive by default: it reads USB descriptors out of sysfs and
|
|
//! opens nothing. The handshake is opt-in, per port, and says so.
|
|
//!
|
|
//! **Finding a printer is not being able to drive it.** The Klipper and
|
|
//! PrusaLink clients in `printer/` are stubs. Discovery still reports those
|
|
//! machines, because knowing what is on the network is useful before the
|
|
//! client exists, but a rendered entry for one carries a warning - otherwise
|
|
//! an operator pastes it in and gets a gateway that connects and does
|
|
//! nothing.
|
|
//!
|
|
//! # Verified, and not
|
|
//!
|
|
//! Verified live on 2026-10-19 against the only hardware available, two
|
|
//! Bambu printers: a P1P presented `CN = 01S09B441900143` (issuer `BBL CA`,
|
|
//! TLS 1.2) and an H2C `CN = 31B8BP5A1800585` (`BBL Device CA O1C2-V2`,
|
|
//! TLS 1.3), each an exact match for `printers.toml`, and a blind sweep of
|
|
//! their `/24` found exactly those two in 24s.
|
|
//!
|
|
//! **Unverified, and untestable from here:** SSDP (this host is routed to the
|
|
//! printers rather than on their segment, so their multicast cannot arrive),
|
|
//! the Klipper and PrusaLink probes (no such hardware), and all serial
|
|
//! detection (no serial device is attached, and `/sys/class/tty` holds
|
|
//! nothing but virtual consoles). Every parser those paths depend on is unit
|
|
//! tested against recorded protocol text, and each probe is tolerant by
|
|
//! design - a missing field costs a field, not the printer. `discover_printers
|
|
//! raw` dumps whatever actually arrives, so the first run on real hardware
|
|
//! shows its working rather than only its conclusion.
|
|
|
|
pub mod net;
|
|
pub mod report;
|
|
pub mod scan;
|
|
pub mod serial;
|
|
|
|
use std::net::{Ipv4Addr, SocketAddr, UdpSocket as StdUdpSocket};
|
|
use std::time::Duration;
|
|
|
|
use tokio::net::UdpSocket;
|
|
use tracing::{debug, warn};
|
|
|
|
/// The SSDP multicast group, which is the standard one - Bambu's discovery is
|
|
/// ordinary SSDP with vendor headers, not a protocol of its own.
|
|
pub const SSDP_GROUP: Ipv4Addr = Ipv4Addr::new(239, 255, 255, 250);
|
|
|
|
/// Both ports Bambu has been seen to announce on. 2021 is the current one;
|
|
/// 1990 is older firmware, and listening on both costs a socket.
|
|
pub const SSDP_PORTS: [u16; 2] = [2021, 1990];
|
|
|
|
/// Which kind of printer a candidate is, which decides what its entry needs.
|
|
///
|
|
/// These map one-to-one onto the `vendor` strings `fleet.rs` accepts, and the
|
|
/// mapping is the whole reason this enum exists rather than a string: a typo
|
|
/// in a rendered `vendor` produces a config file that fails to load, and the
|
|
/// compiler can prevent that.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Vendor {
|
|
Bambu,
|
|
Klipper,
|
|
/// Prusa's own implementation of the OctoPrint API, on an MK4/XL/MINI.
|
|
PrusaLink,
|
|
/// OctoPrint itself, or anything else serving its API.
|
|
///
|
|
/// This is the honest default for a printer found by probing, because
|
|
/// what a probe observes is "something here speaks the OctoPrint API" -
|
|
/// never which implementation. Reporting `prusa_link` would assert a
|
|
/// product nothing on the wire names.
|
|
Octoprint,
|
|
/// Marlin-family firmware over a raw serial port. Named for Prusa
|
|
/// because that is what `fleet.rs` calls it, but
|
|
/// `printer/marlin_serial.rs` drives any Marlin board the same way -
|
|
/// there is nothing Prusa-specific in the transport.
|
|
PrusaSerial,
|
|
}
|
|
|
|
impl Vendor {
|
|
/// The exact string `fleet.rs` expects in `vendor`.
|
|
pub fn toml_value(self) -> &'static str {
|
|
match self {
|
|
// Never a guessed `bambu_v1`/`bambu_v2`: this value auto-detects
|
|
// the MQTT dialect on connect, and since a firmware update can
|
|
// move a physical printer between them, a guess written into a
|
|
// file goes stale silently.
|
|
Vendor::Bambu => "bambu",
|
|
Vendor::Klipper => "klipper",
|
|
Vendor::PrusaLink => "prusa_link",
|
|
Vendor::Octoprint => "octoprint",
|
|
Vendor::PrusaSerial => "prusa_serial",
|
|
}
|
|
}
|
|
|
|
/// Whether `printer/` can actually drive this vendor yet.
|
|
///
|
|
/// Only Bambu is real. Reporting a printer the gateway cannot drive is
|
|
/// still worth doing - it answers "what is on this network?" - but it has
|
|
/// to be said out loud, or the entry looks finished when it is not.
|
|
pub fn driver_is_implemented(self) -> bool {
|
|
match self {
|
|
Vendor::Bambu => true,
|
|
// `printer/klipper.rs` and `printer/prusa.rs` are stubs whose
|
|
// methods return without doing anything.
|
|
Vendor::Klipper | Vendor::PrusaLink | Vendor::Octoprint => false,
|
|
// `marlin_serial.rs` is a real transport, but
|
|
// `PrusaSerialPrinter`'s command methods on top of it are not.
|
|
Vendor::PrusaSerial => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// How a candidate was found, which bounds how much it can know.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Source {
|
|
/// A Bambu SSDP announcement.
|
|
Ssdp,
|
|
/// The subject CN of the TLS certificate on Bambu's MQTT port.
|
|
Certificate,
|
|
/// An unauthenticated HTTP identity endpoint.
|
|
HttpApi,
|
|
/// USB descriptors read out of sysfs, with no port opened.
|
|
UsbDescriptor,
|
|
/// An `M115` handshake, which required opening the port.
|
|
SerialHandshake,
|
|
}
|
|
|
|
impl Source {
|
|
/// The `source` value in a `discovered_printers` message - see the
|
|
/// backend's `discovered_printer.schema.json`, which is an enum there, so
|
|
/// these strings are a contract and not a label.
|
|
pub fn wire_value(self) -> &'static str {
|
|
match self {
|
|
Source::Ssdp => "ssdp",
|
|
Source::Certificate => "certificate",
|
|
Source::HttpApi => "http_api",
|
|
Source::UsbDescriptor => "usb_descriptor",
|
|
Source::SerialHandshake => "serial_handshake",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A printer found on the network or on a USB port, minus its credential.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Candidate {
|
|
/// What goes in `host` - or in `port`, for a serial candidate, where it
|
|
/// is a device path like `/dev/ttyACM0`.
|
|
pub host: String,
|
|
pub vendor: Vendor,
|
|
/// The printer's serial number. Bambu's MQTT topics are addressed by it,
|
|
/// so for that vendor it is required rather than decorative.
|
|
pub serial: Option<String>,
|
|
pub model: Option<String>,
|
|
/// The name the operator gave the printer, where the vendor reports one.
|
|
pub name: Option<String>,
|
|
/// `DevBind.bambu.com`: whether the printer is bound to a Bambu cloud
|
|
/// account. Worth surfacing rather than acting on - a bound printer can
|
|
/// still be driven over the LAN if LAN mode is on, and "bound" is the
|
|
/// likeliest explanation for a printer that is visible here and then
|
|
/// refuses to connect.
|
|
pub bind_state: Option<String>,
|
|
/// Firmware or software version, free-form, for an operator to recognise
|
|
/// the machine by. Not rendered into the TOML - nothing in `fleet.rs`
|
|
/// takes a version, and a stale one in a config file would be a lie.
|
|
pub detail: Option<String>,
|
|
/// Another vendor this could equally be, when the probe genuinely cannot
|
|
/// tell - which today means only OctoPrint versus PrusaLink.
|
|
///
|
|
/// Carried rather than resolved by guessing, because the operator knows
|
|
/// what they own and the network does not. It renders as a comment above
|
|
/// `vendor`, so changing it is a one-word edit in the file they are
|
|
/// already pasting into.
|
|
pub ambiguous_with: Option<Vendor>,
|
|
pub source: Source,
|
|
}
|
|
|
|
impl Candidate {
|
|
pub fn new(host: impl Into<String>, vendor: Vendor, source: Source) -> Self {
|
|
Self {
|
|
host: host.into(),
|
|
vendor,
|
|
serial: None,
|
|
model: None,
|
|
name: None,
|
|
bind_state: None,
|
|
detail: None,
|
|
ambiguous_with: None,
|
|
source,
|
|
}
|
|
}
|
|
|
|
/// The wire form for `ClientMessage::DiscoveredPrinters`.
|
|
///
|
|
/// `bind_state` is deliberately dropped. It is a Bambu-only fact about a
|
|
/// cloud account, the cloud already knows more about its own accounts
|
|
/// than an SSDP packet does, and the one thing it is good for here -
|
|
/// explaining a printer that is visible and then refuses to connect -
|
|
/// is a diagnosis for someone standing in front of the machine, which is
|
|
/// what `examples/discover_printers.rs` prints it for.
|
|
pub fn to_wire(&self) -> crate::uplink::DiscoveredPrinter {
|
|
crate::uplink::DiscoveredPrinter {
|
|
vendor: self.vendor.toml_value(),
|
|
address: self.host.clone(),
|
|
serial: self.serial.clone(),
|
|
model: self.model.clone(),
|
|
name: self.name.clone(),
|
|
detail: self.detail.clone(),
|
|
ambiguous_with: self.ambiguous_with.map(Vendor::toml_value),
|
|
source: self.source.wire_value(),
|
|
driver_ready: self.vendor.driver_is_implemented(),
|
|
}
|
|
}
|
|
|
|
/// A `[[printer]]` block to paste into `printers.toml`.
|
|
///
|
|
/// Each vendor gets the fields `fleet.rs` actually requires of it and no
|
|
/// others, since an unused key is a key someone later has to work out the
|
|
/// meaning of. The credential, where there is one, comes last, empty, and
|
|
/// labelled - it is the one thing discovery cannot supply, and the entry
|
|
/// does not load until a human supplies it.
|
|
pub fn to_toml_entry(&self, id: &str) -> String {
|
|
let name = self
|
|
.name
|
|
.clone()
|
|
.or_else(|| self.model.clone())
|
|
.unwrap_or_else(|| id.to_string());
|
|
|
|
let mut out = String::new();
|
|
if let Some(detail) = &self.detail {
|
|
out.push_str(&format!("# {detail}\n"));
|
|
}
|
|
if !self.vendor.driver_is_implemented() {
|
|
out.push_str(&format!(
|
|
"# WARNING: continuum-proxy's {} client is still a stub - it will connect\n\
|
|
# and do nothing. Found and reported so you know it is there.\n",
|
|
self.vendor.toml_value()
|
|
));
|
|
}
|
|
out.push_str("[[printer]]\n");
|
|
out.push_str(&format!("id = {}\n", toml_string(id)));
|
|
out.push_str(&format!("name = {}\n", toml_string(&name)));
|
|
if let Some(other) = self.ambiguous_with {
|
|
out.push_str(&format!(
|
|
"# vendor: change to {:?} if that is what this is - nothing on the network\n\
|
|
# distinguishes them, so this is a guess only you can settle.\n",
|
|
other.toml_value()
|
|
));
|
|
}
|
|
out.push_str(&format!("vendor = {}\n", toml_string(self.vendor.toml_value())));
|
|
|
|
match self.vendor {
|
|
Vendor::Bambu => {
|
|
out.push_str(&format!("host = {}\n", toml_string(&self.host)));
|
|
match &self.serial {
|
|
Some(sn) => out.push_str(&format!("sn = {}\n", toml_string(sn))),
|
|
// Required by `fleet.rs`, so a blank would not load
|
|
// either - but it must not be left looking filled in.
|
|
None => out.push_str("# sn = \"\" # NOT FOUND - printer screen > Settings > Device\n"),
|
|
}
|
|
out.push_str("access_code = \"\" # REQUIRED: printer screen > Settings > Network (LAN mode)\n");
|
|
}
|
|
// The only field `fleet.rs` wants, and Moonraker needs no
|
|
// credential from us - it authorizes by client address.
|
|
Vendor::Klipper => out.push_str(&format!("host = {}\n", toml_string(&self.host))),
|
|
// One client, two flavours - same required fields.
|
|
Vendor::PrusaLink | Vendor::Octoprint => {
|
|
out.push_str(&format!("host = {}\n", toml_string(&self.host)));
|
|
out.push_str("api_key = \"\" # REQUIRED: the printer's or OctoPrint's web UI > API key\n");
|
|
}
|
|
Vendor::PrusaSerial => {
|
|
out.push_str(&format!("port = {}\n", toml_string(&self.host)));
|
|
// Stated rather than left to the default, because a serial
|
|
// port that is wrong about its baud rate does not fail - it
|
|
// returns noise, which is far harder to diagnose than a
|
|
// refusal.
|
|
out.push_str("baud_rate = 115200 # Marlin's usual default; check your firmware\n");
|
|
}
|
|
}
|
|
if let Some(bind) = &self.bind_state
|
|
&& bind != "free"
|
|
{
|
|
out.push_str(&format!("# note: this printer reports DevBind = {bind:?} (bound to a Bambu account).\n"));
|
|
out.push_str("# LAN mode has to be on for a gateway to drive it.\n");
|
|
}
|
|
out
|
|
}
|
|
|
|
/// A plausible `id` for the entry: short, stable, and filename-safe.
|
|
///
|
|
/// Derived from the model or the operator's name rather than the serial,
|
|
/// because this id is what appears in logs and in the cloud, and
|
|
/// `01S09B441900143` is not something anyone recognises. Falls back to
|
|
/// the serial's tail only when there is nothing else.
|
|
pub fn suggested_id(&self) -> String {
|
|
let basis = self
|
|
.model
|
|
.as_deref()
|
|
.or(self.name.as_deref())
|
|
.map(slug)
|
|
.filter(|s| !s.is_empty());
|
|
if let Some(slug) = basis {
|
|
return slug;
|
|
}
|
|
match &self.serial {
|
|
// The tail, not the head: serials share a model prefix, so the
|
|
// last characters are what distinguishes two of the same machine.
|
|
Some(sn) if sn.len() > 6 => format!("printer-{}", slug(&sn[sn.len() - 6..])),
|
|
Some(sn) => format!("printer-{}", slug(sn)),
|
|
None => {
|
|
// A serial port has no identity but its path, and
|
|
// `/dev/ttyACM0` makes a better id than `printer`.
|
|
let from_path = self.host.rsplit('/').next().map(slug).filter(|s| !s.is_empty());
|
|
from_path.unwrap_or_else(|| "printer".to_string())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn slug(input: &str) -> String {
|
|
let mut out = String::new();
|
|
for ch in input.chars() {
|
|
if ch.is_ascii_alphanumeric() {
|
|
out.push(ch.to_ascii_lowercase());
|
|
} else if !out.ends_with('-') && !out.is_empty() {
|
|
out.push('-');
|
|
}
|
|
}
|
|
out.trim_end_matches('-').to_string()
|
|
}
|
|
|
|
/// Quotes a value for TOML, escaping what a basic string must escape.
|
|
///
|
|
/// None of these characters can appear in a serial or an IP address, but the
|
|
/// name comes from whatever the operator typed into their slicer, and a stray
|
|
/// quote in it would otherwise produce a block that does not parse.
|
|
fn toml_string(value: &str) -> String {
|
|
let mut out = String::with_capacity(value.len() + 2);
|
|
out.push('"');
|
|
for ch in value.chars() {
|
|
match ch {
|
|
'"' => out.push_str("\\\""),
|
|
'\\' => out.push_str("\\\\"),
|
|
'\n' => out.push_str("\\n"),
|
|
'\r' => out.push_str("\\r"),
|
|
'\t' => out.push_str("\\t"),
|
|
other => out.push(other),
|
|
}
|
|
}
|
|
out.push('"');
|
|
out
|
|
}
|
|
|
|
/// Rejects anything that is not shaped like a printer serial number.
|
|
///
|
|
/// The point is not to validate a vendor's format - it is that this value
|
|
/// gets written into a config file as `sn`, and a `USN` carrying a UPnP UUID
|
|
/// or a human-readable device description would produce an entry that looks
|
|
/// filled in and then addresses a topic no printer publishes. Better to leave
|
|
/// it blank and say so.
|
|
pub(crate) fn valid_serial(value: String) -> Option<String> {
|
|
let ok = (8..=32).contains(&value.len()) && value.chars().all(|c| c.is_ascii_alphanumeric());
|
|
ok.then_some(value)
|
|
}
|
|
|
|
/// Parses one SSDP datagram, or `None` if it is not a Bambu announcement.
|
|
///
|
|
/// `from` is the sender's address, used when the message carries no usable
|
|
/// `Location` - which matters because the address a packet came from is the
|
|
/// one thing in a datagram that cannot be mistyped.
|
|
///
|
|
/// Tolerant on purpose: it extracts whatever headers are present rather than
|
|
/// requiring a full set. The exact header spelling is from Bambu's published
|
|
/// discovery format and has not been checked against a real announcement
|
|
/// here, so a missing vendor header has to mean "one fewer field filled in",
|
|
/// not "no printer found".
|
|
pub fn parse_ssdp(message: &str, from: Ipv4Addr) -> Option<Candidate> {
|
|
let mut lines = message.lines();
|
|
let start = lines.next()?.trim();
|
|
|
|
// A NOTIFY is an unsolicited announcement; a `200 OK` is the reply to an
|
|
// M-SEARCH. Anything else is rejected, which is what keeps a listener
|
|
// from parsing the M-SEARCH queries it hears - including its own, which
|
|
// arrive back on the same socket.
|
|
let upper = start.to_ascii_uppercase();
|
|
if !upper.starts_with("NOTIFY") && !upper.contains("200 OK") {
|
|
return None;
|
|
}
|
|
|
|
let mut headers: Vec<(String, &str)> = Vec::new();
|
|
for line in lines {
|
|
if let Some((key, value)) = line.split_once(':') {
|
|
headers.push((key.trim().to_ascii_lowercase(), value.trim()));
|
|
}
|
|
}
|
|
let get = |name: &str| {
|
|
headers
|
|
.iter()
|
|
.find(|(key, _)| key == name)
|
|
.map(|(_, value)| *value)
|
|
.filter(|value| !value.is_empty())
|
|
};
|
|
|
|
// Only claim a Bambu printer. Every UPnP device on a LAN - televisions,
|
|
// routers, printers of the paper kind - announces on this same group, and
|
|
// offering an operator a TOML block for their TV would be worse than
|
|
// finding nothing.
|
|
let device_type = get("nt").or_else(|| get("st")).unwrap_or("");
|
|
let looks_bambu = device_type.to_ascii_lowercase().contains("bambulab")
|
|
|| headers.iter().any(|(key, _)| key.ends_with(".bambu.com"));
|
|
if !looks_bambu {
|
|
debug!(%from, %device_type, "ignoring a non-Bambu SSDP announcement");
|
|
return None;
|
|
}
|
|
|
|
let host = get("location").map(host_from_location).unwrap_or_else(|| from.to_string());
|
|
let mut candidate = Candidate::new(host, Vendor::Bambu, Source::Ssdp);
|
|
candidate.serial = get("usn").map(|usn| usn.trim_start_matches("uuid:").trim().to_string()).and_then(valid_serial);
|
|
candidate.model = get("devmodel.bambu.com").map(str::to_string);
|
|
candidate.name = get("devname.bambu.com").map(str::to_string);
|
|
candidate.bind_state = get("devbind.bambu.com").map(str::to_string);
|
|
candidate.detail = get("devversion.bambu.com").map(|v| format!("Bambu firmware {v}"));
|
|
Some(candidate)
|
|
}
|
|
|
|
/// Bambu's `Location` is a bare address, but SSDP's is a URL. Accept either.
|
|
fn host_from_location(location: &str) -> String {
|
|
let without_scheme = location.split_once("://").map(|(_, rest)| rest).unwrap_or(location);
|
|
let host = without_scheme.split('/').next().unwrap_or(without_scheme);
|
|
// Strip a port, while leaving a bracketed IPv6 literal alone.
|
|
match host.rsplit_once(':') {
|
|
Some((before, after)) if !before.ends_with(']') && after.chars().all(|c| c.is_ascii_digit()) => {
|
|
before.to_string()
|
|
}
|
|
_ => host.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Listens for SSDP announcements on one port for `window`, de-duplicated.
|
|
///
|
|
/// `raw` logs every datagram that arrives, Bambu or not, at info level. That
|
|
/// is here because this path cannot be tested from the development
|
|
/// environment: the printers are on `192.168.0.0/24` and the only host
|
|
/// available is routed to them from another subnet, so their multicast never
|
|
/// arrives - a router does not forward it. The first run on a real segment is
|
|
/// therefore also the first real test, and it should print what it saw rather
|
|
/// than only what it understood.
|
|
pub async fn listen_ssdp(port: u16, window: Duration, raw: bool) -> anyhow::Result<Vec<Candidate>> {
|
|
let socket = bind_ssdp(port)?;
|
|
let mut found: Vec<Candidate> = Vec::new();
|
|
let mut buf = vec![0u8; 65_535];
|
|
let deadline = tokio::time::Instant::now() + window;
|
|
|
|
loop {
|
|
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
|
if remaining.is_zero() {
|
|
break;
|
|
}
|
|
let received = match tokio::time::timeout(remaining, socket.recv_from(&mut buf)).await {
|
|
Ok(Ok(received)) => received,
|
|
// The window closed, which is the ordinary way out.
|
|
Err(_) => break,
|
|
// One malformed or oversized datagram must not end the scan.
|
|
Ok(Err(err)) => {
|
|
warn!(?err, "SSDP receive failed");
|
|
continue;
|
|
}
|
|
};
|
|
let (len, from) = received;
|
|
let text = String::from_utf8_lossy(&buf[..len]);
|
|
let sender = match from {
|
|
SocketAddr::V4(v4) => *v4.ip(),
|
|
SocketAddr::V6(_) => continue,
|
|
};
|
|
if raw {
|
|
tracing::info!(%from, bytes = len, "SSDP datagram:\n{}", text.trim());
|
|
}
|
|
if let Some(candidate) = parse_ssdp(&text, sender)
|
|
&& !found.iter().any(|existing| existing.host == candidate.host)
|
|
{
|
|
found.push(candidate);
|
|
}
|
|
}
|
|
Ok(found)
|
|
}
|
|
|
|
/// Binds the SSDP port and joins the group on every interface.
|
|
///
|
|
/// `Ipv4Addr::UNSPECIFIED` as the interface asks the kernel to pick, which is
|
|
/// right for a gateway appliance: it may well have two interfaces and only
|
|
/// the operator knows which one faces the printers.
|
|
fn bind_ssdp(port: u16) -> anyhow::Result<UdpSocket> {
|
|
let socket = StdUdpSocket::bind((Ipv4Addr::UNSPECIFIED, port)).map_err(|err| {
|
|
anyhow::anyhow!("could not bind UDP :{port} for discovery: {err} (is another process using it?)")
|
|
})?;
|
|
socket
|
|
.join_multicast_v4(&SSDP_GROUP, &Ipv4Addr::UNSPECIFIED)
|
|
.map_err(|err| anyhow::anyhow!("could not join {SSDP_GROUP} on :{port}: {err}"))?;
|
|
socket.set_nonblocking(true)?;
|
|
Ok(UdpSocket::from_std(socket)?)
|
|
}
|
|
|
|
/// Drops candidates already covered by another, so one printer found twice is
|
|
/// reported once.
|
|
///
|
|
/// A sweep and an SSDP listen overlap on every Bambu printer, and the two
|
|
/// know different things - SSDP has the model and the operator's name, the
|
|
/// certificate has the serial - so the merge keeps whichever field is
|
|
/// present rather than preferring a source.
|
|
pub fn merge(mut found: Vec<Candidate>) -> Vec<Candidate> {
|
|
let mut merged: Vec<Candidate> = Vec::new();
|
|
for candidate in found.drain(..) {
|
|
let existing = merged
|
|
.iter_mut()
|
|
.find(|other| other.host == candidate.host && other.vendor == candidate.vendor);
|
|
match existing {
|
|
Some(other) => {
|
|
other.serial = other.serial.take().or(candidate.serial);
|
|
other.model = other.model.take().or(candidate.model);
|
|
other.name = other.name.take().or(candidate.name);
|
|
other.bind_state = other.bind_state.take().or(candidate.bind_state);
|
|
other.detail = other.detail.take().or(candidate.detail);
|
|
}
|
|
None => merged.push(candidate),
|
|
}
|
|
}
|
|
merged
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Bambu's announcement shape, with the vendor headers it is worth
|
|
/// extracting. Not a capture - see `parse_ssdp`'s note.
|
|
const NOTIFY: &str = "NOTIFY * HTTP/1.1\r\n\
|
|
HOST: 239.255.255.250:2021\r\n\
|
|
Server: UPnP/1.0\r\n\
|
|
Location: 192.168.0.21\r\n\
|
|
NT: urn:bambulab-com:device:3dprinter:1\r\n\
|
|
USN: 31B8BP5A1800585\r\n\
|
|
Cache-Control: max-age=1800\r\n\
|
|
DevModel.bambu.com: O1C2\r\n\
|
|
DevName.bambu.com: H2C\r\n\
|
|
DevSignal.bambu.com: -48\r\n\
|
|
DevConnect.bambu.com: lan\r\n\
|
|
DevBind.bambu.com: free\r\n";
|
|
|
|
#[test]
|
|
fn an_announcement_yields_every_field_it_carries() {
|
|
let found = parse_ssdp(NOTIFY, Ipv4Addr::new(10, 0, 0, 1)).expect("a Bambu announcement");
|
|
assert_eq!(found.host, "192.168.0.21");
|
|
assert_eq!(found.serial.as_deref(), Some("31B8BP5A1800585"));
|
|
assert_eq!(found.model.as_deref(), Some("O1C2"));
|
|
assert_eq!(found.name.as_deref(), Some("H2C"));
|
|
assert_eq!(found.bind_state.as_deref(), Some("free"));
|
|
assert_eq!(found.vendor, Vendor::Bambu);
|
|
assert_eq!(found.source, Source::Ssdp);
|
|
}
|
|
|
|
#[test]
|
|
fn a_missing_vendor_header_costs_one_field_and_not_the_printer() {
|
|
// The whole reason the parser is tolerant: the header spelling is
|
|
// unverified, so an announcement that spells `DevModel` differently
|
|
// must still produce a usable candidate.
|
|
let trimmed = NOTIFY.replace("DevModel.bambu.com: O1C2\r\n", "");
|
|
let found = parse_ssdp(&trimmed, Ipv4Addr::new(10, 0, 0, 1)).expect("still a printer");
|
|
assert_eq!(found.model, None);
|
|
assert_eq!(found.serial.as_deref(), Some("31B8BP5A1800585"));
|
|
}
|
|
|
|
#[test]
|
|
fn the_sender_address_stands_in_for_a_missing_location() {
|
|
let without = NOTIFY.replace("Location: 192.168.0.21\r\n", "");
|
|
let found = parse_ssdp(&without, Ipv4Addr::new(192, 168, 0, 21)).expect("a printer");
|
|
assert_eq!(found.host, "192.168.0.21");
|
|
}
|
|
|
|
#[test]
|
|
fn a_url_location_is_reduced_to_a_host() {
|
|
for (location, expected) in [
|
|
("http://192.168.0.21:80/description.xml", "192.168.0.21"),
|
|
("http://192.168.0.21/", "192.168.0.21"),
|
|
("192.168.0.21", "192.168.0.21"),
|
|
] {
|
|
let message = NOTIFY.replace("Location: 192.168.0.21", &format!("Location: {location}"));
|
|
let found = parse_ssdp(&message, Ipv4Addr::LOCALHOST).expect("a printer");
|
|
assert_eq!(found.host, expected, "for {location}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_search_query_is_not_an_announcement() {
|
|
// A listener hears its own M-SEARCH back off the multicast group, and
|
|
// an M-SEARCH names a device type without naming a device - parsing
|
|
// one would invent a printer at whatever address asked the question.
|
|
let query = "M-SEARCH * HTTP/1.1\r\n\
|
|
HOST: 239.255.255.250:2021\r\n\
|
|
MAN: \"ssdp:discover\"\r\n\
|
|
MX: 1\r\n\
|
|
ST: urn:bambulab-com:device:3dprinter:1\r\n";
|
|
assert!(parse_ssdp(query, Ipv4Addr::new(10, 200, 0, 4)).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn another_vendors_upnp_device_is_ignored() {
|
|
let television = "NOTIFY * HTTP/1.1\r\n\
|
|
HOST: 239.255.255.250:1900\r\n\
|
|
Location: http://192.168.0.9:8080/desc.xml\r\n\
|
|
NT: urn:schemas-upnp-org:device:MediaRenderer:1\r\n\
|
|
USN: uuid:4d696361-736f-6674-2d77-696e646f7773\r\n";
|
|
assert!(parse_ssdp(television, Ipv4Addr::new(192, 168, 0, 9)).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn a_usn_that_is_not_a_serial_is_dropped_rather_than_written_out() {
|
|
let uuid = NOTIFY.replace("USN: 31B8BP5A1800585", "USN: uuid:4d696361-736f-6674-2d77-696e646f7773");
|
|
let found = parse_ssdp(&uuid, Ipv4Addr::LOCALHOST).expect("a printer");
|
|
assert_eq!(found.serial, None, "a UUID is not a serial and must not be written into `sn`");
|
|
assert!(found.to_toml_entry("h2c").contains("# sn = \"\" # NOT FOUND"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_bambu_entry_parses_as_toml_and_names_the_access_code() {
|
|
let found = parse_ssdp(NOTIFY, Ipv4Addr::LOCALHOST).expect("a printer");
|
|
let entry = found.to_toml_entry("h2c");
|
|
let parsed: toml::Value = toml::from_str(&entry).expect("the emitted block must be valid TOML");
|
|
let printer = &parsed["printer"][0];
|
|
assert_eq!(printer["id"].as_str(), Some("h2c"));
|
|
assert_eq!(printer["host"].as_str(), Some("192.168.0.21"));
|
|
assert_eq!(printer["sn"].as_str(), Some("31B8BP5A1800585"));
|
|
assert_eq!(printer["name"].as_str(), Some("H2C"));
|
|
// Auto-detecting, not a guess from the TLS version - see `toml_value`.
|
|
assert_eq!(printer["vendor"].as_str(), Some("bambu"));
|
|
// Present and empty: the operator has to fill it in, and `fleet::load`
|
|
// refuses the entry until they do.
|
|
assert_eq!(printer["access_code"].as_str(), Some(""));
|
|
// Bambu is the one vendor that works, so no stub warning.
|
|
assert!(!entry.contains("WARNING"), "{entry}");
|
|
}
|
|
|
|
/// Each vendor's entry has to carry exactly the fields `fleet.rs`
|
|
/// requires of it - this is the test that fails if those two drift apart.
|
|
#[test]
|
|
fn every_vendors_entry_carries_the_fields_its_loader_demands() {
|
|
let cases = [
|
|
(Vendor::Bambu, "192.168.0.21", vec!["host", "sn", "access_code"]),
|
|
(Vendor::Klipper, "192.168.0.50", vec!["host"]),
|
|
(Vendor::PrusaLink, "192.168.0.51", vec!["host", "api_key"]),
|
|
(Vendor::Octoprint, "192.168.0.60", vec!["host", "api_key"]),
|
|
(Vendor::PrusaSerial, "/dev/ttyACM0", vec!["port", "baud_rate"]),
|
|
];
|
|
for (vendor, host, expected) in cases {
|
|
let mut candidate = Candidate::new(host, vendor, Source::HttpApi);
|
|
candidate.serial = Some("31B8BP5A1800585".to_string());
|
|
let entry = candidate.to_toml_entry("x");
|
|
let parsed: toml::Value = toml::from_str(&entry).unwrap_or_else(|err| panic!("{vendor:?}: {err}\n{entry}"));
|
|
let printer = &parsed["printer"][0];
|
|
assert_eq!(printer["vendor"].as_str(), Some(vendor.toml_value()));
|
|
for key in &expected {
|
|
assert!(printer.get(key).is_some(), "{vendor:?} must render {key}:\n{entry}");
|
|
}
|
|
// No stray keys: an unused one is a key someone later has to
|
|
// work out the meaning of.
|
|
for absent in ["host", "sn", "access_code", "api_key", "port", "baud_rate"] {
|
|
if !expected.contains(&absent) {
|
|
assert!(printer.get(absent).is_none(), "{vendor:?} must not render {absent}:\n{entry}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_vendor_with_a_stub_client_says_so_in_its_entry() {
|
|
for vendor in [Vendor::Klipper, Vendor::PrusaLink, Vendor::Octoprint, Vendor::PrusaSerial] {
|
|
let entry = Candidate::new("192.168.0.50", vendor, Source::HttpApi).to_toml_entry("x");
|
|
assert!(entry.contains("WARNING"), "{vendor:?} is a stub and must say so:\n{entry}");
|
|
assert!(entry.contains("stub"), "{vendor:?}:\n{entry}");
|
|
// Still valid TOML, because the operator is meant to paste it.
|
|
toml::from_str::<toml::Value>(&entry).expect("a warning must be a comment, not a syntax error");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_name_with_a_quote_in_it_still_renders_parseable_toml() {
|
|
let awkward = NOTIFY.replace("DevName.bambu.com: H2C", "DevName.bambu.com: Bob\"s \\printer");
|
|
let found = parse_ssdp(&awkward, Ipv4Addr::LOCALHOST).expect("a printer");
|
|
let parsed: toml::Value = toml::from_str(&found.to_toml_entry("h2c")).expect("valid TOML");
|
|
assert_eq!(parsed["printer"][0]["name"].as_str(), Some("Bob\"s \\printer"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_bound_printer_is_flagged_rather_than_hidden() {
|
|
let bound = NOTIFY.replace("DevBind.bambu.com: free", "DevBind.bambu.com: bound");
|
|
let found = parse_ssdp(&bound, Ipv4Addr::LOCALHOST).expect("a printer");
|
|
let entry = found.to_toml_entry("h2c");
|
|
assert!(entry.contains("DevBind"), "a bound printer needs the explanation: {entry}");
|
|
assert!(entry.contains("LAN mode"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_firmware_version_is_a_comment_and_never_a_field() {
|
|
// Nothing in `fleet.rs` takes a version, and one written into a
|
|
// config file would be a lie the moment the printer updates.
|
|
let versioned = NOTIFY.replace("DevBind.bambu.com: free", "DevVersion.bambu.com: 01.08.00.00");
|
|
let found = parse_ssdp(&versioned, Ipv4Addr::LOCALHOST).expect("a printer");
|
|
assert_eq!(found.detail.as_deref(), Some("Bambu firmware 01.08.00.00"));
|
|
let entry = found.to_toml_entry("h2c");
|
|
assert!(entry.contains("# Bambu firmware 01.08.00.00"));
|
|
let parsed: toml::Value = toml::from_str(&entry).expect("valid TOML");
|
|
assert!(parsed["printer"][0].get("detail").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn an_id_is_suggested_from_the_model_then_the_name_then_the_serial() {
|
|
let found = parse_ssdp(NOTIFY, Ipv4Addr::LOCALHOST).expect("a printer");
|
|
assert_eq!(found.suggested_id(), "o1c2");
|
|
|
|
let no_model = parse_ssdp(&NOTIFY.replace("DevModel.bambu.com: O1C2\r\n", ""), Ipv4Addr::LOCALHOST).unwrap();
|
|
assert_eq!(no_model.suggested_id(), "h2c");
|
|
|
|
let mut bare = Candidate::new("192.168.0.20", Vendor::Bambu, Source::Certificate);
|
|
bare.serial = Some("01S09B441900143".to_string());
|
|
// The tail distinguishes two of the same model; the prefix does not.
|
|
assert_eq!(bare.suggested_id(), "printer-900143");
|
|
|
|
// A serial port has no identity but its path, which still beats
|
|
// calling it "printer".
|
|
let port = Candidate::new("/dev/ttyACM0", Vendor::PrusaSerial, Source::UsbDescriptor);
|
|
assert_eq!(port.suggested_id(), "ttyacm0");
|
|
}
|
|
|
|
#[test]
|
|
fn an_operators_name_becomes_a_safe_id() {
|
|
for (name, expected) in [("Bob's P1P!", "bob-s-p1p"), (" spaced out ", "spaced-out"), ("!!!", "")] {
|
|
assert_eq!(slug(name), expected, "for {name:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn one_printer_found_by_two_mechanisms_is_reported_once_with_both_halves() {
|
|
// SSDP knows the model and the operator's name; the certificate
|
|
// knows the serial. Neither is complete, and an operator should not
|
|
// have to merge two blocks by hand.
|
|
let mut from_ssdp = Candidate::new("192.168.0.21", Vendor::Bambu, Source::Ssdp);
|
|
from_ssdp.model = Some("O1C2".to_string());
|
|
from_ssdp.name = Some("H2C".to_string());
|
|
let mut from_cert = Candidate::new("192.168.0.21", Vendor::Bambu, Source::Certificate);
|
|
from_cert.serial = Some("31B8BP5A1800585".to_string());
|
|
|
|
let merged = merge(vec![from_ssdp, from_cert]);
|
|
assert_eq!(merged.len(), 1);
|
|
assert_eq!(merged[0].model.as_deref(), Some("O1C2"));
|
|
assert_eq!(merged[0].serial.as_deref(), Some("31B8BP5A1800585"));
|
|
}
|
|
|
|
#[test]
|
|
fn two_vendors_at_one_address_stay_separate() {
|
|
// A Raspberry Pi can run Moonraker and have PrusaLink reachable
|
|
// through it; merging those into one entry would invent a printer
|
|
// that is neither.
|
|
let merged = merge(vec![
|
|
Candidate::new("192.168.0.50", Vendor::Klipper, Source::HttpApi),
|
|
Candidate::new("192.168.0.50", Vendor::PrusaLink, Source::HttpApi),
|
|
]);
|
|
assert_eq!(merged.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn a_certificate_authoritys_name_is_not_a_serial_number() {
|
|
// The failure mode this guards: an issuer CN sits earlier in the DER
|
|
// than the subject's, so a scan for "the first CN" reports `BBL CA`.
|
|
assert!(valid_serial("BBL CA".to_string()).is_none(), "a space disqualifies it");
|
|
assert!(valid_serial("BBL Device CA O1C2-V2".to_string()).is_none());
|
|
// Both real printers, as read off their certificates on 2026-10-19.
|
|
assert_eq!(valid_serial("01S09B441900143".to_string()).as_deref(), Some("01S09B441900143"));
|
|
assert_eq!(valid_serial("31B8BP5A1800585".to_string()).as_deref(), Some("31B8BP5A1800585"));
|
|
}
|
|
}
|