Commit Graph
8 Commits
Author SHA1 Message Date
octoturgeandClaude Opus 5 4f37384706 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 07:40:28 +00:00
octoturgeandClaude Opus 5 e6830d3846 Discover Klipper, PrusaLink and serial printers too
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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 07:28:05 +00:00
octoturgeandClaude Opus 5 c18fd7b649 Find printers on the network instead of retyping their details
Setting up a farm meant reading five fields off each printer's screen into
printers.toml, and the serial is the one that punishes a typo: 15 unspaced
alphanumerics that address Bambu's MQTT topics, so a wrong character
produces a gateway that connects to the broker, subscribes to a topic no
printer publishes, and reports nothing.

Two mechanisms, because they fail in different places:

SSDP (`listen_ssdp`) hears the announcements Bambu printers send to
239.255.255.250 unsolicited, on 2021 and 1990. It learns the most - the
model and the operator's own name for the printer come only from here - but
multicast does not cross a router, so it finds nothing from a gateway on a
different VLAN than its printers, which is an ordinary way to run a
workshop.

The TLS certificate (`identify`) is the dependable one. Every Bambu printer
puts its serial in the subject CN of the certificate it presents on 8883,
readable with no credentials, no MQTT session and across subnets. Verified
against both real printers: a P1P gives 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 what printers.toml already said. A blind
sweep of their /24 found exactly those two and nothing else, in 24s.

Reading an unverifiable certificate is deliberate and worth being plain
about: it is being read as a name tag, not trusted. There is nothing to
verify it against - the serial is what is being learned, and a P1P does not
chain to the bundled Bambu CA at all. Trust is still decided by
BambuTls::resolve and the trust-on-first-connect pinning, where credentials
actually get sent. `to_string` and not the deprecated `as_utf8`, which
truncates at an interior NUL; `valid_serial` would reject the untruncated
form anyway, which is why the shape is checked rather than the name
trusted. `entries_by_nid` on the subject specifically, because an issuer's
CN sits earlier in the DER and anything scanning for the first CN reports
the certificate authority as the printer.

Three decisions that are not incidental:

It prints a [[printer]] block and does not write printers.toml. Discovery
cannot learn an access code - that code is the only thing between anyone on
the network and control of the machine - so every block is incomplete by
construction, and fleet::load rejects an empty access_code. Writing a
half-finished entry in would stop the gateway starting.

`vendor = "bambu"`, never a guessed v1/v2. That value auto-detects on
connect, and a firmware update can move a physical printer between dialects,
so a guess in a file goes stale silently. There is a tempting correlation in
the two printers to hand - the v1 machine negotiated TLS 1.2 and the v2 one
TLS 1.3 - but two samples is not evidence.

A sweep refuses anything larger than a /24. 256 addresses is every printer
anyone has on one segment, and a mistyped /8 would be sixteen million
connection attempts across somebody's network.

The SSDP path has not received a real announcement: the only host available
to test from is routed to the printers rather than on their segment, so
their multicast cannot arrive. Hence the parser is tolerant - a header
spelled differently costs one field, not the printer - and `raw` mode dumps
every datagram, so the first run on a real segment is a test that shows its
working rather than only its conclusion.

openssl is named directly for the CN read. It is already in the tree -
native-tls is openssl on Linux - so this adds nothing to the build, and
Apache-2.0 passes `cargo deny check licenses`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 00:51:09 +00:00
octoturgeandClaude Sonnet 5 94484bb190 Update docs and prune examples superseded by real functionality
README/certs/README/.env.example/printers.example.toml were all still
describing this as a learning-stage stub with no real printer protocol
clients - rewritten to reflect the real Bambu MQTT client, cert
auto-pinning, and marlin serial connection that now exist.

- Remove examples/fetch_bambu_cert.rs: its manual fetch-and-save workflow
  is now what BambuPrinter::connect()'s ensure_trusted does automatically
  on every connect, so it has no remaining use case.
- Fix examples/printer_polymorphism.rs, which no longer compiled -
  dispatch_gcode was replaced by real methods earlier this session;
  swapped in print_status() as the vendor-only-extension example.
- Remove Config::bambu_ca_cert_overrides/bambu_require_valid_cert and the
  .env vars documenting them - confirmed dead (nothing read them),
  fully superseded by printers.toml's per-printer ca_cert_path/
  require_valid_cert plus automatic trust-on-first-connect pinning.
- Fix stale doc comments in printer/mod.rs and printer/bambu.rs that
  described certificate trust and MQTT as not built yet, or referencing
  the now-removed example.

No behavior change in the daemon itself - verified with cargo build
--examples and a live run of printer_polymorphism and auto_connect_bambu
against the real P1P/H2C printers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:17:15 +00:00
octoturgeandClaude Sonnet 5 2c4ef21291 Add upload_project_file/send_gcode_line, real Marlin serial, Bambu protocol auto-detect
- GenericPrinter grows upload_project_file, send_gcode_line, and a
  generic upload_gcode<P> driver that streams a whole gcode file line by
  line through any vendor's send_gcode_line.
- PrusaSerialPrinter's connect/send_gcode_line are real (not stubs): a
  held-open MarlinSerialConnection (src/printer/marlin_serial.rs) writes
  each line and waits for Marlin's "ok" before sending the next. Config
  changed from com_port: i16 to port: String + baud_rate: u32 to hold a
  real device path.
- Bambu: BambuGenericPrinter + BambuV1Printer/BambuV2Printer collapsed
  into one BambuPrinter, since which MQTT/BambuMQTT version (v3.1.1/v1
  vs v5/v2) a printer speaks is discovered at connect() time (or forced),
  not something config should pick a type for - the same physical
  printer can change after a firmware update. printers.toml gets a new
  "bambu" vendor (auto-detect, the default) alongside bambu_v1/bambu_v2
  (force a specific one).
- Bambu's gcode_line request is now built for real via
  continuum-schema-instance against continuum-schemas' JSON Schema,
  picking the v1 or v2 schema directory based on the detected protocol
  version, instead of a hand-written serde struct.
- Added rumqttc as a dependency (not wired up yet - the real MQTT
  v5-then-v3.1.1 fallback connect and the publish/receive plumbing are
  still stubs).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWQVtBGxHYLkVw2kBupd5g
2026-09-05 20:40:21 +00:00
octoturge a2d4e578ec Replace em dashes with hyphens across docs and comments 2026-08-29 08:13:54 +00:00
octoturge 8572d564b1 Split BambuPrinter into BambuGenericPrinter -> BambuV1Printer/BambuV2Printer
Completes the GenericPrinter -> BambuGenericPrinter -> BambuV1/V2 chain this
project wanted from the start. BambuGenericPrinter holds the fields and
behavior every Bambu printer shares (access code, serial number, CA trust,
the TLS test/fetch methods); BambuV1Printer and BambuV2Printer each *have*
one (composition) and are now genuinely separate types, ready to carry
flavour-specific report-schema fields later. Also threads through a new
'sn' (serial number) field the real MQTT topics will need.

Adds real 'fetch the CA automatically' capability, verified against a live
TLS server (not just compiled):
- BambuGenericPrinter::fetch_certificate() does trust-on-first-connect —
  connects once with verification disabled, captures the certificate the
  printer actually presents via native_tls's peer_certificate()/to_der(),
  and returns it as PEM. Deliberately a method you call once by hand
  (examples/fetch_bambu_cert.rs), not something connect() falls back to
  silently, since TOFU trusts whoever's on the network the moment you run
  it. Verified end-to-end against a local openssl s_server: the fetched
  PEM's SHA-256 fingerprint exactly matched the server's real certificate.
- Verified the other direction too: test_bambu_certs (bundled-CA mode)
  correctly REJECTS that same test server's cert, since it wasn't signed
  by the real Bambu CA.

Splitting BambuV1Printer/BambuV2Printer into distinct types broke the
PrinterHandle::BambuV1(x) | PrinterHandle::BambuV2(x) or-pattern in both
examples (or-patterns require every alternative to bind the same type) —
fixed by giving each variant its own match arm.
2026-08-28 21:04:56 +00:00
octoturge d8b7430296 Add printers.toml (gitignored) + real TLS cert test against actual hardware
New:
- printers.example.toml (committed template) / printers.toml (gitignored,
  real hosts + access codes don't belong in git) — a list of real printers
  with vendor, host, access_code/api_key/com_port, and an optional
  per-printer CA override.
- src/fleet.rs loads that file into ready-to-use PrinterHandles, matching
  vendor strings to the right constructor.
- BambuPrinter::test_tls_handshake() does a real (blocking, one-shot) TLS
  handshake to port 8883 using that printer's configured BambuTls trust
  mode — no MQTT protocol, just 'does the certificate verify'. Added
  native-tls and toml as direct dependencies for this.
- examples/test_bambu_certs.rs loads printers.toml and runs the handshake
  test against every Bambu entry.

Also fixes a real bug found while testing against unreachable IPs: plain
TcpStream::connect has no timeout and hung indefinitely on an offline
printer — switched to connect_timeout (5s).

Verified with cargo check --all-targets (0 errors) and by actually running
test_bambu_certs against a local printers.toml (correctly errored on a
missing cert file, then correctly timed out against unreachable test IPs
instead of hanging).
2026-08-28 20:48:05 +00:00