The gateway can now run a cloud-dispatched print: download the job file from
the presigned URL in the command, then hand it to the existing
`upload_project_file`, which derives the filament mapping from the archive,
transfers it over FTPS and starts it.
## The download refuses to be a weapon
`src/uplink/download.rs` checks the HTTP status before reading a byte,
because an expired presigned URL answers 403 with an XML error document and
writing *that* to a printer as a project is the worst outcome available. It
rejects a `Content-Length` over 256 MiB before reading, and enforces the cap
while streaming as well, so a server that lies or omits the length cannot
make a small gateway box buffer until the OOM killer arrives. A byte count
that disagrees with `Content-Length` is an error: a `.3mf` is a zip, and a
truncated one loses the central directory the printer needs. HTTPS only, no
redirects, and the URL is stripped from error messages.
`PrinterCommand` no longer derives `Debug` for the same reason - `dispatch`
logs every command, and a presigned URL is a bearer credential until it
expires. It prints as `<redacted>`.
## Why it is spawned
`start_print` is the one command that cannot be answered inline, and the
reason is not latency. Everything else here is an MQTT publish returning in
milliseconds. This downloads tens of megabytes and then transfers them at a
measured 0.2 MiB/s - about four minutes for a 50 MB project - and awaiting
that inside the uplink's `select!` loop stops heartbeats and, far worse,
stops telemetry for the duration.
No telemetry is what makes spawning mandatory rather than tidy. The cloud now
derives a job's state from the telemetry that follows a start, so a gateway
that goes silent for four minutes during every job start withholds exactly
the information the job depends on - while the backend times the command out
after ten seconds and concludes the start failed. The job would be requeued
onto a printer that was busy printing it.
So the `ok` means "accepted, working on it", which is all a start reply was
ever trusted to mean. `runs_for_minutes` is an exhaustive match rather than a
`matches!`, so a new variant has to make that choice instead of defaulting to
the stall.
The gap this leaves is honest and recorded in the code: a failure after the
ack has no `CommandResult` to travel on, because answering later needs an
outbound channel this loop does not have. Until it does, the gateway's log is
where the reason lives and the backend reaps the job.
reqwest 0.12 with `rustls-tls-native-roots`, not 0.13: 0.13 pulls
`webpki-root-certs` under CDLA-Permissive-2.0, which is not on the allow
list, and `aws-lc` brings OpenSSL-licensed C. `cargo deny check licenses`
passes. 54 tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two changes, both following from the print that paused at 1% because
`use_ams: false` pointed an AMS-sliced project at the external spool.
## upload_project_file no longer guesses
It now reads the `.3mf` it already holds and works out where each filament
should come from. `Metadata/slice_info.config` names every filament's
`tray_info_idx`, type and colour; the AMS report names the same three per
loaded tray; matching them is the whole job. `src/printer/project_file.rs`
does the reading and matching and is free of printer types, so it is
testable without a printer - which is the only way this gets tested at all,
the alternative being to notice a mapping bug by watching a machine print in
the wrong colour.
The mapping is derived *before* the upload: a project that cannot be matched
should not result in a file being moved onto the machine that cannot then be
printed.
It never falls back to the external spool to make an unmatched project
"work". The one case it answers `ExternalSpool` for is a printer reporting no
AMS trays at all. A printer *with* an AMS whose project wants the spool is an
error, not a fallback, because `vt_tray` is not modelled on `PrintReport` -
there is no way to check the spool holds the right material, and claiming a
match without checking is how this went wrong the first time.
Two fixtures carry the real cases: `cube_slice_info.3mf` is the project that
cost the print (deflated on purpose - every other test archive is Stored, so
the decompression path and the flate2 backend behind zip's `deflate-flate2`
would otherwise never run), and the existing `push_status_v1_p1p_idle.json`
supplies the AMS side. The end-to-end test asserts they agree on
`[3,-1,-1,-1,-1]`: both slots hold PETG GFG99 and only the colour tells them
apart, so a matcher stopping at `tray_info_idx` would print the cube in
black.
A malformed `<filament>` element is an error rather than a skip. A skipped
filament leaves a hole at its index, the hole becomes `-1`, and the printer
is told that material is unused - so the plate prints in whatever remains
mapped.
New dependency: `zip` 9.0.3 (MIT) with `deflate-flate2`, plus `flate2` for a
backend, which zip's feature does not select on its own. All six new crates
are MIT, MIT/Apache-2.0, 0BSD/MIT/Apache-2.0, MIT/Zlib/Apache-2.0 or Zlib,
and `cargo deny check licenses` passes. No bzip2, lzma, xz or zstd entered
the graph.
## Lights on completion
`completion::run` is a third daemon task that notices a print finishing and
blinks that printer's chamber light for ten seconds, then leaves it off:
500ms on, 500ms off, twenty commands, ending dark.
Its own task rather than a branch in the uplink's telemetry loop, because
that loop only ticks while the cloud connection is up - and a light that only
works when the internet does is not much of a notification for someone
standing next to the printer.
Three rules, each a false celebration avoided and each tested: only a
*transition* into `FINISH` fires, because `FINISH` persists until the next
print starts; a printer seen for the first time never fires, so restarting
the gateway does not announce prints that finished yesterday; and `FAILED`
never fires, because that is what a Bambu reports for a cancellation,
including every `print.stop` this software sends.
The blink is driven from `On`/`Off` rather than the firmware's `flashing`
mode. `flashing` is in the schema but the meaning of `loop_times` versus
`interval_time` is unconfirmed, so "blink for ten seconds" could not be
predicted; two obvious commands give an exact duration. The light is left off
even when a blink fails part-way, since a machine abandoned with its light on
looks like one still working.
`set_light` is **unverified against hardware** - the P1P was powered off when
this was written, and the doc comment says so. The shape is built from
`schemas/bambulab/{v1,v2}/request/system/ledctrl.schema.json` rather than by
hand, and it goes out as a correlated request so a rejection is visible.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`upload_project_file` was a `println!`. MQTT can start a print but can
only name a file the printer already has, so moving the bytes there is a
separate protocol: implicit FTPS on port 990, user `bblp`, password the
same access code MQTT uses, into the printer's own `cache/` directory.
Confirmed live against a P1P: 102 bytes uploaded, byte count matched
exactly, first attempt.
Deliberately suppaftp's *blocking* client on `tokio::task::spawn_blocking`
rather than its async one. The blocking client takes a plain
`native_tls::TlsConnector`, so this reuses `BambuTls::build_connector()`
unchanged and every TLS trust decision for a printer stays in that one
method; the async client needs a second TLS stack (`async-native-tls`)
whose futures-vs-tokio `AsyncRead` mismatch has already cost this project
a debugging session. `connect()`'s doc comment already pointed at
`spawn_blocking` as the eventual answer for blocking work, and a
multi-megabyte upload earns it more obviously than a handshake does.
`connect_secure_implicit` sits behind suppaftp's `deprecated` feature
because implicit FTPS is what the RFCs deprecated in favour of explicit
AUTH TLS. The printers only speak implicit, so the feature is on.
Split into `upload_file` (FTPS only) and `start_uploaded_file` (the MQTT
command that begins printing), because the two halves have very different
consequences: moving a file onto a printer is inert and reversible, while
starting a print commits the machine to hours of physical work. That split
is what made the transfer testable against real hardware at all.
`start_uploaded_file` dispatches on file type - a `.3mf` is a container so
`print.project_file` needs both a `url` and a `param` naming the gcode
inside it, while a bare `.gcode` has nothing to select and
`print.gcode_file` takes just a path. It has deliberately never been
fired; its doc comment lists the three guesses to confirm first.
suppaftp is MIT OR Apache-2.0, checked against docs/LICENSES.md before
being added, along with all 22 crates it pulled in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Continuum is intended to be sold, so every dependency has to permit
distribution in closed-source commercial software. This crate carried
`license = "UNLICENSED"`, which is not valid SPDX and made `cargo deny`
fail outright; `LicenseRef-Proprietary` with `publish = false` is what
marks it first-party instead.
The note in deny.toml is the part that earns its keep: `src/go2rtc.rs`
spawns go2rtc from PATH and expects the operator to have installed it, so
we currently distribute nothing. go2rtc shells out to ffmpeg, and ffmpeg
is LGPL-2.1+ only when built without `--enable-gpl` - most prebuilt
binaries are GPL builds because they bundle x264. If we ever ship either
binary, those build flags become our problem.
Policy and full audit: continuum-common's docs/LICENSES.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Removes GenericPrinter::receive (two disconnected fire-and-forget stubs
that didn't actually give request/response) in favor of a real mechanism
in the new src/printer/bambu_mqtt.rs:
- BambuMqttSession wraps whichever rumqttc client a connection ended up
using (v3.1.1's AsyncClient/EventLoop for BambuMQTT v1, v5's for
BambuMQTT v2 - different types, different event/error shapes) behind
one interface. `open()` respects ForceV1/ForceV2, and for Auto tries
v5 first, falling back to v3.1.1 on any failure (see the module doc
comment for why "any failure", not a precisely-matched version-
rejection code - unverified against a live printer, no broker here to
test against).
- Reuses BambuTls::build_connector (now pub(crate)) for rumqttc's TLS
transport via TlsConfiguration::NativeConnector, instead of a second,
separate TLS trust story - hence rumqttc now pulls in use-native-tls
instead of its default rustls stack, so there's one TLS implementation
in the binary, not two.
- request() publishes and waits (with a timeout) for a report whose
sequence_id matches, correlated via a shared pending-request table
that a spawned background task completes as reports arrive - the
first real tokio::spawn in this crate, flagged as deferred since
printer/mod.rs's very first doc comment; this is why it was deferred
until now, and why it's needed now (polling the eventloop has to run
continuously, independent of whatever call site is waiting).
- publish()/publish_raw() are the fire-and-forget half, for commands
like gcode_line that only get acknowledged implicitly.
BambuPrinter::connect/send_gcode_line/publish now call the real session
instead of printing what they would do. Verified against 192.0.2.1 (a
reserved, never-routable test address): both AsyncClient/EventLoop pairs
actually attempt a real TCP connection and fail gracefully (network
timeout, not a panic) when nothing answers, and Auto genuinely attempts
both while ForceV1 skips straight to v3.1.1.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWQVtBGxHYLkVw2kBupd5g
- 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
One file per command (get_version.rs, pushall.rs), each holding its
request AND response together. CommandEnvelope (sequence_id + command) is
the shared-fields piece every command embeds via #[serde(flatten)] — the
'inheritance' replacement for these structs, same composition idea as
BambuGenericPrinter in bambu.rs, just expressed as a struct field instead
of impl-block delegation.
examples/bambu_commands_demo.rs round-trips both commands against Bambu's
exact real JSON (from the original conversation) and confirms byte-for-
byte matches, same as the removed proto version did — this replacement is
behaviorally identical, just without protoc/build.rs in the loop.
BambuRequest::Info(InfoCommand) is now live (was commented out) —
examples/bambu_protocol_demo.rs constructs one, serializes it, and prints
it next to Bambu's real wire JSON for comparison. Run it and copy this
exact shape (proto message -> use it here -> add an enum variant) for
every other command.
The manual fetch_bambu_cert workflow works but needs two commands and a
printers.toml edit. ensure_trusted() collapses that into one call, using
the same trust model SSH uses for host keys:
- a pin already on disk (certs/pinned/<id>.pem) is used directly, no new
trust decision is made on every run
- no pin yet + bundled CA verifies fine (current-gen printers): nothing to
do
- no pin yet + bundled CA fails (P1P, etc.): auto-pins via
trust-on-first-connect, same as fetch_bambu_cert, but automatic
- an *explicit* pin (you set ca_cert_path yourself) never gets silently
auto-pinned over — a failure there is a real error
examples/auto_connect_bambu.rs demonstrates the one-command flow.
Verified all three states against local test servers, not just compiled:
first run with no pin auto-pins and connects; second run against the same
server is silent (no re-TOFU message) and just verifies against the pin;
third run after swapping the server's certificate correctly FAILS instead
of silently re-pinning — confirms the security property survives the
smoother UX.
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.
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).
Removed adapters/ (rumqttc MQTT, suppaftp FTPS, reqwest HTTP), plate_changer/
(tokio-serial), discovery/, and cache.rs (rusqlite) — src/printer/ already
covers 'talk to a printer' as simple stubs, so keeping a second, more complex
version alongside it was redundant. main.rs goes from 6 concurrent tasks to
2 (uplink + go2rtc). uplink/ drops channels, jitter, and Send-bound futures.
Verified with cargo build + cargo run --example printer_polymorphism.