Report the OctoPrint API as what it is, and let the operator name it

Reporting a detected printer as `prusa_link` asserted a product nothing on
the wire mentions. What a probe observes is that something speaks the
OctoPrint REST API; PrusaLink is one implementation of it, OctoPrint itself
is another, and without a credential the two are indistinguishable - every
PrusaLink identity endpoint requires digest auth.

So discovery now reports `vendor = "octoprint"`, the protocol it actually
saw, and carries `ambiguous_with` so the rendered entry prints a comment
offering `prusa_link` directly above the `vendor` line. Changing it is a
one-word edit in the file the operator is already pasting into, which is the
right place for a question only they can answer. The exception is a body
that names itself: `/api/version`'s `text` field reads "PrusaLink 0.7.0" or
"OctoPrint 1.9.3", so on the rare unauthenticated 200 the answer is known
and no choice is offered.

For that to mean anything, `octoprint` had to become a vendor `fleet.rs`
accepts - otherwise discovery printed a block that would not load. It maps
to the same client as `prusa_link`, because they speak the same API, with an
`OctoPrintFlavour` recording which. The flavour is stored now, while the
client is still a stub, precisely because no probe can settle it: it is the
operator's answer, and without somewhere to keep it the real client would
have to ask them a second time. `PrusaLinkPrinter` is accordingly now
`OctoPrintPrinter` - one client for the API family - since a struct called
PrusaLink holding a flavour that says "Generic" is a contradiction someone
would later have to unpick.

**Found by the new test, and the reason it exists:** `fleet::load` did *not*
reject an entry whose credential was blank. `require_field` took an
`Option`, so `access_code = ""` arrived as `Some("")` and loaded happily -
which falsified the claim this feature was built on, that printing a block
with an empty credential is safe because pasting it in unfinished fails
loudly at startup. It does now. An empty credential is not a usable one; it
only moves the failure somewhere worse, where a Bambu printer is refused at
MQTT and a PrusaLink one 401s on every poll with nothing saying the field is
blank. This is the same trap as `??`-instead-of-`||` on an environment
variable that ships empty, which the backend's `auth/edge_secret.ts` and
`s3/index.ts` both document; it bites identically on a TOML key.

tests/discovery_to_fleet.rs is new and closes the loop: discovery and fleet
are coupled by nothing but a string, unit tests on either side cannot catch
a drift between them, and the symptom of a drift is an operator pasting in
exactly what they were told to and the gateway refusing to start. It renders
a candidate for every vendor, fills in the credential the way a human would,
loads it for real, and checks the handle matches the vendor asked for - not
merely that something loaded, since `octoprint` and `prusa_link` share a
client and a mapping that collapsed them would still load and still be
wrong.

Verification: the Moonraker probe now runs end to end over real HTTP against
a stand-in serving Moonraker's documented `/printer/info`, and correctly
reports a klipper entry named from the returned hostname. Port 80 cannot be
bound without root here, so the PrusaLink path is covered by a test that
takes a real socket answering a real digest challenge - the one link unit
tests on the classifier cannot reach, and the one PrusaLink is identified by.
This commit is contained in:
2026-10-19 07:40:28 +00:00
parent a401773406
commit da28597eeb
8 changed files with 413 additions and 62 deletions
+31 -4
View File
@@ -98,7 +98,15 @@ pub const SSDP_PORTS: [u16; 2] = [2021, 1990];
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 -
@@ -117,6 +125,7 @@ impl Vendor {
Vendor::Bambu => "bambu",
Vendor::Klipper => "klipper",
Vendor::PrusaLink => "prusa_link",
Vendor::Octoprint => "octoprint",
Vendor::PrusaSerial => "prusa_serial",
}
}
@@ -131,7 +140,7 @@ impl Vendor {
Vendor::Bambu => true,
// `printer/klipper.rs` and `printer/prusa.rs` are stubs whose
// methods return without doing anything.
Vendor::Klipper | Vendor::PrusaLink => false,
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,
@@ -177,6 +186,14 @@ pub struct Candidate {
/// 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,
}
@@ -190,6 +207,7 @@ impl Candidate {
name: None,
bind_state: None,
detail: None,
ambiguous_with: None,
source,
}
}
@@ -222,6 +240,13 @@ impl Candidate {
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 {
@@ -238,9 +263,10 @@ impl Candidate {
// 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))),
Vendor::PrusaLink => {
// 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: PrusaLink web UI > Settings > API key\n");
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)));
@@ -621,6 +647,7 @@ mod tests {
(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 {
@@ -645,7 +672,7 @@ mod tests {
#[test]
fn a_vendor_with_a_stub_client_says_so_in_its_entry() {
for vendor in [Vendor::Klipper, Vendor::PrusaLink, Vendor::PrusaSerial] {
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}");