octoturge 254798e8cb Model vt_tray and the stage fields, and use them
## vt_tray closes a hole in the filament decision

`filament_source_for` used to refuse a project that wanted the external
spool, on a printer that had an AMS, purely because there was no way to see
what the spool held - and claiming a match without looking is how the
`use_ams: false` bug happened. Now that `vt_tray` is on `PrintReport`, the
question can be answered: `external_spool_satisfies` reuses `map_to_trays`
with the spool as the single candidate tray, so an `Ok` means every filament
matched it.

The decision is now: the AMS first when there is one, because a project
sliced for the AMS should use the AMS even if the spool happens to hold the
same filament; the spool if nothing in the AMS fits and the spool does; and
an error otherwise, reporting the AMS mismatch rather than the spool one
since that names what was wanted against what was loaded. A reported spool
that does not match is a real negative now and is refused.

One case stays unverifiable: a v2 printer reporting neither AMS trays nor
`vt_tray`. v2 omits the key entirely, so absence means "not reported", not
"empty". That one is logged as an unverified assumption instead of being
passed off as a match - and it is the same reason `EXTERNAL_SPOOLS` is still
a constant. Counting reported spools would give 0 on exactly the printer the
ams_mapping schema says has two.

Tests use the real P1P capture's own spool (PETG P9599a25 in D3C5A3FF): it
satisfies a project asking for that filament, and does not satisfy one asking
for the AMS's blue GFG99. Those two were indistinguishable before.

## stage reaches the cloud

`stg_cur`/`stg` are modelled, and `stage` is now on `PrinterTelemetry`,
through `continuum-schemas` and the ts-types mirror. Without it progress is
ambiguous in a way that reads as a fault: on the confirmed P1P run `percent`
climbed to 14% while `layer` stayed 0 for minutes, the printer walking
`stg_cur` 54 -> 14 -> 13 -> 0 before laying the first layer. A console
showing 14% at layer 0 with no stage looks stuck.

A zero stage means "printing" and has its own test on both sides, because
`skip_serializing_if = "Option::is_none"` plus one `unwrap_or_default` is all
it takes to turn "printing" into "no stage".

The backend test asserts a *wrongly typed* stage is rejected, not just that a
valid one is accepted: `PrinterTelemetrySchema` permits unknown properties -
verified - so `{ stage: 54 }` validates whether or not the field is declared.
The negative is what fails if someone removes it.

The live capture fixture is left alone. It predates the field, the field is
optional, and editing a capture by hand would defeat the point of having one.
2026-10-15 21:37:20 +00:00

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's BambuProtocolPreference::Auto). Request/reply correlation via sequence_id, an in-memory print.push_status cache 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's ensure_trusted) - see printer/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 via continuum-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-backend that connects, sends a heartbeat on a timer, and reconnects on drop (src/uplink/).
  • go2rtc watchdog - restarts the camera-restreaming subprocess if it dies (src/go2rtc.rs).
  • Bambu file upload - implicit FTPS (port 990) into the printer's own cache/ directory (src/printer/bambu_ftps.rs, via BambuPrinter::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_status parsed into PrintReport with 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 in tests/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 reports gcode_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 PrinterTelemetry snapshot per printer that has reported, batched into a single Telemetry message. Verified live against a P1P and an H2C.
  • Down: a Command from the cloud is looked up by our own printer id, executed, and answered with a CommandResult. The set of commands is deliberately small - a fan speed and a single gcode line - and deliberately has no "start a print" variant; see PrinterCommand.

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 and go2rtc watchdog 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
  go2rtc.rs             Restarts the go2rtc camera-restreaming process if it dies
  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.

S
Description
Continuum platform: continuum-proxy
Readme
914 KiB
Languages
Rust 100%