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>
continuum-proxy
Edge gateway daemon for the Continuum print farm platform. Runs on a Linux
SBC on-site, talks to printers over the LAN, and keeps a connection to the
cloud control plane (continuum-backend).
What's real right now
- Bambu MQTT - a real client for both protocol generations (MQTT
v3.1.1+BambuMQTT v1 on older firmware, MQTT v5+BambuMQTT v2 on current
firmware), auto-detected per printer on connect (a firmware update can
move the same physical printer between them, so this isn't something
config can hard-code once and forget - see
printer/bambu.rs'sBambuProtocolPreference::Auto). Request/reply correlation viasequence_id, an in-memoryprint.push_statuscache that merges v1's partial updates and replaces on v2's full ones, and TLS trust that auto-pins a printer's certificate via trust-on-first-connect the first time the bundled CA doesn't verify (printer/bambu.rs'sensure_trusted) - seeprinter/bambu_mqtt.rs's doc comment for the full protocol write-up. - Marlin/RepRap serial - a real held-open serial connection that paces
gcode lines by Marlin's own "ok" acknowledgment (
printer/marlin_serial.rs). - Wire shapes from schemas, not hand duplication - Bambu's raw MQTT
JSON is built straight from
continuum-schemas' JSON Schema files viacontinuum-schema-instance, instead of a second hand-written copy of every request shape drifting out of sync with the schema. - Cloud uplink - a WebSocket client to
continuum-backendthat connects, sends a heartbeat on a timer, and reconnects on drop (src/uplink/). - Camera restreaming - generates go2rtc's configuration from the camera
URLs the printers report, and restarts go2rtc when it changes
(
src/camera.rs+src/go2rtc.rs). Only the X1/H2 class publishes an RTSP URL: confirmed against two real machines, an H2C reportsrtsps://<ip>:322/streaming/live/1and a P1P reports a camera with no URL for it. A P1/A1 camera needs Bambu's proprietary port-6000 JPEG stream, which this crate does not speak, so those printers advertise no stream rather than one that will not play. Note the stream is reachable on the gateway's LAN only - there is no relay. - Bambu file upload - implicit FTPS (port 990) into the printer's own
cache/directory (src/printer/bambu_ftps.rs, viaBambuPrinter::upload_file). Confirmed live against a P1P on 2026-10-03: login, binary mode, and the transfer all work, byte count matched exactly. - Print status -
print.push_statusparsed intoPrintReportwith real job progress, temperatures, fans and HMS entries. Confirmed live on both generations (idle P1P on v1, H2C 20% into a job on v2); regression fixtures from those captures are intests/fixtures/.
Examples worth knowing
cargo run --example live_reports [printer-id]- read-only, safe against a printer that is mid-print. Connects, reports firmware, and watches the status stream. Redirect stderr to capture raw payloads.cargo run --example bambu_idle_checks -- <printer-id>- writes (FTPS upload, fan speed). Requires an explicit printer id and refuses to run unless that printer reportsgcode_state: "IDLE".
What routes between the cloud and the printers
The uplink and the printer fleet are wired together, in both directions
(uplink/mod.rs):
- Up: every 5s, one
PrinterTelemetrysnapshot per printer that has reported, batched into a singleTelemetrymessage. Verified live against a P1P and an H2C. - Down: a
Commandfrom the cloud is looked up by our own printer id, executed, and answered with aCommandResult. The set of commands is deliberately small - a fan speed and a single gcode line - and deliberately has no "start a print" variant; seePrinterCommand.
One quirk worth knowing: a Bambu on protocol v1 only sends a complete
status when asked, so each telemetry tick also fires a pushing.pushall for
the next one. A v1 printer's telemetry is therefore one tick behind, which
is why it shows up on the second frame rather than the first.
What's still a stub
start_uploaded_file - the MQTT command that actually starts a print from
an uploaded file - is written but has deliberately never been fired, since
that commits a machine to hours of work. Its doc comment lists the three
details to confirm on the first real attempt. upload_file is proven, but
only with a small text file, not a multi-megabyte sliced .3mf.
PrusaLink (REST) and Klipper/Moonraker (WebSocket) - printer/prusa.rs
and printer/klipper.rs just println! what they'd send. Also not built
yet: local buffering for telemetry across connectivity gaps (so telemetry
produced while the uplink is down is simply lost), LAN printer discovery
(SSDP/mDNS), and the mechanical plate-changer interface.
Getting started
cp .env.example .env
cargo run # the daemon: connects every configured printer, then runs the cloud uplink + go2rtc watchdog
cp printers.example.toml printers.toml # fill in your real printers (gitignored)
cargo run --example auto_connect_bambu # checks/auto-pins each Bambu printer's certificate, no MQTT needed
cargo run --example test_bambu_certs # narrower: just the raw TLS handshake, no auto-pinning
cargo run --example printer_polymorphism # standalone trait+enum demo, fake data, no network/env needed
cargo run --example bambu_commands_demo # round-trips hand-written structs against real captured Bambu JSON
Structure
src/
main.rs Loads+connects the printer fleet, then runs the uplink, camera config, watchdog and completion watcher side by side
config.rs Loads settings from environment variables
fleet.rs Loads printers.toml into ready-to-use PrinterHandles
uplink/ WebSocket client to continuum-backend: connect, heartbeat, reconnect on drop
camera.rs Writes go2rtc's config from the camera URLs the printers report
go2rtc.rs Restarts the go2rtc camera-restreaming process if it dies, or when the config changes
printer/
mod.rs GenericPrinter trait + PrinterBase + PrinterHandle enum - the trait+enum
"inheritance" pattern this project uses instead of class inheritance
bambu.rs The real Bambu printer type: cert trust, protocol version auto-detect, commands
bambu_mqtt.rs The real MQTT wiring underneath bambu.rs - see its doc comment for the protocol details
bambu_commands/ Hand-written serde structs mirroring continuum-schemas' bambulab/ shapes
marlin_serial.rs A real held-open Marlin/RepRap serial connection
prusa.rs, klipper.rs Still stubs - see "What's still a stub" above
examples/
auto_connect_bambu.rs Checks/auto-pins every Bambu printer's certificate (no MQTT)
test_bambu_certs.rs Narrower: just a raw TLS handshake per Bambu printer
printer_polymorphism.rs Standalone trait+enum demo, five fake printers, no network needed
bambu_commands_demo.rs Round-trips bambu_commands' structs against real captured Bambu JSON
printers.toml (gitignored - copy from printers.example.toml) holds real
per-printer connection details: host, access code/API key/serial port
depending on vendor, and - for Bambu - an optional per-printer CA
certificate override (see certs/README.md; most printers don't need one,
since connect() auto-pins automatically).
Learning Rust through this repo
src/printer/mod.rs's doc comment is where the "how do I do inheritance
without classes" question lives - trait (shared behavior) + struct
(shared fields) + enum (the closed set of concrete types), instead of
GenericPrinter -> BambuPrinter/PrusaLinkPrinter/... class inheritance.
Run printer_polymorphism to see it work end to end without needing any
real printer on the network.