//! 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, pub model: Option, /// The name the operator gave the printer, where the vendor reports one. pub name: Option, /// `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, /// 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, /// 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, pub source: Source, } impl Candidate { pub fn new(host: impl Into, 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 { 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 { 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> { let socket = bind_ssdp(port)?; let mut found: Vec = 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 { 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) -> Vec { let mut merged: Vec = 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::(&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")); } }