Compare commits

65 Commits
Author SHA1 Message Date
octoturge f027fe7947 Add continuum-proxy enrol to connect a gateway with a one-time code
Windows discovery tool / windows (push) Successful in 1m0s
Exchanges a short code from the Setup page for the farm's gateway
credentials and writes them into the config file (owner-only, atomic,
other keys kept). PC installs only; refused on a Proxy Box.
2026-10-30 08:13:00 +00:00
octoturge f21ad0d497 Keep job outcomes until the cloud acknowledges them
Windows discovery tool / windows (push) Successful in 1m2s
The gateway now follows the job each printer was handed. Started, finished
and failed are written to its SQLite store and sent as job_event on every
connection until a job_event_ack clears them, so an outcome reached during
an outage, or before a restart, is still delivered.

A start that fails after being accepted goes through the same outbox
instead of a one-shot message to whoever was connected. Nothing is
concluded from a printer's state until the job has been seen running,
because a printer told to start still reports the previous job's FINISH.

The backend still owns the queue; this only reports.
2026-10-30 08:05:04 +00:00
octoturge 9359add6aa Drive Klipper, OctoPrint and PrusaLink for real
Windows discovery tool / windows (push) Successful in 1m2s
The three were println stubs. Each is now an HTTP client (Moonraker,
OctoPrint's REST API, PrusaLink's v1 API) behind a small shared helper:
status polled in the background, pause/resume/cancel, G-code lines, fan
speed, and upload-and-start of a sliced file. A slicer project (.3mf) is
refused for these, and so is an upload while a print is running.

Telemetry and the completion watcher now read a vendor-neutral
PrinterStatus instead of Bambu's PrintReport, so these printers report
progress, temperatures and job state to the cloud.

Written from the public API docs and tested against mock servers only; no
real Klipper, OctoPrint or PrusaLink machine has been on the bench.
2026-10-30 07:49:31 +00:00
octoturge b1e733a5fe Bound MQTT publishes and send status refreshes unacked
Windows discovery tool / windows (push) Successful in 1m35s
On the first real dispatch the proxy froze about 4.5 minutes in: pushall was
published at QoS 1 from two tasks while holding the fleet lock, and once the
in-flight window filled every publish blocked forever, stalling telemetry,
heartbeats and the uplink. Publishes now time out after 10 s and pushall is
QoS 0.

Also adds an optional ams_slot to start_print so a job can choose its AMS
slot (single-filament projects, same material type only).
2026-10-30 06:56:24 +00:00
octoturge 47290175aa Accept partial ams objects; allow plain-HTTP downloads from a local store on request
Windows discovery tool / windows (push) Successful in 57s
A P1P mid-print pushes ams objects with no unit list; every such update was
dropped as "missing field ams", silencing telemetry for the rest of the print
(found on the first real dispatch). The unit list now defaults to empty and a
partial object does not wipe known units.

CONTINUUM_ALLOW_INSECURE_LOCAL_DOWNLOADS=true lets the downloader fetch a
presigned URL over plain HTTP, but only from a loopback or RFC 1918 address.
2026-10-29 22:13:47 +00:00
octoturge 0c449c47bc Add a read-only dry run of a print's filament mapping
Windows discovery tool / windows (push) Successful in 1m4s
bambu_mapping_check downloads one project from the card and prints the AMS
mapping the production path would send, starting nothing.
2026-10-29 16:20:03 +00:00
octoturge 0c8ad9cc3d Serve P1/A1 camera frames over the uplink
A P1 or A1 has no RTSP endpoint; its camera is a TLS stream on port 6000.
bambu_camera logs in and reads one JPEG frame, and the uplink answers a
camera_snapshot_request with it. Telemetry advertises camera_snapshot for
printers that report a camera block but no RTSP URL.
2026-10-29 15:55:21 +00:00
octoturge b4948076a0 Trade the gateway token for an edge JWT on every connection
Windows discovery tool / windows (push) Successful in 1m24s
The proxy put its configured gateway_token straight into the upgrade's
Authorization header, but the backend only accepts a one-hour edge JWT
(scope "edge") there, minted by POST /auth/edge-token from the farm's edge
proxy token. Nothing in the proxy ever made that call, so a gateway
configured with the token the console hands out could not connect at all,
and one given a hand-minted JWT (which is what every test did) would have
been locked out an hour later on its first reconnect.

Each connection attempt now starts by trading gateway_token for a fresh JWT,
at an address derived from the socket URL so there is one to configure. A
401 is reported as what it means (token rotated or wrong), since retrying
will not fix it. Config's Debug no longer prints the token.

tests/uplink_late_result.rs now plays the cloud's exchange endpoint and
asserts the socket opens with the minted JWT, not the configured token.
.env.example and continuum.example.toml say what the token is.
2026-10-29 15:10:27 +00:00
octoturge 5fc09aef04 Test that an added printer joins the fleet and survives a restart
The OctoPrint client's connect succeeds without hardware, so it stands in
to prove the plumbing of a successful add: saved, joins the running fleet,
refused when added twice, and rebuilt from the store after a restart.
2026-10-29 15:07:44 +00:00
octoturge 9dff600160 Let the console add and remove printers; keep them in a local SQLite store
The only way to give a gateway a printer was to edit printers.toml and
restart it. A printer added from the console now arrives as a
provision_printer message, is checked by actually connecting to it (so a
mistyped access code is reported to the operator, not discovered later),
and only then is saved to the gateway's own SQLite file and joined to the
running fleet - no restart. remove_printer undoes it. Both are answered
with a command_result carrying the request id.

The access code exists only on the gateway: the cloud relays it once and
keeps nothing, which is the platform's rule for printer credentials. The
store file is created owner-only; the message type's Debug and the fleet
entry's Debug redact the credential; and an unparseable message from the
cloud is no longer logged with its payload, because the message that
fails to parse is exactly the one carrying an access code.

The cloud may not choose a certificate path or serial port on the
gateway's disk, so provision_printer cannot carry them.

Startup now reads printers.toml (strict, as before; a missing file is fine)
and the store (a bad stored entry is skipped, not fatal), so the gateway
comes up with its fleet with the API unreachable. Discovery treats stored
printers as already configured. rusqlite (MIT, bundled SQLite) passes
cargo deny.
2026-10-29 15:02:48 +00:00
octoturge 6c60bd1f70 Rebuild the Windows tool when Cargo.lock changes
Windows discovery tool / windows (push) Successful in 51s
2026-10-29 14:20:14 +00:00
octoturge f33b9092e8 Commit Cargo.lock and update the crates cargo-deny flagged
The lockfile was gitignored, so CI and every checkout resolved dependencies
afresh and a build could change underneath us - and the advisories below
were sitting in whatever happened to resolve. Tracked now, as is usual for
a binary; the proxy ships as an executable.

`cargo update` alone cleared all four findings (no workaround needed):
rustls-webpki 0.103.15 -> .16 (RUSTSEC-2026-0382, certificate verification
could accept distrusted intermediates), ctutils 0.4.3 -> .7
(RUSTSEC-2026-0385), tokio 1.53.1 -> 1.54.1 (RUSTSEC-2026-0386), and the
yanked serialport 4.10.0 -> 4.10.1. cargo deny check: advisories, bans,
licenses and sources all ok. Tests pass; clippy unchanged (the two
pre-existing large_enum_variant warnings).
2026-10-29 14:20:04 +00:00
octoturge 9061d39684 Don't let a missing serial listing discard an 'all' discovery on Windows
Windows CI (run 415) ran the discovery tests: 48 passed and one failed,
because port listing reads Linux sysfs and the test assumed it exists.
That is a real limitation, not just a bad test - and in the example it
meant `all` would listen and sweep and then abort on the serial step,
throwing the results away.

The serial step in `all` is now a printed note on failure; the standalone
`serial` command still errors. The Linux-only test is gated to Linux and
a counterpart asserts that off Linux the listing is an error, not a clean
empty result.
2026-10-29 11:17:01 +00:00
octoturge 970db780be End the build step with exit 0
The deliberate no-argument smoke run exits 1 and PowerShell passes the last
native exit code to the runner: run 414 compiled, passed its checks and was
still marked failed.
2026-10-29 11:05:44 +00:00
octoturge 32dba6af2c Clone the sibling repos with git, not a second actions/checkout
Run 413: a second use of actions/checkout in one job fails on this Windows
runner syncing the action's cached clone (pack .idx: Access is denied).
2026-10-29 11:03:26 +00:00
octoturge 7124335fb7 Check out the sibling repos the proxy's path dependencies need
The first CI run built OpenSSL fine and then failed: Cargo.toml depends on
../continuum-schemas and ../continuum-common, which the runner's checkout
of the proxy alone did not have.
2026-10-29 11:02:23 +00:00
octoturge 815131b951 Build discover_printers for Windows on the Gitea runner
SSDP can only be tried on a machine that shares the printers' network
segment, and the development container is a router hop away. This builds
the existing discover_printers example as a Windows .exe and uploads it as
a CI artifact, with a README for whoever runs it and a SHA-256 file.

Uses the windows-latest self-hosted runner continuum-cad already uses
(Windows PowerShell 5.1, MSVC present, upload-artifact@v3 only). The proxy
links OpenSSL directly and Windows has none, so the job builds it with
vcpkg (x64-windows-static-md, linked into the .exe) in a directory of its
own, away from the CAD pipeline's. The job smoke-runs the exe, then runs
the discovery tests on Windows after the upload, so a Windows-only test
failure turns the job red without withholding the executable.

Not run anywhere yet: this container cannot build for Windows (no MSVC or
mingw). The first CI run is the first real build.
2026-10-29 10:54:07 +00:00
octoturge e0a6ccd2c1 Join the SSDP group on every IPv4 interface, not the OS's pick
bind_ssdp passed UNSPECIFIED as the interface, which leaves the choice to
the OS. On a machine with several adapters - a Windows PC with Wi-Fi plus a
VPN, Hyper-V or WSL adapter is the ordinary case - it joins on one and then
hears nothing from printers on the others, while looking healthy. That is
the worst way for a tool whose first real run is the test to fail.

It now joins on each non-loopback IPv4 interface (if-addrs, MIT OR
BSD-3-Clause, one libc dependency), skips an adapter that refuses, fails
only if none joined, and logs which were joined. Falls back to the OS's
choice if the interfaces cannot be listed.

Checked on Linux with a single interface only; the multi-adapter case is
the reason for the change and is not exercised here.
2026-10-29 10:52:50 +00:00
octoturge 29c85fa449 Ignore the pid file the go2rtc supervisor writes 2026-10-29 10:48:57 +00:00
octoturge 07f49f0c5b Stop a go2rtc that outlived a hard-killed proxy
SIGTERM already stops go2rtc (the signal handler), but SIGKILL and the OOM
killer give the proxy no chance to, and the orphan keeps :1984/:8555 bound
and a printer's access code in memory.

Two layers, neither needing unsafe or a new dependency:

- The watchdog records go2rtc's pid next to its config and, on startup,
  stops a leftover. It acts only if /proc confirms the pid is still *our*
  go2rtc - the configured binary on our own `-config` path - so a reused pid
  or another proxy's go2rtc is never touched; without /proc it does nothing.
  The pid file is removed when the supervisor goes away.
- deploy/continuum-proxy.service, whose KillMode=control-group (written out,
  with the reason) makes systemd kill the whole cgroup when the proxy dies.
  Syntax-checked with systemd-analyze only; not run under a real systemd.

Tests: the identity check, and a real stand-in process - a leftover is
terminated while an unrelated `sleep` survives. Making the identity check
always say yes fails both, so they do guard the behaviour.
2026-10-29 10:47:26 +00:00
octoturge ecbec11336 Pin a real H2C's SSDP reply as a test fixture
The SSDP parser's header spelling had never been checked against a real
printer, because multicast does not reach this container. It turns out a
printer answers a *unicast* M-SEARCH sent straight at it, on both 1990 and
2021, so the reply can be captured across the router.

The H2C's 200 OK uses exactly the spelling the parser guessed
(DevModel/DevName/DevBind.bambu.com, USN as the serial, a bare-IP
Location), so no parsing change was needed - this adds the real shape as a
regression test (serial, name and address made up) and corrects the doc
comment that said the spelling was unchecked.

The P1P did not answer a unicast M-SEARCH on any of 1990/2021/1900. An
unsolicited NOTIFY over multicast is still unseen.
2026-10-29 10:30:09 +00:00
octoturge af8bac4bf3 Stop orphaning go2rtc, serve cameras sooner, keep its config private
Three defects found by running a real WebRTC viewer through the whole
stack for the first time (continuum-backend/scripts/e2e-camera.sh).

1. go2rtc was orphaned on every ordinary stop. `kill_on_drop(true)` was
   set, but main handled no signals, so SIGTERM - what `kill`, `docker
   stop` and a service manager send - ended the process on the spot with
   no destructor run and the child was never dropped. The orphan keeps
   :1984 and :8555 bound, so the restarted proxy cannot start its own,
   and it keeps a printer's access code in memory. One had been running
   for 4.5 days. main now selects on SIGTERM/SIGINT so it returns
   normally and the supervisor is dropped. A SIGKILL or OOM kill still
   orphans it; PR_SET_PDEATHSIG would cover that but needs unsafe and a
   libc dependency, so it is left for a decision.

2. A camera was advertised before go2rtc could serve it. The comment on
   REBUILD_INTERVAL said the first useful rebuild is "a few seconds
   after startup", but a tokio interval ticks immediately - with the
   fleet still empty - and the next tick was 30s away. For that window
   the cloud offered a camera and go2rtc answered "stream not found".
   Polling every 5s costs nothing: it reads in-memory reports and only
   restarts go2rtc when the rendered config changes.

3. The generated go2rtc.yaml was created 0644 by tokio::fs::write. It
   holds the printer's access code and go2rtc's API password, so any
   local user could read both. It is now created owner-only and fresh,
   because a temp file left by a crash would otherwise keep its old
   mode. The new test pre-creates a stale 0644 temp file; against the
   old implementation it fails with mode 420 (0o644) where 384 (0o600)
   is expected.

cargo test: 193 lib tests and all integration tests pass.
2026-10-23 16:01:34 +00:00
octoturge c6cf9bcb5e Report discovered printers to the cloud, off by default
The gateway could already find printers; only a human reading a terminal
could see the result. So adding a printer in the web console still began with
walking to a machine to read its IP address off the screen.

Now a background task scans on a timer and sends a `discovered_printers`
frame. It cannot finish the job - the credential is on that screen and no
probe can read it - but it removes the address, the serial and the guesswork
about which vendor the thing is.

OFF by default, which is the load-bearing part of this commit. A /24 sweep is
254 TLS handshakes and up to three HTTP requests per host, and nothing
watching can tell that from a port scan; on a corporate network, being
indistinguishable from a port scan is itself the problem. A gateway that did
this by default would scan networks its operator never meant to point it at,
and the first they would hear of it is from their own security team. So:

- `discovery.enabled` is false unless set, per gateway, in that gateway's own
  config. The cloud cannot switch it on. A setting whose whole purpose is to
  bound what this process does to the local network has to be decided where
  that network is understood, which is not in a data centre.
- `subnets` is explicit, never derived from this host's interfaces, so a
  second NIC, a VPN or a container bridge cannot widen the sweep. Anything
  wider than a /24 is still refused, now at config-import time rather than
  when the timer first fires.
- A flag that is neither `true` nor `false` is an error, not a shrug. The two
  readings of a typo are not equally safe.
- Naming a subnet without setting `enabled` scans nothing - the safe reading
  of an incomplete instruction - and `enabled` with nowhere to look is
  refused as the mistake it is.
- The env var can switch it *off* over a file that enables it, which is the
  direction that has to work: a Proxy Box's config is signed in the cloud, so
  an operator who finds their gateway probing cannot just edit it.

What a scheduled scan will not do, unlike the example CLI: send `M115`.
Opening a serial port toggles DTR, which reboots an Arduino-based board and
ruins a print in progress. The example can do it behind
`--yes-reset-the-printer` because a human is standing there; a timer has
nobody to ask, so it does not get the option and reads sysfs instead.

Two things that took more care than expected:

`Scan::looked()`. An empty report means "this gateway looked and its network
has no unconfigured printers", so sending one after every probe failed would
state something untrue about the farm. A partial failure is still evidence,
though - withholding a good sweep because a container can never bind SSDP
would suppress it forever - so the test is "did anything succeed", not "did
everything".

`fleet::configured()` reads the fleet file at parse level rather than through
`load`, because the entry it most needs to recognise is the one `load`
refuses: a printer discovery suggested, pasted in, with the access code not
yet typed in. Re-offering that printer as a fresh find is noise at the one
moment the operator is mid-way through adding it.

Verified against the real LAN: a sweep found both printers by TLS certificate
with correct serials and no credentials, and the frame it produced validates
against the backend's EdgeClientMessageSchema. The `wire` subcommand on the
example prints exactly that frame, and the capture is pinned as a backend
fixture. SSDP remains untestable here - this container is a router hop from
the printers and multicast does not cross one.
2026-10-19 08:21:30 +00:00
octoturge 1cd8ce25c3 Authenticate config bundles: Ed25519, per-box identity, no plaintext
The hash in a bundle only proved it was undamaged. Anyone who could write
the stick could write both files, so a Proxy Box would install a config
handed to it by whoever got physical access - including a config pointing the
box at a different cloud.

Encryption alone does not fix that either. While plaintext was accepted an
attacker simply did not encrypt, and the box took it. So a Box now refuses
plaintext outright, and the bundle carries a signature.

A third entry, `config.toml.sig`: strict TOML with `box_id`, `config_version`
and a raw Ed25519 signature. What is signed is `signed_message()` - a domain
tag, the length-prefixed box id, the version big-endian, then the raw
`config.toml` bytes. Raw bytes rather than the archive, so the signature is
independent of ZIP framing, compression, entry order and encryption. The box
id and version are inside the signed message rather than beside it, so
neither can be edited without breaking it, and the length prefix stops a
byte-shifting collision between the id and the config.

Past decryption the order is hash, signature, box id, version, then parse and
validate. The claims in the `.sig` are attacker-controlled text until the
signature over them verifies, so nothing is concluded from them before then -
and nothing about this box's identity is revealed before then either, which
is why unsigned junk cannot be used to probe which box it is talking to.

Which error wins when a box cannot even open a bundle: a signature over the
plaintext is only checkable after decryption, so a box with the wrong key
cannot distinguish "meant for another box" from "forged". Wrong key wins.
Neither bundle installs either way, it is nearly always the true cause, and
calling something forged that we could not open would be an accusation we
cannot support.

Replay is enforced rather than documented away: `config_version` must not go
backwards, recorded in a sidecar beside the installed config. An equal
version is accepted on purpose, because the version is written before the
config, so a power cut between the two leaves "new version, old config" and
re-inserting the same stick repairs it. The other write order would leave a
replay window instead.

Both key sets are lists, so the cloud's signing key and a box's own key can
be rotated. `load_provisioning()` is the single seam where a box's identity
and keys come from; a TPM or secure element replaces that function and
nothing else. A missing file means unprovisioned, and the box then says
loudly what it cannot check - but an empty or malformed one is an error,
because quietly reading a broken key file as absent would turn a
provisioning mistake into a box that accepts unsigned bundles.

Not verified: interop with the real cloud signer. These tests prove the box
accepts what this code defines as correct, not that the cloud will produce
the same bytes - the message layout, the pure-Ed25519-no-prehash choice and
the `.sig` shape all need a cross-check against the configurator's own crypto
library once it exists. A compromised cloud signing key still breaks every
box trusting it; revocation is not built.
2026-10-19 08:08:27 +00:00
octoturge ad55645ec9 Accept an AES-encrypted config bundle from the cloud
The bundle is authored by Continuum Cloud's configurator and downloaded by
the customer, so it travels over the internet and then on a USB stick while
holding the gateway token and possibly a wifi PSK. That makes it a secret at
rest, and confidentiality is the benefit encryption actually delivers here.

WinZip AES-256 ("AE-x"), via the zip crate's `aes-crypto` feature. That
pulls in aes, hmac, pbkdf2, sha1, constant_time_eq and zeroize - all
permissive (MIT/Apache-2.0, and CC0-1.0 OR MIT-0 OR Apache-2.0 for
constant_time_eq), each checked against docs/LICENSES.md, and
`cargo deny check licenses` passes.

**Legacy ZipCrypto is refused outright**, whatever key is configured: it
falls to a known-plaintext attack. The test is on metadata, not on a failed
decrypt - `encrypted()` is set for ZipCrypto and AES alike, but only AES
carries the AE-x extra field, so encrypted-without-it is ZipCrypto and the
whole bundle is rejected. Judged in the first pass, before any decrypt call,
so the crate's ZipCrypto path never runs on our input. Mixed bundles - one
entry encrypted, the other not - are rejected too.

The caps now bound *decrypted* output, so a small ciphertext that inflates
is capped exactly as a plaintext bomb is. Everything else holds unchanged
for an encrypted bundle: zip-slip, the inner config.toml.sha256, the
no-environment validation, atomic install, and a rejected bundle never
touching the installed config.

Failures are distinguished, because "wrong key" and "damaged" need different
answers: no key on the box and a wrong key both say "not for this box", while
tampered or damaged ciphertext says it failed its AES integrity check. A
wrong key passes AES's 16-bit password check 1 time in 65536 and then fails
the HMAC, so that message names a wrong key as a possibility.

**The limitation to be clear about: with plaintext bundles still accepted,
encryption does nothing against tampering.** An attacker supplies a
plaintext bundle and never touches the crypto. The tamper goal only begins to
hold once `accept_unencrypted` is false, which is one line in main.rs and is
deliberately not flipped yet - doing so breaks every stick made before the
configurator encrypts, and every hand-made development stick. That is a
product decision, not a code one.

More generally, encryption is not authenticity: the AES HMAC detects
modification only by someone without the password, and the password has to
live on the box, so extracting it from one image breaks every box sharing it.
It raises the bar from "anyone with a text editor" to "anyone who can read a
firmware image". The module docs say exactly that and do not call it
tamper-proof. `check_authenticity` is a named no-op where an Ed25519 check
over the config, verified against a *public* key in the box image, belongs -
it verifies nothing and says so, so the missing authenticity is a searchable
gap rather than an implicit one.

`load_bundle_key` is the single marked seam where provisioning lands. Its
current `$CONTINUUM_BUNDLE_KEY_FILE` / `/etc/continuum/bundle.key` lookup is
a placeholder, not a decision: shared-vs-per-box keys, where the key lives,
and rotation are open product questions and the mechanism depends on none of
them.

Encrypted fixtures are genuine AES ZIPs written by the zip crate itself, and
one test confirms the config text is absent from the ciphertext. The
ZipCrypto fixture sets the encrypted flag without an AE-x field, which is the
metadata shape this must refuse. Unproven: interop with whatever ZIP writer
the cloud actually uses (only archives written by this same crate version
were read back), AES-128/192, and `load_bundle_key` itself, which reads
process env and so has no test.
2026-10-19 07:56:25 +00:00
octoturge 64d46dbc49 Load proxy settings from a file, and import them from a pendrive
Everything came from CONTINUUM_* environment variables, which suits neither
shape the product ships in. A Proxy Box is an appliance whose settings
arrive on a USB stick; a proxy on someone's PC is a config file they edit.

Precedence is stated once, in config.rs: the file is the base, env overrides
it, defaults fill the rest. `Config::from_env` keeps its behaviour and the
struct is unchanged, so existing callers and tests are untouched. Layering
is `Config::layered(file, env_lookup)` with the environment injected as a
closure, so tests do not have to mutate process env to cover it.

The bootstrap is read from $CONTINUUM_BOOTSTRAP, then
/etc/continuum/bootstrap.toml, then nowhere - and nowhere means PC mode with
./continuum.toml, so an env-only deployment behaves exactly as before. The
working directory is deliberately not searched, and the bootstrap itself
must never come from a stick: it is what says whether sticks are trusted at
all.

Bundle format is continuum-config.zip holding config.toml and
config.toml.sha256. The hash is checked before the bytes are parsed - the
TOML parser should not be the first thing to read possibly-corrupt input -
and **the comments say plainly what that buys: corruption and change, not
forgery.** Anyone who can write the stick writes both files. It is an
integrity check under a recorded trust assumption, that physical access to
the box is trusted; a signature with the key in the box image is named as
the follow-up that would give authenticity. sha256 via openssl, already a
direct dependency, so no new crate and `cargo deny check licenses` still
passes.

The hazards of ingesting a file off removable media, all tested:

Zip-slip is refused on absolute paths, `..` components, backslash
separators, drive prefixes, NUL and non-UTF-8 names, and one bad entry
rejects the whole bundle rather than being skipped. The stronger guarantee
is structural though: entries are only ever read **by index, matched against
two literal names**, and no write path is ever derived from an archive name,
so a hole in the name check still writes nothing. A test confirms a bundle
carrying `../evil.toml` alongside a valid config writes nothing outside the
target and leaves the installed config alone.

Zip bombs are capped at 16 entries, 256 KiB per entry and a 1 MiB file on
the stick. Declared sizes are refused in a first pass that decompresses
nothing, and a bounded reader then caps actual output so a lying header
gains nothing.

Installing is atomic: temp file in the destination directory - not /tmp,
since rename is only atomic within a filesystem - created 0600 from the
first byte because the config holds the gateway token, fsync, rename, then a
best-effort directory fsync. A write that fails halfway leaves the previous
config intact and no temp file behind.

A bad stick never costs the box its config: a bad hash, bad TOML, an unsafe
entry or missing settings all return Rejected and are logged as keeping the
existing config. A correctly-hashed config is also validated **with no
environment at all**, so a stick cannot install something that parses now
and fails to start on the next boot - which would be a bricked box caused by
exactly the stick we were being careful about.

PC mode ignores pendrives twice over, by mode and by an emptied directory
list, because on a general-purpose machine importing settings from a USB
stick would be a way to change them behind the user's back.

A [network] block is parsed, validated and exposed, and stops there. main.rs
logs a warning that this build does not apply it, so a box given network
settings cannot look like it acted on them. Applying them needs root, is
specific to the appliance image, and a wrong write strands the box - it wants
its own decision, not a side effect of reading a file.

continuum.toml is gitignored, since it holds the gateway token.

Not done, and not claimed: no signature, so no authenticity. There is no real
pendrive here, so the removable-media path was tested with ordinary
directories standing in for mount points - real udisks/udev mounts, FAT
behaviour, a genuinely yanked stick and how promptly the 5s poll notices a
mount are all unproven. The watcher's install-then-exit flow, which relies
on a supervisor restarting the process, has no test and no supervisor exists
in this environment; a stick left permanently inserted reports Unchanged
rather than looping, because the hash matches. Env still overrides on a box,
so a box is only pendrive-only to the extent its environment is trusted -
bootstrap.rs says so.
2026-10-19 07:49:46 +00:00
octoturge da28597eeb 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.
2026-10-19 07:40:28 +00:00
octoturge a401773406 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.
2026-10-19 07:28:05 +00:00
octoturge e4344b208b 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`.
2026-10-19 00:51:09 +00:00
octoturge 042cf430ff Include the cause chain when go2rtc cannot be reached
reqwest's Display is only "error sending request for url (...)" - the
reason is one or two sources down, and "connection refused" versus
"timed out" is the entire content of the message. This error is relayed
to the cloud and then to a viewer, so it is the only explanation anyone
gets for a camera that will not play.

It now reads "could not reach go2rtc at http://127.0.0.1:1984: ...",
naming go2rtc explicitly, because by far the likeliest cause is that it
is not running and an error mentioning only a port sends whoever reads
it looking at the network instead.
2026-10-19 00:37:27 +00:00
octoturge 1a0037af36 Answer relayed WebRTC offers from the gateway's own go2rtc
Adds src/uplink/webrtc.rs: on a webrtc_offer from the cloud, POST the
offer to go2rtc on 127.0.0.1 and send the answer back as webrtc_answer,
or the reason as webrtc_error.

No credential is involved, because the request goes to loopback and
go2rtc exempts loopback from its API password. That is measured, and it
is also why a probe from the gateway cannot be used to check the
password is working - test from another host for that.

Spawned rather than awaited in the select! loop: go2rtc gathers ICE
candidates before answering, which includes a STUN round trip, so
awaiting inline would stall telemetry and heartbeats for a whole farm
while one person opened one camera. It rides the outbound queue added
earlier for exactly this kind of out-of-band reply.

The bind address is turned into a loopback URL by taking its port and
refusing anything it cannot read a port out of - guessing 1984 would
produce a gateway that silently signals to the wrong place when someone
has moved it. Stream names are validated rather than percent-encoded,
since a real one is a printer id and anything else is a bug or an
attempt at one.

camera.rs now states why there are no ice_servers and no TURN
credentials on this side: the viewer's browser is pinned to relay-only,
so it allocates the relay and go2rtc just sends to it. Only one side of
a session needs one. That asymmetry is load-bearing, not tidy - go2rtc
reads its config once at startup, so rotating short-lived credentials
here would mean restarting it and dropping every viewer on a timer.
2026-10-19 00:32:57 +00:00
octoturge d13284b585 Keep a test's doc comment attached to its test
Adding the test_auth helper put it between
`/// The URL shape confirmed from a real H2C's ipcam block.` and the test
that comment describes, so the comment documented the helper instead.
Clippy's empty_line_after_doc_comments caught it. Also takes clippy's
from_ref over a clone in the new render-stability test.
2026-10-18 22:01:21 +00:00
octoturge 70763108f8 Require a password on go2rtc's API
The API listener is what serves WebRTC, and it defaults to :1984 on every
interface, so anyone who could reach the gateway could watch any printer
with a camera. The RTSP server was closed on 2026-10-18; this was the
other half and the one that actually carries video.

Measured against go2rtc 1.9.14, feeding it this module's own rendered
config: from a non-loopback address /api/webrtc, /api/ws, /api/streams
and the web UI all answer 401 without credentials and 200 with them,
while :8554 stays closed. go2rtc exempts loopback, which is worth
knowing twice - it is why a probe from the gateway still works without
credentials, and why such a probe cannot be used to confirm auth is on.

The password is generated once per run from /dev/urandom, or taken from
CONTINUUM_GO2RTC_PASSWORD. Once per run matters: the config is
re-rendered every 30s and compared byte-for-byte to decide whether to
restart go2rtc, so a per-render credential would restart it and drop
every viewer every half minute, with no symptom but dying video. A test
pins that.

It is never logged - it goes into the config file, which already holds
every printer's access code and is already documented as a secret, so
there is one place to protect rather than two. An operator-supplied
password is escaped for YAML, because a config go2rtc cannot parse means
a camera that silently never appears.

No opt-out flag. The alternative is an unauthenticated video feed of
someone's workshop, which is not a thing to leave behind a setting, and
nothing in this crate calls go2rtc's API so nothing internal breaks.

Note this does NOT let a browser play a stream directly - see the next
commit's notes and PLAN.md. go2rtc sends no CORS headers and 401s the
unauthenticated preflight, so the relay is required, not optional.
2026-10-18 22:00:51 +00:00
octoturge 7a0201c0de Test that a late command failure reaches the cloud
The outbound queue had no coverage, and it is exactly the kind of code
that looks right and does nothing: the failure path only runs minutes
after the command was answered, so nothing in the ordinary flow would
notice it was missing.

Stands up a real websocket server and runs the real `uplink::run`
against it, asserting two results for one command_id in order - ok:true
on acceptance, then ok:false carrying a reason that names what went
wrong. That the id repeats is the contract, not a bug to deduplicate.

It touches no printer and makes no outbound request: the fleet is empty
and `dispatch` checks the fleet before downloading, so the command fails
on a lookup. The unreachable URL is on port 1 so a regression that did
reach the network fails fast rather than hanging on a real host.

Verified to fail without the feature, not just to pass with it: stubbing
out the send makes it time out waiting for the second result. That check
is worth doing by hand here - the go2rtc RTSP test asserted the absence
of a config key and passed the whole time the server was open.
2026-10-18 21:52:05 +00:00
octoturge f88103a510 Back the uplink reconnect off from 5s to 60s
A gateway cannot tell a cloud that is briefly restarting from a token
the cloud will never accept: with the edge token on the upgrade request,
both are just a failed handshake here. So a flat 5s retry meant a
permanently misconfigured gateway knocking twelve times a minute,
forever - pointless, and the shape of the thing the other end would
eventually have to rate-limit.

Doubles to a 60s ceiling, which is still short enough that an outage
resolves without anyone touching the gateway. That matters when the
gateway is in someone else's workshop.

The reset is on a completed handshake, which is why `connect` is split
out of what is now `serve`: a handshake is the only unambiguous evidence
that the cloud is reachable and this gateway is accepted. Resetting
after the connection ended instead could not tell a connection that
lived an hour from one that never opened.

Re-verified live against the backend and both printers with
continuum-backend's scripts/e2e-edge.sh.
2026-10-18 20:03:49 +00:00
octoturge 930d117c4e Report a long-running command's failure to the cloud, not just the log
A `start_print` is acknowledged the moment it is accepted: the transfer
takes minutes at the measured 0.2 MiB/s and the backend gives up after
ten seconds, so the real outcome can only ever arrive late. Until now
there was nowhere for it to arrive - the uplink's `select!` loop was the
only writer, and a task outside it could not reach the socket. A failed
job start therefore reported success, and the reason lived only in this
gateway's log, on a machine in someone else's workshop.

Adds a bounded mpsc queue, created outside the reconnect loop so a
message produced while the socket is down waits for the next connection
rather than being dropped. A late failure goes back as a second
`CommandResult` for the same command_id, carrying the reason.

Be clear what that buys: the backend is no longer awaiting that command,
so the result does not change the job's fate - the job never reaching
`printing` is what fails it, and that already worked. What it adds is the
reason, next to the farm and gateway that produced it. Attaching it to
the job row needs a column `print_jobs` does not have. Nothing is sent
on success; the telemetry that follows says it better.

Also documents, in the module doc, why telemetry is not buffered across
an uplink gap - it is a decision now rather than a gap. See PLAN.md.
2026-10-18 20:00:00 +00:00
octoturge 51ff768153 Close go2rtc's RTSP server, which was open all along
Found by installing go2rtc and running the real daemon against the live
H2C, which is also how the comment this replaces turned out to be wrong.

`camera.rs` said: "No `rtsp:` or `webrtc:` block: ... Opening go2rtc's
own RTSP server would publish every printer's camera on the LAN
unauthenticated, which is not something to do by default." The reasoning
was right and the conclusion was backwards. **Omitting the key does not
leave the server off - go2rtc defaults to :8554.** Measured against
go2rtc 1.9.14 with exactly the config this module generated:
`rtsp://<gateway>:8554/h2c` answered a DESCRIBE with a real video SDP,
with no credentials sent at all. Every printer with a camera was being
republished to anything that could reach the port.

The unit test asserted that the rendered config contained no `rtsp:`
key, and from that concluded no server was opened. It passed throughout.
A test that asserts a key is absent cannot tell "off" from "defaulted
on" - it now asserts the value that actually turns it off, and that an
empty fleet closes it too, since a gateway with no cameras is the config
most likely to be left running.

`webrtc:` is left at its default on purpose: it is not a second stream
server, because a session only exists after an SDP offer through the
API, and the browser needs those candidates. The API listener itself is
unauthenticated and stays that way for now - the browser reaches it
directly, there is no relay, and the frontend is handed only a URL and a
stream name - so anyone on the gateway's LAN can watch. That is a real
limit, now written down here and in PLAN.md rather than implied.

Two smaller things from the same session:

- The logged stream count was `matches("\n  ").count() - 1`, which
  reported one stream for a config with none: the `{}` of an empty
  mapping indents exactly like a stream does. It made the log say a
  camera was being served when none was, and it hid that the two writes
  on startup are genuinely different configs (empty, then the fleet once
  it reports) rather than a pointless rewrite. Counted from the sources
  now, collected once so the number describes the config actually
  written.

- Recorded what is now confirmed: go2rtc connects to
  `rtsps://bblp:<access_code>@<ip>:322/streaming/live/1` and pulls video
  - 984,604 bytes over `rtsp+tcp`, no certificate complaint, no auth
  failure. The printer runs LIVE555 and sends H.264 High profile level
  4.1, which a browser plays natively over WebRTC, so no transcoding and
  no ffmpeg on the gateway. The `rtspx://` prefix go2rtc documents for
  skipping TLS verification is not needed.

Verified after the fix with the real daemon: the generated config now
leaves :8554 closed while :1984 and the WebRTC port stay up. 65 tests,
clippy unchanged, licenses ok.
2026-10-18 13:43:19 +00:00
octoturge ad150cd29e Send the edge token on the upgrade, not in the hello
The uplink put its edge JWT in a `token` field on the `hello` message.
It now goes in an `Authorization: Bearer` header on the WebSocket upgrade
request, which the backend verifies before the socket exists - so a bad
token is an HTTP 401 here rather than a connection that opens and is then
closed, and the credential stays out of the URL, where it would land in
every access and proxy log on the way. `Authorization` is the header
those tools already know to redact.

`farm_id` stays on the hello even though the token carries the same
claim: the backend checks the two against each other rather than
trusting the message, so sending it is how a configuration mismatch gets
caught instead of silently working against the wrong farm.

The cost is that tokio-tungstenite no longer derives the handshake from
a URL for us - a request we build ourselves needs its own `Host`,
`Connection`, `Upgrade`, `Sec-WebSocket-Version` and
`Sec-WebSocket-Key`, and a handshake missing any of them is rejected
rather than ignored. `host_header` errors rather than guessing if the URL
has no authority, because a wrong `Host` fails the handshake far less
legibly than this does.

Verified against a real backend rather than assumed: a valid token
handshakes to HTTP 101 and the server reads the farm from the token, and
a forged one is refused with 401 before any socket exists.

64 tests, clippy unchanged at the two deliberate variant-size warnings,
`cargo deny check licenses` ok.
2026-10-17 14:00:05 +00:00
octoturge 3b93a1b4e1 Confirm at the network layer that a P1P has no RTSP endpoint
`camera.rs` said a P1/A1 camera is reachable only over Bambu's
port-6000 JPEG stream, inferred from the absence of `rtsp_url` in what
the printer reports. Checked directly today: the H2C accepts TCP on 322
and the P1P *refuses* 322 while listening on 6000.

So the inference was right and is now a fact. Worth writing down,
because it closes off the tempting shortcut - there is no URL to guess
for a P1P, the endpoint does not exist.
2026-10-15 23:10:57 +00:00
octoturge 6a0d80a381 Generate go2rtc's config from what the printers report
go2rtc has been supervised since before this commit, with nothing to
serve: `CONTINUUM_GO2RTC_CONFIG` pointed at `./go2rtc.yaml`, and that
file did not exist in the repository or anywhere else. A watchdog was
restarting a process that had no cameras configured.

`camera.rs` writes it, from the `rtsp_url` a printer publishes in its own
`print.push_status` `ipcam` block rather than from `printers.toml`. The
URL is already something the machine tells us, so asking an operator to
keep a second copy of it - and to update it by hand when a printer's
address changes - would be asking for drift.

Two tasks, not one: the builder decides what should be served, the
watchdog keeps the thing serving it alive. They meet at one `Notify`,
because go2rtc reads its configuration only at startup, so a changed
config needs a restart. The watchdog now selects the child's exit against
that notification and skips its backoff on a deliberate change.

What this does *not* cover is most printers, and that is the honest
limit. Only the X1/H2 class publishes an RTSP URL - confirmed against two
real machines: the H2C reports
`rtsps://192.168.0.21:322/streaming/live/1`, the P1P reports an `ipcam`
block with `ipcam_dev: "1"` and no URL at all. "A camera exists" and "a
camera you can connect to" are different facts. A P1/A1 needs Bambu's
proprietary port-6000 JPEG stream, which this crate does not speak, so
those printers report no `camera_stream` and a UI should offer a player
only when one is present.

Details worth keeping:

- The config is rendered sorted so an unchanged fleet is byte-identical,
  and on the first pass it is compared against the file on disk - without
  that, every daemon restart rewrites an identical file and drops every
  live viewer.
- Written through a `.tmp` and renamed. The watchdog can restart go2rtc at
  any moment, and a half-written config fails to parse, which takes every
  working stream down rather than one.
- Never logged, and gitignored: the credentials go in the URL because
  that is the only place go2rtc takes them for an RTSP source, so the file
  holds printer access codes.
- A reported URL with no scheme is refused rather than repaired. One bad
  entry makes go2rtc fail to load the whole file.
- No `rtsp:` or `webrtc:` block. The API listener is what serves WebRTC to
  a browser, which is the only consumer here; opening go2rtc's own RTSP
  server would publish every printer's camera on the LAN unauthenticated.
- `stream_base_url` is reported in `hello`, not derived from the listen
  address: `:1984` is a bind address and not something a browser can open.
  Unset means "advertise nothing", which is the right default, because a
  wrong URL makes the UI offer a player that cannot connect. It is a LAN
  address either way - there is no relay, so this works for an operator in
  the workshop and not for anyone outside it.

63 tests, clippy unchanged at the two deliberate variant-size warnings,
`cargo deny check licenses` ok. go2rtc itself stays operator-installed and
is not distributed.
2026-10-15 23:03:02 +00:00
octoturge 901d5e9748 Execute start_print, and never await it on the uplink task
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.
2026-10-15 22:53:47 +00:00
octoturge 76b3894ca9 Say when a filament source is being ignored, and what a real failure looked like
Two things a real end-to-end run on the P1P taught, neither of them a fix.

`start_uploaded_file` silently discarded its `filament` argument for a bare
`.gcode`. That is protocol-correct - the v1 `gcode_file` request has only
`command` and `param`, with no `use_ams`/`ams_mapping` to put it in, so a
bare gcode draws whatever its own tool changes select. But the argument is
required, and a caller passing `FilamentSource::ams([3, ..])` would
reasonably believe it took effect. "A filament choice quietly overridden" is
the precise bug `FilamentSource` was introduced to stop, so this now warns
instead of dropping it.

The other is the whole-path confirmation, which is worth having written down
because it ended in a *physical* failure rather than a clean success. The
derivation read the project's one PETG/GFG99 filament and produced slot 3;
the printer echoed `tray_tar: 3`, so it agreed; 297,227 bytes went up with an
exact byte count; `IDLE -> PREPARE -> RUNNING`; it heated to 255C - and then
paused itself at stage 54 with `print_error: 0x07008003` and
`hms: [{attr: 0x07002300, code: 0x00020004}]`, a broken-filament fault on the
slot it was loading.

Every line of this code did the right thing and the print still did not
happen. That is the argument for the thing this commit documents rather than
changes: `command_outcome` can only report what the printer said about the
command, and "accepted" has now been shown to be three distinct steps away
from "printing". Anything that reports job status has to watch `gcode_state`,
`print_error` and `hms` after the fact - a job dispatcher that trusts the
start reply will report a successful print that never ran.

Also noted: `tray_now` is not a usable confirmation on its own. It went
255 -> 3 -> 254 over the run, so only the transition through the real slot
number is evidence; a single read can land on either sentinel.
2026-10-15 22:37:28 +00:00
octoturge df6dfca3ca Refuse a truncated project, and drive the whole path from the probe
`upload_project_file` already existed - as a `GenericPrinter` trait method,
which is why a grep for `pub async fn` missed it and this commit nearly
reimplemented it. It logged `written` and `byte_count` next to each other and
never compared them, so a short write was something you could only notice by
reading the logs afterwards, and the next line printed the truncated file
anyway. A `.3mf` is a zip: cut the end off and the central directory goes with
it, so the printer gets an archive it cannot open. That is also the failure a
slow transfer invites, and this transfer is slow - 4.96 MB took 31.3s, so a
62 MB project is minutes of exposure.

`print-project <local> [name]` runs that method against a real printer, so
the harness tests the production entry point rather than the three pieces in
sequence. It deliberately takes no `--ams`: the whole point is that the
mapping comes out of the archive. Gated like `start`, on a terminal state and
`--yes-start-a-real-print`.

Confirmed end to end on the P1P (2026-10-15, with the owner's go-ahead), with
the same project the 2026-10-11 run used: the derivation read one PETG/GFG99/
`#2850E0` filament out of `Metadata/slice_info.config` and produced
`mapping=[3]`, padded to five trays; 297,227 bytes went up with an exact byte
count; `IDLE -> PREPARE -> RUNNING`. The AMS on that machine has slot 2 in
black and slot 3 in blue, same product and same `tray_info_idx`, so picking
slot 3 is the colour tie-break doing real work rather than a one-candidate
match.
2026-10-15 22:32:31 +00:00
octoturge 7d4cf41999 Confirm the light and the upload on a real P1P
Four things were shipped unverified because the printer was off when they
were written. Three are now confirmed first-hand (P1P, 2026-10-15), and one
is settled in the opposite direction from what was assumed.

**`set_light` had never once reached a printer**, and `completion::run`
blinks a finished print's chamber light with it. It works - but "accepted" is
not "it changed": `ledctrl` answers `result: "success"` as soon as the
command parses, exactly like the print-control verbs. So `lights_report` is
now modelled on `PrintReport`, which is the only read-back there is, and the
probe polls it rather than printing a reassuring message. Setting the light
on and off reads `[{"node":"chamber_light","mode":"on"}]` / `"off"` back,
about 3s later. The field was observed on v1 even though only the v2 schema
declares it.

**The blink itself works at the rate it ships at.** `completion::celebrate`
is now public so the probe runs the real function instead of a copy: twenty
`ledctrl` commands half a second apart, which a printer is entitled to
refuse. None were refused, it returned after 10.35s, and the light ended
`off` as specified - a blink that leaves the light on would look like a
printer still working.

**The firmware's own `flashing` mode is not the better option.** It is
accepted, and the reply echoes all four parameters back
(`led_on_time`, `led_off_time`, `loop_times`, `interval_time`), so the
printer does parse them. But `lights_report` never reports `flashing` - it
stayed on the previous mode for 12s of polling. A firmware blink is therefore
unobservable: nothing can confirm it happened or tell when it ended, while
the self-driven blink is built from a call that reads back. That is a better
reason to keep `celebrate` than the one in the comment it replaces, which was
that `loop_times` semantics were unconfirmed. Whether the light physically
blinks is still unwitnessed - nobody was watching the machine.

**Multi-MB FTPS upload works, and is slow.** A 4.96 MB project archive went
up with an exact byte count and came back byte-identical (sha256). But it
took 31.3s - about 0.2 MiB/s - so the 62 MB project already on that card
would take six and a half minutes. Anything that uploads a job needs a
timeout budgeted in minutes, not seconds. The claim in `download`'s comment
that these files are "a few hundred KB" is gone; the card disproves it.

Also adds `delete`, because the probe left a 5 MB file on a real SD card and
there was no way to remove it. One explicit path, no globbing: the convenient
version of this deletes something somebody printed from last week. Used to
clean up, and the card is back as it was.

New probe steps: `light on|off|flash`, `blink`, `upload <local> [name]`,
`delete <path>`. 51 tests still pass.
2026-10-15 22:23:41 +00:00
octoturge 13bfec97fe Clear the clippy warnings, except the two that are not cosmetic
Down from 29 warnings to 2, in the spirit of 4ab95ff.

The doc warnings were all one mistake made repeatedly: a paragraph wrapped so
that a line *began* with `- ` or `+ `, which rustdoc reads as a markdown list
marker and the following lines as unindented continuations. The prose always
meant an em-dash. Fixed by moving the dash to the end of the previous line and
re-wrapping; not one word changed, which is the point - these comments record
things confirmed against real printers and are worth more than the warning.
The genuine lists just had their continuations de-indented and stayed lists.

The code ones are `match x { V(v) => .., _ => {} }` to `if let`, and nested
`if let Some(x) = .. { if cond { .. } }` to a let-chain. None had an `else`,
so each is the same control flow written once instead of twice.

Two warnings are left deliberately, because fixing them is not cosmetic:
`AmsTray` (544 bytes against 32) and `BambuReport` (984 against 120) both have
one variant far larger than the rest. The fix clippy wants is a `Box`, which
changes how every reader of those enums is written and puts an allocation on
the push_status path that currently has none. Worth measuring before doing,
not worth doing to silence a warning.

51 tests still pass.
2026-10-15 21:43:06 +00:00
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
octoturge c22ccf5d22 Derive the AMS mapping from the project, and blink on completion
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.
2026-10-15 21:27:24 +00:00
octoturge 431dd849da Make the filament source explicit, and print from the AMS
`start_uploaded_file` hardcoded `use_ams: false` / `ams_mapping: ""`, which
silently overrode whatever the slicer had chosen. On a P1P with an AMS and a
project sliced for it, that is not a missing feature - it points the print at
the external spool. Confirmed on 2026-10-11: the print started, heated to
250C, reached 1%, then the printer paused itself with `print_error: 50364420`
and `tray_now: 255`. Nothing in the command's reply said so; `result` was
`success`, because the command had been accepted.

So `FilamentSource` is now a required argument with no default, because there
is no safe default - picking wrong draws the wrong filament or none. The
`ExternalSpool` variant sends the empty *string* the schema's trailing
`oneOf` demands, and `ams` takes slots per project filament index and pads to
every tray the printer has, per the padding rule in
`schemas/bambulab/v1/common/ams/ams_mapping.schema.json`. It refuses an empty
mapping (padding `[]` would mean `use_ams: true` with nothing mapped), one
longer than the printer has trays, and any negative that is not -1.

Confirmed live with the real mapping: `ams_mapping: [3,-1,-1,-1,-1]`,
`use_ams: true` took the printer to RUNNING, `tray_now` became 3 - the slot
the project was sliced for - and it ran past 1% to 13% with `print_error: 0`,
where the external-spool attempt had paused.

Finding the mapping needed reading the project back off the card, so FTPS
gains `download`/`download_file` and the probe a `fetch` step. The archive's
`Metadata/slice_info.config` lists each plate's filaments with
`tray_info_idx` and colour - here one PETG GFG99 at `#2850E0`, matching AMS
slot 3's `2850E0FF` - and `Metadata/plate_1.gcode` is present, which also
confirms the hardcoded `param`.

`upload_project_file` is left on the external spool and is the one remaining
place that can pick wrong, because it is vendor-generic and an AMS mapping is
a Bambu concept. It is unreachable - there is no start-print command on the
uplink - and the fix is for it to derive the mapping from the `.3mf` it
already holds, which needs a zip reader and so a dependency decision.
2026-10-11 22:35:51 +00:00
octoturge 0077e15490 Record what a real sliced project did, and widen the start guard
A second live run, this time against a project the owner sliced in Bambu
Studio rather than a file already on the card, and through
`start_uploaded_file` itself rather than a hand-built body - so the
root-vs-cache fix is confirmed end to end. `gcode_file` in the printer's own
status came back as `cube-test-2026-10-11.gcode.3mf`, 150 layers, print_type
`local`.

It also found a defect the first run could not. The print heated to 250C,
reached 1%, and then the printer paused itself: `print_error: 50364420`
(`0x03008004`), `hms: []`, `tray_now: 255`. 255 is the external spool, which
is exactly what the hardcoded `use_ams: false` selects - so that pair
overrode the slicer's own filament choice and pointed an AMS-sliced project
at an external spool. The first run looked fine only because it was stopped
during PREPARE, before filament mattered. The doc comment said that pair was
"accepted, and the print ran"; it now says what it actually does, because
silently changing which filament a print uses is worse than not supporting
AMS at all.

The `start` guard demanded IDLE, which meant the harness refused every print
following a cancelled one - a Bambu reports an operator cancellation as
FAILED. IDLE, FINISH and FAILED are all terminal; PREPARE, RUNNING, PAUSE and
SLICING mean the machine is committed, and anything unrecognised is still
refused rather than assumed idle.

`bambu_cache_list` now prints nozzle/bed temperatures and remaining time.
State plus layer number cannot tell a print that is working from one that is
stalled: a P1P sits at layer 0 for minutes during bed levelling and purge,
and the only thing moving in that window is the heat.
2026-10-11 17:55:01 +00:00
octoturge 939c35fb91 Confirm the print path on a real P1P, and stop discarding its answer
Print control (pause/resume/stop) and print start are now both confirmed
against the P1P on the LAN, and doing that turned up three real defects.

`start_uploaded_file` published fire-and-forget, so the printer's reply was
thrown away. Asking it to print a file it does not have returned `Ok(())`,
and watching `print.push_status` could not tell that apart from a successful
start, since both leave the machine IDLE for a while. The printer does in
fact answer - `{"command":"gcode_file","param":"...","reason":"error
string","result":"fail"}` - and it echoes the `sequence_id` we send, so
`request` correlates it. Both this and `control_print` now go out as
correlated requests and run the reply through `print_command_outcome`.

The upload and the print command disagreed about where files live. `upload`
cwd'd into `cache/` while the printable projects are in the card's root: the
root holds 43 `<name>.gcode.3mf` archives, `cache/` holds the printer's own
extracted `<project>_plate_N.gcode` working copies with `.gcode.bbl`
sidecars. Those extracted copies are not independently printable -
`gcode_file` with `param: "/cache/Cube_plate_1.gcode"` was rejected for a
file the FTPS listing shows is right there - while `project_file` with
`url: "ftp:///Cube.gcode.3mf"` started a print. So the destination is now a
parameter and both halves point at `SD_ROOT`.

What the hardware settled, recorded in the doc comments: the `ftp:///` url
spelling is right, `param: "Metadata/plate_1.gcode"` works for a
single-plate project, `use_ams: false` + `ams_mapping: ""` is accepted, and
a stop during PREPARE/RUNNING moves the printer to FAILED, which is how
Bambu reports an operator cancellation. What it did not settle: all three
control verbs answer `result: "success"` on an *idle* printer, so the reply
means "accepted", not "the state changed" - callers still have to read
`gcode_state` back.

`PrinterCommand::ControlPrint` makes this reachable from the cloud at all;
until now pause/resume/stop existed in the proxy with no way to send them.
Still no start-print variant, now for a different reason: the path around it
is missing, not the hardware proof.

Also: `list_dir` for any directory (how the layout above was found, without
sending a print command to guess), `request_raw` for probing an unconfirmed
command shape, and two examples - `bambu_cache_list` (read-only) and
`bambu_print_probe` (gated steps, `start` needing both IDLE and
--yes-start-a-real-print).
2026-10-11 17:38:27 +00:00
octoturge 588ca85842 Compile the modules once, and clear the warning backlog
`cargo check --all-targets` emitted 32 warnings; it now emits none. Almost all
of them came from one cause: main.rs declared `mod config/fleet/printer/uplink`
while lib.rs declared the same modules, so every file was compiled twice, once
per crate. In the binary copy nothing is externally reachable, so every
`pub use` re-export and every method that only an example or a test calls was
genuinely dead - and the warnings were real about the binary while being wrong
about the library's actual public API. main.rs now uses the library crate and
keeps only `mod go2rtc`, which nothing else needs.

The rest, individually:

- `ModuleInfo` was re-exported from bambu_commands but not from printer, so it
  was unreachable even though `GetVersionResponse.module` is a public field of
  type `Vec<ModuleInfo>` - a caller could not name the type of a field it can
  read. Re-exported rather than dropped.
- Three stub `publish` impls take a payload they do not use yet; renamed to
  `_payload`.
- One unused import in examples/test_bambu_certs.rs.

That leaves the `async_fn_in_trait` lint on GenericPrinter, which is now
`allow`ed with the reasoning written down rather than left to fire on every
build. The old comment said it "only matters once this is wired into the
multi-threaded daemon", which has since happened - so the question got
answered instead of deferred: the futures cannot be required to be `Send`, and
that is fine because every printer is reached through one Arc<Mutex<Vec<..>>>
from the uplink's own task, so they are created and polled in the same place.
The note says what to do if a printer ever gets its own task.

No behaviour change. Tests still pass and the daemon was re-verified against
the live P1P and H2C through the full backend path.
2026-10-03 12:16:48 +00:00
octoturge ca3846b7c7 Make the uplink match the written protocol, and test that it does
Adds tests/uplink_protocol.rs, the gateway half of a contract test whose
other half is continuum-backend/tests/edge_protocol.test.ts. It checks that
every message the backend sends is one this gateway can read, and that what
this gateway writes is what the schema says it writes.

It found two things immediately.

Telemetry was serializing empty fields as explicit nulls. serde emits
`"job_name": null` for an empty Option unless told otherwise, and the
backend's validator treats a missing key and an explicit null as different
things - so every batch containing an idle printer, which by definition has
no job name, would have been rejected whole. Every optional field now has
skip_serializing_if, confirmed against a live capture: an idle P1P's
snapshot has no nulls in it at all.

ServerMessage had no variant for the handshake being rejected. A bad or
expired token would have arrived as "unparseable message from cloud,
ignoring" and then been retried every five seconds forever, which in the
logs is indistinguishable from a network fault. HelloError is now a real
variant, logged at error level naming the env var to go fix, and it stops
the connection attempt instead of looping on it.

Also exposes config and uplink from lib.rs so the test can reach them, same
reason fleet and printer are already there.

Verified live against a P1P and an H2C for the whole run, including the H2C
finishing its print: RUNNING 98% -> FINISH 100% layer 50/50 with the nozzle
cooling 84C -> 76C, all of it arriving through the uplink, no warnings.
2026-10-03 10:37:24 +00:00
octoturge 91fbcaf665 Route telemetry up and commands down over the cloud uplink
The proxy did not actually proxy. `fleet` was loaded and connected in
main.rs and then never touched again, while the uplink said hello and sent
heartbeats into the void - two halves of the program running side by side
with nothing between them. Both directions are wired now.

Up: every 5s, one vendor-neutral `PrinterTelemetry` snapshot per printer
that has reported, batched into a single `Telemetry` message. The snapshot
is a translation rather than a forwarded `print.push_status`, because it
has to describe a Prusa too, and because Bambu's wire format has traps
(0-15 fan gears, ints-or-floats temperatures) that should not reach the
rest of the system.

Down: a `Command` is looked up by our own printer id, executed, and
answered with a `CommandResult`. `PrinterCommand` is deliberately small and
deliberately has no "start a print" variant - `start_uploaded_file` has
never been run against hardware, and the first thing to exercise it should
be a human watching the machine, not a message from the internet.

The fleet becomes `Arc<Mutex<Vec<PrinterHandle>>>` so main and the uplink
can share it. One lock for the whole Vec is coarse - a slow command to one
printer stalls telemetry for all of them - which is fine at a handful of
printers and is the shape to revisit (a lock per printer, or an actor task
each) before a farm gets large.

Two things found by running this against the live P1P and H2C:

- A v1 Bambu reached the cloud as a row of nulls with one real bed
  temperature in it, while the v2 H2C beside it was fully populated. v1
  only sends a *complete* status when asked, and `pushall`'s own doc
  comment said to call it periodically - nothing ever did. Each telemetry
  tick now collects, then requests the next refresh, so v1 telemetry runs
  one tick behind instead of staying stuck on fragments. The quirk lives in
  `PrinterHandle::refresh_status` so the uplink does not have to know it.
- The third instance of the silent-swallow parse bug, at the old
  uplink/mod.rs:63: `if let Ok(server_msg) = serde_json::from_str(..)` with
  no else. Same shape that hid broken `print.push_status` parsing for
  weeks, except here it would mean commands vanishing with the cloud never
  being told. Now a `match` with a loud `Err` arm.

Verified end to end against both printers with a throwaway WebSocket
server: H2C `RUNNING 88% layer 48/50 13min nozzle 230/230 bed 35/35`, P1P
`IDLE 0% nozzle 23.5/0 bed 22.5/0`, both fan commands `ok=true`, zero
warnings over 24s.
2026-10-03 10:22:22 +00:00
octoturge 9a314b2b9f Fix print.push_status parsing, and stop losing reports silently
Pointed at a real H2C that was 20% into a job, `print_status()` returned
nothing at all. The printer was in fact pushing a full status object every
second; every one was arriving, failing to deserialize, and being dropped
invisibly by an `if let Ok(report) = ..` with no `else`.

That silence produced two confidently-wrong claims in our own comments,
both corrected here:

- `pushall`'s doc said whether v2 emits `push_status` unprompted was "an
  open question". It is not: v2 pushes a full 97-field object roughly once
  a second from the moment you subscribe, no trigger needed. There is no
  missing "other trigger" to hunt for.
- The "sequence gap" warning blamed the network for dropped v1 updates.
  Those updates were not dropped in transit - they failed to parse, so
  they never reached the counter, making the next one that did look like a
  skip. Confirmed: seq 57, 62, 64 and 69 all arrived. After the fix, zero
  gaps and zero parse failures across a 25s run against both printers.

Two concrete causes, both found with captured payloads:

- v1 trays carry `tray_temp`/`tray_time`; v2 trays carry
  `drying_temp`/`drying_time`. All four were required, so *every* v1 push
  carrying AMS data failed.
- A third tray shape existed that nothing modelled: an empty v2 slot sends
  `{"id": "2", "state": 0}`, which matched neither `EmptyTray` (it denies
  unknown fields, deliberately) nor `LoadedTray` (16 fields short).

A parse failure now reports itself, naming the printer, the sequence id and
the serde error. A report this process cannot parse is a defect in
`PrintReport`, and the only way to fix it is to be told which field.

`PrintReport` itself modelled 9 of 97 top-level fields, and none of them
were progress or temperature - it was built from a truncated example. It
now carries gcode_state, percent, layer counts, remaining time, nozzle/bed
temperatures, fan gears, wifi and HMS entries. `merge_from`'s two dozen
four-line blocks collapse to `newer.x.or(self.x.take())` one-liners: the
old shape made adding a field a three-place edit, and forgetting the merge
half produced a field that silently never survived a partial update.

Two scale traps documented where they bite: reported fan speeds are 0-15
gears, not percent and not the 0-255 PWM we send (`set_fan_speed(50)`
reads back as "7"); and v2 pins `sequence_id` to one value for minutes
while contents change, so it orders and identifies nothing there.

Two examples, and the distinction between them is the point. `live_reports`
is read-only and safe against a printer mid-job. `bambu_idle_checks`
writes, so it requires an explicit printer id and refuses to run unless
that printer reports `gcode_state: "IDLE"`.

Regression fixtures are verbatim captures off both printers, hardware
identifiers scrubbed - the only way to keep this from regressing without a
printer plugged in.
2026-10-03 10:12:05 +00:00
octoturge 0ab602ed79 Add real FTPS upload to Bambu printers
`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.
2026-10-03 10:11:36 +00:00
octoturge f3f6e21012 Declare proprietary licensing and enforce permissive-only dependencies
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.
2026-10-03 10:11:05 +00:00
octoturge 305c41993d 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.
2026-09-07 16:17:15 +00:00
octoturge 23f157d07a Prefix generated sequence_id instead of offsetting it
A printer's own free-running counter for unsolicited messages is always
a plain numeric string, so a non-numeric prefix ("cxproxy-") rules out
collision with it outright, rather than a large numeric starting offset
merely making collision improbable. Confirmed sequence_id need not be
numeric (live test, ISO-timestamp-prefixed value round-tripped fine) and
re-verified the new prefixed IDs round-trip through get_version on both
P1P (v1) and H2C (v2).
2026-09-07 15:03:14 +00:00
octoturge 256463e29c Add get_calibration; fix a real 10KB packet-size ceiling; revert bad correlation-field experiment
Real bug found and fixed live: both rumqttc clients default to a 10KB
max packet size in both directions. A real H2C's get_version reply
(25625 bytes - it reports a lot of modules: dual AMS, AMS HT, six WTM
units, ...) blew straight through that on the v5 client and killed the
whole connection outright (a real MQTT protocol error, not a timeout).
Raised to 1MB on both clients - print.push_status reports can get large
too.

Also tried, and reverted after live-testing disproved it: replacing
request()'s sequence_id correlation with a homemade
continuum_correlation_id field, on the understanding that most replies
echo back arbitrary extra request fields. True in general, false for
get_version specifically - a real H2C's reply had exactly ["command",
"module", "sequence_id"], nothing extra survived, so every get_version
call started timing out. Reverted to sequence_id, which is what's
actually proven (twice now, on both protocol versions) to round-trip for
this command.

Added BambuPrinter::get_calibration(option) - print.calibration gets a
real, correlated reply (confirmed live: it does echo sequence_id) on
both protocol versions, per the schema fixes in continuum-schemas.
`option` is the request's own bitmask, left to the caller rather than
defaulled to "run everything" - this triggers a real physical
calibration sequence on the printer, not a read-only query like
get_version, so it hasn't been live-tested here without explicit
confirmation first.
2026-09-07 14:55:09 +00:00
octoturge 6ce60542c6 Add pushall + print_status cache; fix envelope, race, and matching bugs
PrintReport now flattens CommandEnvelope (sequence_id/command) -
push_status.schema.json composes the shared envelope like every other
Bambu message; the struct's old doc comment concluded otherwise from a
capture that turned out to be an incomplete example (also missing dozens
of other real fields), not proof the message lacks them.

Added BambuMqttSession's PrintStatusCache: the in-memory
print.push_status object, replaced wholesale on v2 (always full) or on a
v1 message with msg != 1 (forced/full), merged field-by-field otherwise
(v1's organic partial updates) via PrintReport::merge_from. Tracks
last_sequence_id to warn on a gap in the organic stream. BambuPrinter
gets pushall() (v1-only; pushing.pushall doesn't exist in v2's schema at
all) and print_status().

Three real bugs found and fixed via live testing against an actual P1P,
none of them guessable from the schema alone:
- pushall's reply doesn't echo the sequence_id we send - the printer
  stamps its own free-running counter regardless of what triggered it.
  request()'s ID correlation silently can't work here; first fix attempt
  added request_next_of_category (wait for the next message under a
  category, no ID needed).
- That undershot too: matching by category alone let an unrelated
  "print" message (that category carries many commands - gcode_line,
  pause, push_status, ...) satisfy the wait before the real push_status
  reply arrived, so pushall() returned Ok having consumed the wrong
  message - now keyed by (category, command).
- A real ordering race: handle_incoming_report woke a waiting oneshot
  before updating the print_status cache, so a caller could resume and
  check print_status() before the write landed - pushall() returned Ok
  immediately followed by print_status() still showing None. Fixed by
  applying the cache update before sending on any matched oneshot.

Superseded by real protocol knowledge before any of the above needed to
ship, though: push_status carries its own msg field (1 = automatic/
partial, 0 = forced/full) - the actual, reliable, self-describing signal
for which kind a message is, regardless of timing or which client (if
any) triggered it. PrintStatusCache now reads that directly instead of
inferring it from request correlation, and pushall() is plain fire-and-
forget (publish, no wait) since there's nothing left to correlate for -
confirmed live: msg reads correctly off a real P1P, and the cache
populates/updates correctly with no gap warnings across several organic
pushes. request_next_of_category is kept (a future case may still need
"no correlation ID at all" for real), just no longer used here.

Also: the poll loop now logs when it exits instead of returning silently
- that silence is exactly what made a real dropped MQTT connection to a
live H2C indistinguishable from "nothing happening" during testing.
2026-09-07 14:39:55 +00:00
octoturge 835a4ae778 Auto-pin certificates on connect; fix Custom TLS mode for real MQTT
connect() now calls ensure_trusted() before opening the MQTT session, so
a newly-added printer with no ca_cert_path configured just works: bundled
CA if it verifies, trust-on-first-connect auto-pin if it doesn't (P1P,
at least) - the same thing examples/auto_connect_bambu.rs already did by
hand, now automatic on every real connect.

Wiring that in immediately surfaced a real, second instance of a bug the
codebase already knew about: build_connector()'s BambuTls::Custom branch
used add_root_certificate on the pinned certificate - but ensure_trusted
pins a *leaf* cert (CN = the printer's serial number, issued by Bambu's
own CA, not a root), for byte-exact fingerprint comparison, not chain
validation. Handing that leaf to add_root_certificate asks OpenSSL to
treat it as its own trust anchor - exactly the "self-signed certificate
in certificate chain" failure test_tls_handshake's doc comment already
described, just not fixed at this second call site (the one the real
MQTT connection actually uses) until now. native_tls has no per-
certificate verification callback hook (unlike rustls), so Custom now
accepts any certificate at the TLS layer instead, relying on
ensure_trusted having already fingerprint-verified this exact printer
moments before - weaker than continuous pinning on every reconnect, but
not a fresh regression: pinning a leaf as a root here never worked.

Verified live: deleted P1P's existing pin entirely, ran the daemon, and
watched it auto-fetch, pin, and connect on the very first attempt - the
freshly-pinned certificate came back byte-identical to the one that was
there before. Both configured printers (P1P forced v1, H2C auto-detected
v2) now connect successfully from a cold start with zero manual steps.
2026-09-05 21:45:32 +00:00
octoturge 4add7c1248 Wire fleet::load into main.rs; fix request() reply unwrapping
main.rs never actually loaded printers.toml or connected anything -
fleet/printer were only ever exercised by standalone examples. Now
main() loads the fleet (path configurable via CONTINUUM_PRINTERS_PATH,
default "printers.toml") and connects every printer concurrently at
startup, logging success/failure per printer rather than failing
startup over one bad printer.

That surfaced a real bug the instant it ran against a live printer:
complete_pending() was sending the whole {"info": {...}} wrapper through
the pending oneshot, not the unwrapped body - BambuPrinter::get_version
deserializing that into GetVersionResponse failed with "missing field
module" even though module was right there, just one level deeper than
expected. Fixed by unwrapping the single category key before completing
the pending request, symmetric with how a request gets wrapped on the
way out.

Verified live against a real H2C (MQTT v5/BambuMQTT v2): connect,
get_version, and the full pending-request correlation all worked
end-to-end, returning real firmware versions for every module (main
board 01.01.05.00, both AMS units, AMS HT, etc). The other configured
printer (P1P, forced v1) failed with a real, expected TLS error - it
doesn't chain to the bundled CA, matching the already-documented P1P
quirk; needs the ca_cert_path/fetch_bambu_cert workflow, not a code fix.
2026-09-05 21:39:26 +00:00
octoturge 416ef6db07 Add BambuPrinter::get_version, the first real request/response round trip
Documents request()'s real constraint on BambuMqttSession directly (not
all Bambu commands map 1:1 to a reply, and even where one exists its
shape can differ by protocol version - e.g. pushing.pushall triggers an
ongoing print.push_status stream rather than a direct reply, and
print.calibration data lives inside push_status on BambuMQTT v1 but gets
its own report on v2 - so request() is only correct for genuinely
one-shot, sequence_id-correlated commands like info.get_version).

get_version itself is Bambu-only (no GenericPrinter equivalent, matching
test_tls_handshake/fetch_certificate/ensure_trusted's existing pattern):
builds the request via continuum-schema-instance, sends it through
BambuMqttSession::request, and parses the reply into the existing
hand-written GetVersionResponse - reusing the struct already validated
against real captured Bambu JSON in bambu_commands_demo.rs, rather than
introducing a second response representation.

Verified structurally (no real broker reachable from here - confirmed
this sandbox has no route into the printer's LAN at all, not just a
blocked port): connect fails gracefully against an unreachable host, and
get_version correctly reports "not connected" rather than panicking when
called without a live session.
2026-09-05 21:27:59 +00:00
octoturge 9643c8b685 Real MQTT wiring for Bambu: connect, auto-detect v5/v3.1.1, request/reply
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.
2026-09-05 21:04:39 +00:00
octoturge b352fd618d 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).
2026-09-05 20:40:21 +00:00
50 changed files with 10018 additions and 268 deletions
+6 -4
View File
@@ -1,10 +1,12 @@
# Identity
CONTINUUM_GATEWAY_ID=gw-farm01-rpi01
CONTINUUM_FARM_ID=replace-with-farm-uuid
# The edge JWT from POST /auth/edge-token. Sent as an Authorization: Bearer
# header on the uplink's WebSocket upgrade - not in a message and not in the
# URL - so the backend verifies it before any socket exists.
CONTINUUM_GATEWAY_TOKEN=replace-with-provisioning-token
# The farm's edge proxy token, shown once by the console's Setup page (or
# POST /farms/:id/edge-proxy-token). The gateway trades it for a one-hour edge
# JWT (POST /auth/edge-token) on every connection, and the JWT - sent as an
# Authorization: Bearer header on the WebSocket upgrade, never in a message
# or the URL - is what the backend verifies before any socket exists.
CONTINUUM_GATEWAY_TOKEN=replace-with-the-farms-edge-proxy-token
# Cloud uplink
CONTINUUM_UPLINK_URL=wss://api.continuum.local/ws/edge/v1
+183
View File
@@ -0,0 +1,183 @@
# Builds `discover_printers` as a Windows executable, for trying SSDP
# discovery on a machine that shares the printers' network segment (multicast
# does not cross a router, so the development container cannot hear them).
#
# Runs on the `windows-latest` self-hosted runner that continuum-cad's
# pipeline also uses: a host executor running as SYSTEM, Windows PowerShell
# 5.1 (no pwsh), MSVC build tools present. The notes in
# continuum-cad/.gitea/workflows/ci.yml on that machine apply here too.
# Its artifact UPLOAD works (`upload-artifact@v3`); DOWNLOAD does not, which
# is why nothing here downloads one.
#
# A job whose label matches no registered runner is skipped silently, not
# failed - if this never runs, check that the runner is online.
#
# Cargo.lock is not committed in this repo, so this resolves dependencies
# fresh; the artifact is built from whatever versions that picks today.
name: Windows discovery tool
on:
push:
paths:
- "src/**"
- "examples/discover_printers.rs"
- "Cargo.toml"
- "Cargo.lock"
- ".gitea/workflows/discover-windows.yml"
workflow_dispatch:
env:
CARGO_TERM_COLOR: never
# Outside the runner's deep work directory, and short, for the same MAX_PATH
# reason continuum-cad gives. A directory of its own - not C:\vcpkg or
# C:\ccad-target - so the two pipelines cannot step on each other's state
# on the one machine.
CARGO_TARGET_DIR: 'C:\continuum-proxy-target'
VCPKG_ROOT: 'C:\continuum-vcpkg'
# The same release tag continuum-cad pins, known to exist.
VCPKG_REF: "2026.07.29"
jobs:
windows:
runs-on: windows-latest
defaults:
run:
shell: powershell
# Every `run` step below works in the proxy's checkout.
working-directory: continuum-proxy
timeout-minutes: 90
steps:
# Cargo.toml has path dependencies on two sibling repositories
# (`../continuum-schemas`, `../continuum-common`), so all three are laid
# out side by side inside the workspace - the checkout action will not
# place a repo outside it, which is why the proxy is in a subdirectory
# rather than at the root.
- uses: actions/checkout@v4
with:
path: continuum-proxy
# Plain git for the siblings, not a second `actions/checkout`: on this
# Windows runner a second use of the action in one job fails syncing the
# action's own cached clone ("...\.git\objects\pack\...idx: Access is
# denied", run 413). Same branch name as this one, because the proxy's
# code needs their unreleased commits. The job's token goes in a header
# for the clone and is never written to a remote URL or a log.
- name: Check out the sibling repositories
shell: powershell
working-directory: .
env:
CI_TOKEN: ${{ github.token }}
CI_SERVER: ${{ github.server_url }}
CI_BRANCH: ${{ github.ref_name }}
run: |
$auth = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("x-access-token:$($env:CI_TOKEN)"))
foreach ($repo in @("continuum-schemas", "continuum-common")) {
if (Test-Path $repo) { Remove-Item -Recurse -Force $repo }
git -c "http.extraheader=AUTHORIZATION: basic $auth" clone --quiet --depth 1 --branch $env:CI_BRANCH "$($env:CI_SERVER)/Continuum/$repo.git" $repo
if ($LASTEXITCODE -ne 0) { throw "could not clone $repo at branch $($env:CI_BRANCH)" }
Write-Host "$repo at $(git -C $repo rev-parse --short HEAD)"
}
- name: Install the Rust MSVC toolchain
shell: powershell
run: |
# Genuine no-BOM UTF-8: Windows PowerShell 5.1's `-Encoding utf8`
# writes a BOM, which corrupts the first entry of GITHUB_PATH.
$NoBom = New-Object System.Text.UTF8Encoding $false
function Add-CiPath([string] $Value) {
[System.IO.File]::AppendAllText($env:GITHUB_PATH, "$Value`r`n", $NoBom)
}
if (-not (Get-Command rustup -ErrorAction SilentlyContinue)) {
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Invoke-WebRequest -UseBasicParsing -Uri https://win.rustup.rs/x86_64 -OutFile rustup-init.exe
.\rustup-init.exe -y --default-toolchain stable-x86_64-pc-windows-msvc
if ($LASTEXITCODE -ne 0) { throw "rustup-init failed" }
$cargoBin = Join-Path $env:USERPROFILE ".cargo\bin"
if (-not (Test-Path (Join-Path $cargoBin "rustup.exe"))) {
throw "rustup-init reported success but rustup.exe is not in $cargoBin"
}
# GITHUB_PATH only takes effect for later steps; the in-process
# PATH is what the calls just below need.
$env:PATH = "$cargoBin;$env:PATH"
Add-CiPath $cargoBin
}
rustup default stable-x86_64-pc-windows-msvc
if ($LASTEXITCODE -ne 0) { throw "could not select the MSVC toolchain" }
rustc -vV
# The proxy links OpenSSL directly (Ed25519 verification, SHA-256, and
# reading a printer's certificate), and Windows has none. vcpkg builds
# it, fetching its own Perl and NASM. `x64-windows-static-md` links it
# into the .exe, so the tool needs no DLL beside it; openssl-sys finds
# it through VCPKG_ROOT with no further settings.
- name: Build OpenSSL with vcpkg
shell: powershell
run: |
git config --global core.longpaths true
if (-not (Test-Path "$env:VCPKG_ROOT\.git")) {
git clone --depth 1 --branch $env:VCPKG_REF https://github.com/microsoft/vcpkg $env:VCPKG_ROOT
if ($LASTEXITCODE -ne 0) { throw "could not clone vcpkg at $env:VCPKG_REF" }
}
Push-Location $env:VCPKG_ROOT
try {
if (-not (Test-Path .\vcpkg.exe)) {
.\bootstrap-vcpkg.bat -disableMetrics
if ($LASTEXITCODE -ne 0) { throw "bootstrap-vcpkg failed" }
}
.\vcpkg.exe install openssl:x64-windows-static-md
if ($LASTEXITCODE -ne 0) { throw "vcpkg failed to build OpenSSL" }
} finally {
Pop-Location
}
- name: Build discover_printers
shell: powershell
run: |
$env:VCPKGRS_TRIPLET = "x64-windows-static-md"
cargo build --release --example discover_printers
if ($LASTEXITCODE -ne 0) { throw "cargo build failed" }
New-Item -ItemType Directory -Force -Path dist | Out-Null
Copy-Item "$env:CARGO_TARGET_DIR\release\examples\discover_printers.exe" dist\
Copy-Item scripts\discover-windows-README.txt dist\README.txt
# It must run on a machine with nothing installed. No arguments
# prints the usage and exits non-zero, which proves the binary
# loads (no missing DLL) without touching the network.
$ErrorActionPreference = "Continue"
$out = cmd /c "dist\discover_printers.exe 2>&1" | Out-String
if ($out -notmatch "identify") { throw "discover_printers.exe did not print its usage: $out" }
Write-Host "usage text looks right"
# Imports of the .exe, so a DLL that a clean machine lacks shows up
# in the log (the VC++ runtime is the one that commonly does).
if (Get-Command dumpbin -ErrorAction SilentlyContinue) {
& dumpbin /dependents dist\discover_printers.exe | Select-String "\.dll" |
ForEach-Object { Write-Host $_.Line.Trim() }
}
$hash = (Get-FileHash dist\discover_printers.exe -Algorithm SHA256).Hash.ToLower()
Set-Content -Path dist\SHA256.txt -Value "$hash discover_printers.exe" -Encoding ASCII
Write-Host "sha256 $hash"
# The usage run above exits 1 on purpose, and PowerShell hands the
# last native command's exit code to the runner when a script ends -
# so without this a build that passed every check was reported
# failed (run 414).
exit 0
- name: Upload the executable
uses: actions/upload-artifact@v3
with:
name: continuum-discover-printers-windows
path: continuum-proxy/dist/*
if-no-files-found: error
# After the upload on purpose: the executable is useful even if a test
# that only breaks on Windows fails, and a red job still says so.
- name: Run the discovery tests on Windows
shell: powershell
run: |
$env:VCPKGRS_TRIPLET = "x64-windows-static-md"
cargo test --lib discovery
if ($LASTEXITCODE -ne 0) { throw "discovery tests failed on Windows" }
+6 -1
View File
@@ -1,5 +1,4 @@
/target
Cargo.lock
*.db
*.db-journal
*.sqlite
@@ -13,3 +12,9 @@ printers.toml
# access codes.
go2rtc.yaml
go2rtc.yaml.tmp
continuum.toml
go2rtc.yaml.pid
# The local store holds printer access codes.
continuum.db
continuum.db-*
Generated
+2638
View File
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -75,7 +75,7 @@ rumqttc = { version = "0.25.1", default-features = false, features = ["use-nativ
# of explicit AUTH TLS. The printers do not care.
# MIT OR Apache-2.0 - checked against docs/LICENSES.md before adding.
suppaftp = { version = "12.1.1", features = ["native-tls", "deprecated"] }
zip = { version = "9.0.3", default-features = false, features = ["deflate-flate2"] }
zip = { version = "9.0.3", default-features = false, features = ["deflate-flate2", "aes-crypto"] }
# Reads the subject CN out of the certificate a printer presents on 8883,
# which is where its serial number is (see src/discovery.rs). Already in the
# tree - native-tls is openssl on Linux - so naming it directly adds nothing
@@ -87,3 +87,14 @@ flate2 = "1.1.10"
# src/uplink/download.rs). Features are the bare minimum: TLS via rustls, no
# openssl-sys; no json/gzip/cookies/etc, since the body is an opaque binary.
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-native-roots"] }
# The local IPv4 addresses, so the SSDP listener can join the multicast group
# on every interface rather than the one the OS happens to pick (see
# `bind_ssdp`). Windows PCs routinely have VPN/Hyper-V/WSL adapters.
# MIT OR BSD-3-Clause - checked against docs/LICENSES.md before adding.
if-addrs = "0.15"
# The proxy's local store (src/store.rs): printers added from the console live
# here, so the gateway keeps its fleet with the API unreachable. `bundled`
# compiles SQLite from source so the appliance image needs no system library
# (SQLite itself is public domain).
# MIT - checked with `cargo deny check licenses` (docs/LICENSES.md policy).
rusqlite = { version = "0.41", features = ["bundled"] }
+2
View File
@@ -110,6 +110,8 @@ src/
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
and stops one a hard-killed proxy left behind (pid file next to its config)
deploy/ systemd unit template; its KillMode=control-group is what stops go2rtc outliving a SIGKILL'd proxy
printer/
mod.rs GenericPrinter trait + PrinterBase + PrinterHandle enum - the trait+enum
"inheritance" pattern this project uses instead of class inheritance
+10
View File
@@ -0,0 +1,10 @@
# Says what kind of installation this is and where the real config lives.
# Read from CONTINUUM_BOOTSTRAP, else /etc/continuum/bootstrap.toml, else
# absent (which means: PC mode, ./continuum.toml). See src/bootstrap.rs.
#
# A Proxy Box image ships this root-owned. It must NOT come from a pendrive:
# whether a pendrive may change settings is what it decides.
mode = "proxy_box" # or "pc"
# config_path = "/var/lib/continuum/config.toml"
# pendrive_dirs = ["/media", "/run/media"] # ignored (and dropped) in pc mode
+79
View File
@@ -0,0 +1,79 @@
# The proxy's OWN settings - not the printers (that is printers.toml, which
# holds per-site secrets and is never shipped on a pendrive). See
# src/config.rs for how this file layers with CONTINUUM_* environment
# variables: this file is the base, an env var overrides the same key.
#
# PC / server: copy to continuum.toml next to the proxy and edit it. Nothing
# else is needed; removable media is never read.
# Proxy Box: this file is NOT written by hand. Continuum Cloud's
# configurator produces continuum-config.zip: config.toml,
# config.toml.sha256 and config.toml.sig (an Ed25519 signature
# naming this box and a config_version), every entry
# AES-256-encrypted under the box's own key. A Box refuses
# plaintext, unsigned, other-box and older-version bundles.
# See the module docs of src/config_import.rs for the exact
# format the cloud must produce.
#
# Unknown keys are rejected on purpose, so a typo cannot silently fall back to
# a default. On a Proxy Box the file must be complete by itself.
gateway_id = "REPLACE_ME"
farm_id = "REPLACE_ME"
# The farm's edge proxy token from the console (Setup page). Traded for a
# short-lived edge JWT on every connection - see src/uplink/auth.rs.
gateway_token = "REPLACE_ME"
# uplink_url = "wss://api.continuum.local/ws/edge/v1"
# go2rtc_bin = "go2rtc"
# go2rtc_config = "./go2rtc.yaml"
# go2rtc_api_listen = ":1984"
# stream_base_url = "https://proxy.example/"
# printers_path = "/var/lib/continuum/printers.toml" # absolute on a box
# Proxy Box only. PARSED AND VALIDATED, NOT APPLIED - this build never
# touches the OS network configuration.
# [network]
# mode = "static" # or "dhcp"
# ip = "192.168.1.60"
# prefix_len = 24
# gateway = "192.168.1.1"
# dns = ["1.1.1.1"]
# [network.wifi]
# ssid = "farm"
# psk = "REPLACE_ME"
# Printer discovery: look for printers nobody has configured and report them
# to the cloud, so adding one in the web console does not start with walking
# to a machine to read its IP address off the screen.
#
# OFF by default, and the whole block is commented out here for that reason.
# A sweep is unsolicited traffic on someone else's network - 254 TLS
# handshakes and up to three HTTP requests per host, which nothing watching
# can distinguish from a port scan. Switch it on only for a network you are
# responsible for.
#
# Discovery never supplies a credential, because nothing can: a Bambu access
# code is displayed on the printer and a PrusaLink API key is in its web UI.
# It supplies the address, the serial and the vendor - the parts you would
# otherwise have to look up.
#
# `subnets` is explicit and never derived from this host's interfaces, so a
# second NIC, a VPN or a container bridge cannot widen what gets swept.
# Anything wider than a /24 is refused.
#
# `serial` reads USB descriptors from sysfs and opens nothing. It will never
# send an `M115` handshake: opening a serial port toggles DTR, which reboots
# an Arduino-based board, and on a printer mid-job that ruins the print. Use
# `cargo run --example discover_printers -- serial-probe <port>` when you want
# the handshake and are standing in front of the machine.
#
# `ssdp` listens passively for Bambu announcements. It sends nothing, but
# multicast does not cross a router, so on a real farm network a sweep is
# what actually works.
#
# [discovery]
# enabled = true
# subnets = ["192.168.0.0/24"]
# ssdp = false
# serial = false
# interval_minutes = 30
+38
View File
@@ -0,0 +1,38 @@
# Continuum proxy as a systemd service - the template for a Proxy Box or a
# Linux PC install. Adjust the paths; create the `continuum` user and put the
# settings it reads (continuum.toml, or the signed bundle on a Box) under
# /var/lib/continuum.
#
# Not exercised under a real systemd yet: this container's init is not
# systemd, so only `systemd-analyze verify` has been run against it.
[Unit]
Description=Continuum proxy (printer gateway)
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
User=continuum
# The proxy's relative defaults (./go2rtc.yaml, ./printers.toml, the go2rtc
# pid file) resolve here, and StateDirectory creates it owned by the user.
StateDirectory=continuum
WorkingDirectory=/var/lib/continuum
ExecStart=/usr/local/bin/continuum-proxy
# Always, including a clean exit: installing a new config from a pendrive
# deliberately ends the process and relies on this to start it on the new
# settings (src/main.rs, config_import).
Restart=always
RestartSec=2
# Do not change this to `process` or `none`. go2rtc is the proxy's child, so
# it lives in this unit's cgroup, and this is what makes systemd kill it
# whenever the proxy goes - including SIGKILL and the OOM killer, which give
# the proxy no chance to stop it itself. It is systemd's default; it is
# written out so nobody weakens it by accident. An orphaned go2rtc keeps
# :1984/:8555 bound and a printer's access code in memory.
KillMode=control-group
TimeoutStopSec=10
[Install]
WantedBy=multi-user.target
+59
View File
@@ -0,0 +1,59 @@
//! Run with: cargo run --example bambu_mapping_check -- <printer-id> <file-on-card>
//!
//! **Read-only dry run of what a print would do.** Connects, reads the
//! printer's state and loaded filament, downloads one project from the SD
//! card root over FTPS, and prints the AMS mapping the production path would
//! send - without sending it. Starts nothing, uploads nothing, deletes
//! nothing. An optional third argument saves the downloaded copy locally.
use std::time::Duration;
use continuum_proxy::fleet;
use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let mut args = std::env::args().skip(1);
let (Some(target), Some(file)) = (args.next(), args.next()) else {
anyhow::bail!("usage: cargo run --example bambu_mapping_check -- <printer-id> <file-on-card> [save-to]");
};
let save_to = args.next();
let mut printers = fleet::load(std::path::Path::new("printers.toml"))?;
let printer = printers
.iter_mut()
.find(|p| p.base().id == target)
.ok_or_else(|| anyhow::anyhow!("no printer with id '{target}' in printers.toml"))?;
let PrinterHandle::Bambu(bambu) = printer else {
anyhow::bail!("'{target}' is not a Bambu printer");
};
tokio::time::timeout(Duration::from_secs(20), bambu.connect())
.await
.map_err(|_| anyhow::anyhow!("connect timed out"))??;
bambu.pushall().await.ok();
tokio::time::sleep(Duration::from_secs(4)).await;
let report = bambu.print_status().await.ok_or_else(|| anyhow::anyhow!("no status yet"))?;
println!("gcode_state: {:?}", report.gcode_state);
println!("{:#?}", report.ams);
let bytes = bambu.download_file(&format!("/{file}")).await?;
println!("downloaded {} bytes", bytes.len());
if let Some(path) = save_to {
std::fs::write(&path, &bytes)?;
println!("saved a copy to {path}");
}
for f in continuum_proxy::printer::plate_filaments(&bytes, 1)? {
println!("project filament {}: {} {} #{}", f.index, f.filament_type, f.tray_info_idx, f.color);
}
if let Some(slot) = std::env::var("AMS_SLOT").ok().and_then(|v| v.parse::<u8>().ok()) {
println!("(with AMS_SLOT={slot} chosen explicitly)");
bambu.set_ams_slot_override(Some(slot));
}
match bambu.filament_source_for(&bytes, 1).await {
Ok(source) => println!("mapping the production path would use: {source:?}"),
Err(err) => println!("the production path would REFUSE: {err}"),
}
Ok(())
}
+53 -2
View File
@@ -7,6 +7,7 @@
//! cargo run --example discover_printers -- identify <host>... # one known address
//! cargo run --example discover_printers -- sweep <a.b.c.0/24> # every address on a segment
//! cargo run --example discover_printers -- serial # USB/serial ports, passive
//! cargo run --example discover_printers -- wire [subnet] [--ssdp] # the frame the daemon sends
//! cargo run --example discover_printers -- serial-probe <port> [baud] --yes-reset-the-printer
//! ```
//!
@@ -68,7 +69,12 @@ async fn main() -> anyhow::Result<()> {
// picking one would mean probing a network nobody named.
None => println!("(no subnet given, so no sweep - pass one like 192.168.0.0/24 to include it)"),
}
found.extend(list_serial()?);
// Not `?`: serial listing reads Linux sysfs and fails on any other
// OS, and that must not throw away what the listen and sweep found.
match list_serial() {
Ok(ports) => found.extend(ports),
Err(err) => println!("(serial ports skipped: {err})"),
}
found
}
"listen" | "raw" => {
@@ -114,6 +120,50 @@ async fn main() -> anyhow::Result<()> {
println!("opening {port} at {baud} baud - this resets the board; waiting for it to boot ...");
vec![serial::probe_m115(port, baud, SERIAL_TIMEOUT).await?]
}
// Not merged with the others, because this one answers a different
// question: not "what is out there" but "what exactly would the
// daemon send about it". So it runs the real `scan` - the same
// function the background task calls, with the same fleet filtering
// and the same refusal to open a serial port - and prints the frame.
//
// The reason to have it: the gateway and the backend agree about this
// message only by hand, and a frame captured from real hardware is
// the only thing that proves the two halves match. See
// `continuum-backend/tests/edge_protocol.test.ts`, which validates
// captures like this one.
"wire" => {
let settings = continuum_proxy::config::DiscoverySettings {
enabled: true,
subnets: args.get(1).map(|s| vec![s.clone()]).unwrap_or_default(),
// On by default here and off in the daemon: a human asked for
// this one and is waiting for the output.
ssdp: args.iter().any(|a| a == "--ssdp"),
serial: true,
interval: Duration::from_secs(60),
};
let path = std::env::var("CONTINUUM_PRINTERS_PATH").unwrap_or_else(|_| "printers.toml".into());
let configured = continuum_proxy::fleet::configured(std::path::Path::new(&path))?;
let scan = discovery::scan::scan(&settings, &configured).await;
if !scan.looked() {
anyhow::bail!("every discovery source failed, so there is nothing to report");
}
let printers: Vec<_> = scan.candidates.iter().map(Candidate::to_wire).collect();
let count = printers.len();
// The real enum through the real serializer, not a hand-built
// JSON object. A hand-built one would agree with this file rather
// than with the daemon, which is the whole thing this subcommand
// exists to rule out.
let frame = continuum_proxy::uplink::ClientMessage::DiscoveredPrinters {
printers,
scanned_at: discovery::report::now_rfc3339(),
};
println!("{}", serde_json::to_string_pretty(&frame)?);
eprintln!(
"({count} candidate(s) after removing anything {path} already names; point \
CONTINUUM_PRINTERS_PATH at an empty file to see them all)"
);
return Ok(());
}
other => anyhow::bail!("unknown command {other:?}\n{}", usage()),
};
@@ -123,7 +173,8 @@ async fn main() -> anyhow::Result<()> {
fn usage() -> String {
"usage: discover_printers all [subnet] | listen [seconds] | raw [seconds] | identify <host>... \
| sweep <a.b.c.0/24> | serial | serial-probe <port> [baud] --yes-reset-the-printer"
| sweep <a.b.c.0/24> | serial | wire [subnet] [--ssdp] \
| serial-probe <port> [baud] --yes-reset-the-printer"
.to_string()
}
+31
View File
@@ -0,0 +1,31 @@
discover_printers.exe - finds Continuum-compatible 3D printers on this network
=============================================================================
Run it from PowerShell, in the folder it is in. It only LISTENS and asks
printers who they are. It sends no passwords or access codes and changes
nothing on any printer.
The test we need (Bambu printers announce themselves over SSDP multicast,
which only works if this PC is on the SAME network segment as the printers -
same Wi-Fi/Ethernet, not through a VPN, not a guest network):
.\discover_printers.exe raw 120 2>&1 | Tee-Object ssdp.txt
That listens for 2 minutes and prints EVERY datagram it hears, then what it
understood. Send back ssdp.txt. "listening for SSDP ... interfaces=[...]"
near the top lists the adapters it joined; the one on the printers' network
should be there.
Other commands:
.\discover_printers.exe listen 30 SSDP only, Bambu printers
.\discover_printers.exe sweep 192.168.0.0/24 probe every address on a subnet
.\discover_printers.exe all 192.168.0.0/24 both, merged
.\discover_printers.exe identify 192.168.0.21 one known address
Windows Firewall will ask whether to allow it on first run. Allow it on
PRIVATE networks; without that, Windows drops the multicast and the result is
"No printers found" even when printers are there.
Check the file is intact: Get-FileHash .\discover_printers.exe -Algorithm SHA256
(compare with SHA256.txt)
+274
View File
@@ -0,0 +1,274 @@
//! The tiny, fixed-location answer to "what kind of installation is this, and
//! where is my real config?" - which has to be known *before* the real config
//! can be found.
//!
//! # Why a second file at all
//!
//! The proxy's settings live in a config file (see `config.rs`), but a Proxy
//! Box replaces that file from a pendrive, and a PC user keeps it wherever
//! they like. Something has to say which of those two worlds this is, and
//! where the file is. That something cannot be in the file it locates, and it
//! must not be on the pendrive: whether removable media may change settings
//! at all is the very thing being decided, so the media cannot be the one to
//! say yes.
//!
//! # Discovery order
//!
//! 1. `CONTINUUM_BOOTSTRAP` - an explicit path; if set and unreadable that is
//! an error, not a fallthrough, because someone pointed at a file on
//! purpose.
//! 2. `/etc/continuum/bootstrap.toml` - the fixed location a Proxy Box image
//! ships. Root-owned, so a settings change on a box cannot be made by the
//! service user or by a pendrive.
//! 3. Nothing found: **PC mode with defaults**. This is what every existing
//! deployment (env vars only, no files) lands in, so adding the file layer
//! changes nothing for them.
//!
//! A file in the working directory is deliberately *not* searched. It would
//! be convenient on a PC, but "whichever directory the service happened to
//! start in can flip the box into a different mode" is a bad property for the
//! one file that gates removable-media import.
//!
//! # Overrides
//!
//! The same precedence as `config.rs`: bootstrap file, then env on top.
//! `CONTINUUM_MODE` (`proxy_box` | `pc`), `CONTINUUM_CONFIG_PATH`,
//! `CONTINUUM_PENDRIVE_DIRS` (colon-separated).
//!
//! Note what that means on a box: an environment variable can still override
//! a setting. The env of a service is set by its unit file, which is part of
//! the root-owned image, so it sits on the same side of the trust line as the
//! bootstrap file. It is not a way for a pendrive or the service user to
//! change anything.
use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::config::ProxyFile;
/// Where a Proxy Box image puts its bootstrap file.
pub const BOOTSTRAP_PATH: &str = "/etc/continuum/bootstrap.toml";
/// Where a Proxy Box keeps the config it is running on. Under `/var/lib`, not
/// `/etc`: the service has to be able to replace this file atomically (which
/// needs a writable directory), and it should not need write access to `/etc`
/// to do so.
const BOX_CONFIG_PATH: &str = "/var/lib/continuum/config.toml";
/// Relative on purpose: next to wherever the user runs the proxy from, like
/// `printers.toml`.
const PC_CONFIG_PATH: &str = "continuum.toml";
/// Where desktop environments and udev rules mount removable media. Only
/// consulted on a Proxy Box.
const DEFAULT_PENDRIVE_DIRS: [&str; 2] = ["/media", "/run/media"];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Mode {
/// A dedicated appliance. Settings change only by supplying a new
/// pendrive/upload.
ProxyBox,
/// A program on someone's PC or server. The config is a local file they
/// edit; removable media is never read.
Pc,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct BootstrapFile {
mode: Option<Mode>,
config_path: Option<PathBuf>,
pendrive_dirs: Option<Vec<PathBuf>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Bootstrap {
pub mode: Mode,
/// The config the proxy runs on - on a box, the installed copy that
/// imports replace.
pub config_path: PathBuf,
/// Whether `config_path` was named by someone, rather than defaulted. An
/// explicit path that does not exist is an error; a defaulted one just
/// means "no file", which keeps env-only deployments working.
pub config_path_is_explicit: bool,
/// Directories searched for a config bundle. Always empty in PC mode,
/// whatever the bootstrap said - see `from_parts`.
pub pendrive_dirs: Vec<PathBuf>,
}
impl Bootstrap {
pub fn discover() -> anyhow::Result<Self> {
Self::discover_at(Path::new(BOOTSTRAP_PATH), &|key| std::env::var(key).ok())
}
/// `discover` with the fixed path and the environment injected, so tests
/// do not depend on the machine's `/etc` or mutate process-wide env.
pub fn discover_at(fixed: &Path, env: &dyn Fn(&str) -> Option<String>) -> anyhow::Result<Self> {
let file = match env("CONTINUUM_BOOTSTRAP") {
Some(explicit) => Self::read(Path::new(&explicit))?,
None => match std::fs::read_to_string(fixed) {
Ok(text) => Self::parse(&text, fixed)?,
// Absent is the normal case on a PC. Anything else (a file
// we cannot read) is not "absent" and must not quietly turn
// a box into a PC.
Err(err) if err.kind() == std::io::ErrorKind::NotFound => BootstrapFile::default(),
Err(err) => anyhow::bail!("failed to read bootstrap config {fixed:?}: {err}"),
},
};
Self::from_parts(file, env)
}
fn read(path: &Path) -> anyhow::Result<BootstrapFile> {
let text = std::fs::read_to_string(path)
.map_err(|err| anyhow::anyhow!("failed to read bootstrap config {path:?} (from CONTINUUM_BOOTSTRAP): {err}"))?;
Self::parse(&text, path)
}
fn parse(text: &str, path: &Path) -> anyhow::Result<BootstrapFile> {
toml::from_str(text).map_err(|err| anyhow::anyhow!("bootstrap config {path:?} is invalid: {err}"))
}
fn from_parts(file: BootstrapFile, env: &dyn Fn(&str) -> Option<String>) -> anyhow::Result<Self> {
let mode = match env("CONTINUUM_MODE") {
Some(value) => match value.as_str() {
"proxy_box" => Mode::ProxyBox,
"pc" => Mode::Pc,
other => anyhow::bail!("CONTINUUM_MODE must be \"proxy_box\" or \"pc\", not {other:?}"),
},
None => file.mode.unwrap_or(Mode::Pc),
};
let explicit_config = env("CONTINUUM_CONFIG_PATH").map(PathBuf::from).or(file.config_path);
let config_path_is_explicit = explicit_config.is_some();
let config_path = explicit_config.unwrap_or_else(|| match mode {
Mode::ProxyBox => PathBuf::from(BOX_CONFIG_PATH),
Mode::Pc => PathBuf::from(PC_CONFIG_PATH),
});
let requested_dirs = env("CONTINUUM_PENDRIVE_DIRS")
.map(|v| v.split(':').filter(|d| !d.is_empty()).map(PathBuf::from).collect())
.or(file.pendrive_dirs);
let pendrive_dirs = match mode {
Mode::ProxyBox => requested_dirs
.unwrap_or_else(|| DEFAULT_PENDRIVE_DIRS.iter().map(PathBuf::from).collect()),
// Dropped here, at the source, and not merely ignored later: the
// import code then has nothing to scan even if a future caller
// forgets to check the mode. In PC mode pendrive import is a way
// to change settings behind the user's back, so the safe state
// is the one where the list cannot be non-empty.
Mode::Pc => {
if requested_dirs.is_some_and(|dirs| !dirs.is_empty()) {
tracing::warn!("pendrive_dirs is set but this is PC mode; removable media will NOT be read");
}
Vec::new()
}
};
Ok(Self { mode, config_path, config_path_is_explicit, pendrive_dirs })
}
/// Reads the real config. `None` means "no file, use env and defaults" -
/// only ever for a path nobody named, so env-only deployments keep
/// working. A named path that is missing, or any file that does not
/// parse, is an error: starting on defaults because the operator's file
/// had a typo would run a proxy that looks configured and is not.
pub fn load_config_file(&self) -> anyhow::Result<Option<ProxyFile>> {
match std::fs::read_to_string(&self.config_path) {
Ok(text) => ProxyFile::parse(&text)
.map(Some)
.map_err(|err| anyhow::anyhow!("config file {:?} is invalid: {err}", self.config_path)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound && !self.config_path_is_explicit => Ok(None),
Err(err) => Err(anyhow::anyhow!("failed to read config file {:?}: {err}", self.config_path)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn no_env(_: &str) -> Option<String> {
None
}
fn from_text(text: &str, env: &dyn Fn(&str) -> Option<String>) -> anyhow::Result<Bootstrap> {
Bootstrap::from_parts(Bootstrap::parse(text, Path::new("test"))?, env)
}
#[test]
fn with_no_bootstrap_anywhere_the_proxy_is_a_pc_and_reads_no_pendrives() {
let missing = Path::new("/definitely/not/here/bootstrap.toml");
let b = Bootstrap::discover_at(missing, &no_env).unwrap();
assert_eq!(b.mode, Mode::Pc);
assert_eq!(b.config_path, PathBuf::from("continuum.toml"));
assert!(!b.config_path_is_explicit);
assert!(b.pendrive_dirs.is_empty());
}
#[test]
fn a_proxy_box_defaults_to_the_var_lib_config_and_the_standard_mount_points() {
let b = from_text("mode = \"proxy_box\"", &no_env).unwrap();
assert_eq!(b.config_path, PathBuf::from(BOX_CONFIG_PATH));
assert_eq!(b.pendrive_dirs.len(), 2);
}
#[test]
fn pc_mode_drops_pendrive_dirs_even_when_the_bootstrap_lists_some() {
let b = from_text("mode = \"pc\"\npendrive_dirs = [\"/media\"]", &no_env).unwrap();
assert!(b.pendrive_dirs.is_empty());
}
#[test]
fn pc_mode_drops_pendrive_dirs_supplied_through_the_environment_too() {
let env = |k: &str| (k == "CONTINUUM_PENDRIVE_DIRS").then(|| "/media:/mnt".to_string());
let b = from_text("mode = \"pc\"", &env).unwrap();
assert!(b.pendrive_dirs.is_empty());
}
#[test]
fn the_environment_overrides_the_bootstrap_file() {
let env = |k: &str| match k {
"CONTINUUM_MODE" => Some("pc".to_string()),
"CONTINUUM_CONFIG_PATH" => Some("/tmp/mine.toml".to_string()),
_ => None,
};
let b = from_text("mode = \"proxy_box\"", &env).unwrap();
assert_eq!(b.mode, Mode::Pc);
assert_eq!(b.config_path, PathBuf::from("/tmp/mine.toml"));
assert!(b.config_path_is_explicit);
}
#[test]
fn an_unknown_mode_in_the_environment_is_an_error_not_a_guess() {
let env = |k: &str| (k == "CONTINUUM_MODE").then(|| "appliance".to_string());
assert!(from_text("", &env).is_err());
}
#[test]
fn a_misspelled_bootstrap_key_is_refused() {
assert!(from_text("mod = \"proxy_box\"", &no_env).is_err());
}
#[test]
fn an_explicit_bootstrap_path_that_does_not_exist_is_an_error() {
let env = |k: &str| (k == "CONTINUUM_BOOTSTRAP").then(|| "/definitely/not/here.toml".to_string());
assert!(Bootstrap::discover_at(Path::new("/also/missing"), &env).is_err());
}
#[test]
fn a_defaulted_config_path_that_does_not_exist_means_no_file_not_an_error() {
let mut b = from_text("", &no_env).unwrap();
b.config_path = PathBuf::from("/definitely/not/here/continuum.toml");
assert!(b.load_config_file().unwrap().is_none());
}
#[test]
fn a_named_config_path_that_does_not_exist_is_an_error() {
let mut b = from_text("", &no_env).unwrap();
b.config_path = PathBuf::from("/definitely/not/here/continuum.toml");
b.config_path_is_explicit = true;
assert!(b.load_config_file().is_err());
}
}
+65 -11
View File
@@ -18,9 +18,10 @@
//! Only the X1/H2 class publishes an `rtsp_url`. Confirmed against two real
//! machines: an H2C reports `rtsps://<ip>:322/streaming/live/1`, and a P1P
//! reports an `ipcam` block with no URL in it at all. A P1/A1 camera is
//! reachable only over Bambu's proprietary port-6000 JPEG stream, which this
//! crate does not speak, so those printers get no stream here and say so
//! rather than advertising one that will not play.
//! reachable only over Bambu's proprietary port-6000 JPEG stream. That is
//! not restreamable here (go2rtc cannot make WebRTC of MJPEG without ffmpeg),
//! so those printers get no go2rtc stream; they are served single frames by
//! `printer::bambu_camera` instead and advertise `camera_snapshot`.
//!
//! That is not just an inference from the missing field - it is confirmed at
//! the network layer (2026-10-15). The H2C accepts TCP on 322; the P1P
@@ -55,11 +56,16 @@ use crate::uplink::SharedFleet;
/// How often to rebuild the configuration from what the printers report.
///
/// A camera URL changes when a printer's address changes, which is a DHCP
/// lease event, not something that needs watching closely. The first useful
/// rebuild is the one a few seconds after startup, once the first reports
/// have arrived - before that the fleet has nothing to say.
const REBUILD_INTERVAL: Duration = Duration::from_secs(30);
/// The first tick fires immediately, before any printer has reported, so it
/// always writes a config with no streams. The one that matters is the next,
/// once the first reports have arrived - and until it runs, telemetry already
/// advertises a camera that go2rtc cannot serve, so a viewer in that window
/// gets "stream not found". At 30s that window was half a minute after every
/// start (found by scripts/e2e-camera.sh, which asked at +7s).
///
/// Cheap to poll: it reads in-memory reports and compares the rendered
/// config byte for byte, and go2rtc is only restarted when that changes.
const REBUILD_INTERVAL: Duration = Duration::from_secs(5);
/// The username every Bambu uses for its own services - MQTT, FTPS, and the
/// camera. Not a guess: it is what `bambu_mqtt` and `bambu_ftps` already
@@ -126,6 +132,14 @@ pub fn source_for(printer: &PrinterHandle, report_url: Option<&str>) -> Option<C
})
}
/// Whether this printer's camera is the P1/A1 kind: it reports a camera
/// block but no RTSP URL, so the only way to see it is single JPEG frames
/// from port 6000 (`printer::bambu_camera`). The one rule for "snapshot
/// camera", used by telemetry, so what is advertised is what is served.
pub fn wants_snapshot(printer: &PrinterHandle, has_ipcam: bool, report_url: Option<&str>) -> bool {
matches!(printer, PrinterHandle::Bambu(_)) && has_ipcam && report_url.is_none_or(str::is_empty)
}
/// Renders a go2rtc configuration.
///
/// Hand-written YAML rather than a serializer, for the same reason the
@@ -162,8 +176,8 @@ impl ApiAuth {
/// Generated once per run and then held, never re-derived: `render_config`
/// is called every `REBUILD_INTERVAL` and its output is compared
/// byte-for-byte to decide whether to restart go2rtc, so a password that
/// changed per call would restart it - and drop every live viewer - every
/// 30 seconds, forever.
/// changed per call would restart it - and drop every live viewer - on
/// every tick, forever.
///
/// A generated password is never logged. It is written into the config
/// file, which already holds every printer's access code and is already
@@ -369,8 +383,23 @@ pub async fn run(
/// on the same filesystem, so a reader sees either the old file or the new
/// one.
async fn write_atomically(path: &str, contents: &str) -> std::io::Result<()> {
use tokio::io::AsyncWriteExt;
let temp = format!("{path}.tmp");
tokio::fs::write(&temp, contents).await?;
// `tokio::fs::write` creates with 0666 minus the umask, so 0644: any local
// user could read the printer's access code and go2rtc's API password out
// of this file. Created owner-only instead, and created fresh - a temp file
// left behind by a crash would keep whatever mode it was given, because the
// mode applies only when the file is created.
let _ = tokio::fs::remove_file(&temp).await;
let mut options = tokio::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
options.mode(0o600);
let mut file = options.open(&temp).await?;
file.write_all(contents.as_bytes()).await?;
file.flush().await?;
drop(file);
tokio::fs::rename(&temp, path).await
}
@@ -538,4 +567,29 @@ mod tests {
"got:\n{config}"
);
}
/// The file holds a printer's access code and go2rtc's API password, so it
/// must not be readable by other local users. The second write matters: a
/// temp file left by a crash, created 0644, must not poison the next one.
#[cfg(unix)]
#[tokio::test]
async fn the_go2rtc_config_is_written_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("continuum-camera-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("go2rtc.yaml");
let path_str = path.to_str().unwrap();
std::fs::write(format!("{path_str}.tmp"), "stale").unwrap();
std::fs::set_permissions(format!("{path_str}.tmp"), std::fs::Permissions::from_mode(0o644)).unwrap();
write_atomically(path_str, "secret: yes\n").await.unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "secret: yes\n");
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
write_atomically(path_str, "secret: again\n").await.unwrap();
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
std::fs::remove_dir_all(&dir).unwrap();
}
}
+1 -1
View File
@@ -105,7 +105,7 @@ pub async fn run(fleet: SharedFleet) {
if let Err(err) = printer.refresh_status().await {
warn!(printer = %printer.base().id, %err, "could not request a status refresh");
}
if let Some(state) = printer.print_status().await.and_then(|r| r.gcode_state) {
if let Some(state) = printer.status().await.and_then(|s| s.state) {
states.push((printer.base().id.clone(), state));
}
}
+609 -15
View File
@@ -1,6 +1,51 @@
use std::env;
//! The proxy's own settings: what it is called, where the cloud is, where
//! go2rtc lives, and (on a Proxy Box) how it is networked.
//!
//! # Where settings come from, in one place
//!
//! **A config file is the base; `CONTINUUM_*` environment variables override
//! it; built-in defaults fill what neither says.** That is the whole
//! precedence rule and nothing else in the crate re-states it. Env wins
//! because that is what every existing deployment, test and `.env` file
//! already relies on - `Config::from_env` predates the file, and a file that
//! silently beat the env would change the behaviour of setups that never
//! asked for a file.
//!
//! *Which* file is not decided here: that is the bootstrap's job (see
//! `bootstrap.rs`), because the proxy has to know whether it is a Proxy Box
//! or a program on someone's PC before it knows where to look. This module
//! only parses a file it is handed and layers it under the environment.
//!
//! # Not the fleet file
//!
//! `printers.toml` (see `fleet.rs`) lists the printers and holds their access
//! codes. This file configures the *proxy itself*. They are separate on
//! purpose: the proxy config is meant to be shipped to a box on a pendrive by
//! whoever provisions it, and the fleet file is full of per-site secrets that
//! should never ride along on a stick that gets passed around. The two meet
//! at exactly one field, `printers_path`, which says where the fleet file is.
//! On a Proxy Box the fleet file is therefore NOT delivered by this
//! mechanism - how it gets there is a separate, unsolved question.
//!
//! # Network settings are parsed, not applied
//!
//! `[network]` is read, validated and exposed (`ProxyFile::network`), and
//! that is where it stops. Nothing in this crate touches `/etc/network`,
//! NetworkManager or any other OS state. Applying them means choosing a
//! network stack for an image that does not exist yet, running as root, and a
//! wrong write can take the box off the network with nobody able to log in
//! to fix it - so it belongs in the appliance's provisioning layer, designed
//! together with a rollback. The boundary is `NetworkSettings` itself:
//! whatever applies it consumes that type and the proxy never will.
#[derive(Debug, Clone)]
use std::env;
use std::fmt;
use std::net::IpAddr;
use std::time::Duration;
use serde::Deserialize;
#[derive(Clone)]
pub struct Config {
pub gateway_id: String,
pub farm_id: String,
@@ -31,28 +76,577 @@ pub struct Config {
/// `BambuPrinter::ensure_trusted`) lives entirely in that file now,
/// not as a separate fleet-wide env-configured policy here.
pub printers_path: String,
/// The gateway's own database (src/store.rs): printers added from the
/// console, credentials included, so the file is created owner-only.
pub store_path: String,
/// How, and whether, to look for printers nobody has configured.
///
/// Off unless switched on - see [`DiscoverySettings`] for why that is
/// not merely a cautious default.
pub discovery: DiscoverySettings,
}
/// The file form of the proxy's settings. Every key is optional here: a key
/// the file omits falls through to the environment and then the default, so
/// a PC user can keep secrets in the environment and a file for the rest.
///
/// `deny_unknown_fields` is deliberate and the opposite of lenient. On a
/// Proxy Box nobody can edit the file afterwards, so a typo'd key
/// (`uplink_ulr`) that was silently ignored would ship a box running on the
/// default cloud URL with nothing saying so. Refusing at import time turns
/// that into a rejected pendrive the operator sees immediately.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProxyFile {
pub gateway_id: Option<String>,
pub farm_id: Option<String>,
pub gateway_token: Option<String>,
pub uplink_url: Option<String>,
pub go2rtc_bin: Option<String>,
pub go2rtc_config: Option<String>,
pub go2rtc_api_listen: Option<String>,
pub stream_base_url: Option<String>,
pub printers_path: Option<String>,
pub store_path: Option<String>,
/// Only meaningful on a Proxy Box; see the module docs for why it is
/// exposed and not applied.
pub network: Option<NetworkSettings>,
/// Absent means off, which is the same as `enabled = false`. A file that
/// says nothing about discovery gets none.
pub discovery: Option<DiscoveryFile>,
}
impl ProxyFile {
pub fn parse(text: &str) -> anyhow::Result<Self> {
let file: Self = toml::from_str(text)?;
if let Some(network) = &file.network {
network.validate()?;
}
if let Some(discovery) = &file.discovery {
discovery.validate()?;
}
Ok(file)
}
}
/// Whether and how the gateway looks for printers nobody configured, and the
/// one thing it must never do while looking.
///
/// # Off by default, and not just out of caution
///
/// Discovery sends unsolicited traffic to addresses on someone else's
/// network. A `/24` sweep is 254 TLS handshakes and up to three HTTP requests
/// per host. Anything watching cannot tell that from a port scan, and on a
/// corporate network being indistinguishable from a port scan is itself the
/// problem. A gateway that did this by default would scan networks
/// its operator never meant to expose it to, and the first they would hear of
/// it is from their own security team.
///
/// So `enabled` is false until someone says otherwise, per gateway, in that
/// gateway's own config. The cloud cannot switch it on: a setting whose whole
/// purpose is to bound what the gateway does to the local network has to be
/// decided where that network is understood, which is not in a data centre.
///
/// # Serial discovery never opens a port
///
/// `serial` enumerates USB devices from sysfs and stops there. It will not
/// send `M115`, ever, and that is a correctness requirement rather than a
/// preference: opening a serial port toggles DTR, which reboots an
/// Arduino-based board. On a printer that is mid-job that ruins the print.
/// `examples/discover_printers.rs` can do the handshake because a human is
/// standing there and has typed `--yes-reset-the-printer`; a background task
/// on a schedule has nobody to ask, so it does not get the option.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiscoverySettings {
pub enabled: bool,
/// Subnets to sweep, as CIDR, e.g. `192.168.0.0/24`. Empty means no
/// sweep - the gateway then only listens, if `ssdp` is on.
///
/// Explicit rather than derived from the gateway's own interfaces. A box
/// with a second NIC, a VPN or a container bridge would otherwise sweep
/// a network nobody meant to point it at, and the operator naming the
/// subnet is also the operator taking responsibility for it.
pub subnets: Vec<String>,
/// Passive SSDP listening: no traffic sent, just whatever Bambu printers
/// announce themselves. Cheap and unobjectionable, but useless across a
/// router - multicast does not cross one - so a sweep is what works on a
/// real farm network.
pub ssdp: bool,
/// Enumerate USB serial devices. Reads sysfs; opens nothing. See above.
pub serial: bool,
pub interval: Duration,
}
impl Default for DiscoverySettings {
fn default() -> Self {
Self {
enabled: false,
subnets: Vec::new(),
ssdp: false,
serial: false,
// Only ever reached when `enabled` is true, so this is "how
// often a farm's hardware changes", not "how often to scan a
// network that did not ask for it". Half an hour is far more
// often than printers get bought and still negligible traffic.
interval: Duration::from_secs(30 * 60),
}
}
}
/// The file form. Every key optional, same as the rest of [`ProxyFile`].
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DiscoveryFile {
pub enabled: Option<bool>,
pub subnets: Option<Vec<String>>,
pub ssdp: Option<bool>,
pub serial: Option<bool>,
pub interval_minutes: Option<u64>,
}
impl DiscoveryFile {
/// Rejects at import time what would otherwise be found at scan time -
/// which on a Proxy Box means a stick the operator has already walked
/// away from.
pub fn validate(&self) -> anyhow::Result<()> {
if let Some(minutes) = self.interval_minutes {
// A floor, because the cost of a sweep falls on the printers and
// on whatever else answers on that network, not on us. One
// minute is already more often than anything here changes.
anyhow::ensure!(minutes >= 1, "discovery.interval_minutes must be at least 1");
}
for subnet in self.subnets.iter().flatten() {
// The same parser the sweep will use, so a `/16` typo is refused
// here rather than at 03:00 when the timer first fires. It
// refuses anything wider than a /24 - see `hosts_in_subnet`.
crate::discovery::net::hosts_in_subnet(subnet)
.map_err(|err| anyhow::anyhow!("discovery.subnets: {err}"))?;
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NetworkMode {
Dhcp,
Static,
}
/// What a Proxy Box should be configured to do on the network. Parsed and
/// validated only - nothing applies it (module docs).
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NetworkSettings {
pub mode: NetworkMode,
pub ip: Option<IpAddr>,
pub prefix_len: Option<u8>,
pub gateway: Option<IpAddr>,
#[serde(default)]
pub dns: Vec<IpAddr>,
pub wifi: Option<WifiSettings>,
}
impl NetworkSettings {
/// Rejects a static config that could not possibly work. This is the one
/// place a bad network block is caught before it is installed: a box
/// whose stored config says "static" with no address would, once
/// something does apply it, come up unreachable - the failure we least
/// want to learn about from a customer.
pub fn validate(&self) -> anyhow::Result<()> {
if self.mode == NetworkMode::Static {
let ip = self.ip.ok_or_else(|| anyhow::anyhow!("network.mode is \"static\" but network.ip is not set"))?;
let prefix = self
.prefix_len
.ok_or_else(|| anyhow::anyhow!("network.mode is \"static\" but network.prefix_len is not set"))?;
let max = if ip.is_ipv4() { 32 } else { 128 };
anyhow::ensure!(prefix <= max, "network.prefix_len {prefix} is out of range for {ip} (max {max})");
}
if let Some(wifi) = &self.wifi {
anyhow::ensure!(!wifi.ssid.is_empty(), "network.wifi.ssid is empty");
}
Ok(())
}
}
/// `psk` is a secret, so `Debug` is written by hand: the derived one would
/// put the wifi password in any log line that prints the parsed config.
#[derive(Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WifiSettings {
pub ssid: String,
pub psk: Option<String>,
}
impl fmt::Debug for WifiSettings {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("WifiSettings")
.field("ssid", &self.ssid)
.field("psk", &self.psk.as_ref().map(|_| "<redacted>"))
.finish()
}
}
/// By hand: the derived `Debug` would print `gateway_token`, the credential
/// that opens this farm's gateway socket, into any log line that formats the
/// config.
impl fmt::Debug for Config {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Config")
.field("gateway_id", &self.gateway_id)
.field("farm_id", &self.farm_id)
.field("gateway_token", &"<redacted>")
.field("uplink_url", &self.uplink_url)
.field("printers_path", &self.printers_path)
.field("store_path", &self.store_path)
.finish_non_exhaustive()
}
}
impl Config {
/// Environment only - the behaviour from before there was a file, kept
/// so existing callers and tests do not change.
pub fn from_env() -> anyhow::Result<Self> {
Self::layered(None, &|key| env::var(key).ok())
}
/// File as the base, environment on top (module docs).
pub fn from_file_and_env(file: Option<&ProxyFile>) -> anyhow::Result<Self> {
Self::layered(file, &|key| env::var(key).ok())
}
/// The layering itself, with the environment passed in as a lookup so
/// tests can exercise precedence without mutating the process-wide env,
/// which the parallel test runner would turn into a race.
pub fn layered(file: Option<&ProxyFile>, env: &dyn Fn(&str) -> Option<String>) -> anyhow::Result<Self> {
let empty = ProxyFile::default();
let file = file.unwrap_or(&empty);
let require = |key: &str, field: &Option<String>, name: &str| {
env(key).or_else(|| field.clone()).ok_or_else(|| {
anyhow::anyhow!("missing required env var {key} (or `{name}` in the proxy config file)")
})
};
let or_default = |key: &str, field: &Option<String>, default: &str| {
env(key).or_else(|| field.clone()).unwrap_or_else(|| default.to_string())
};
Ok(Self {
gateway_id: require("CONTINUUM_GATEWAY_ID")?,
farm_id: require("CONTINUUM_FARM_ID")?,
gateway_token: require("CONTINUUM_GATEWAY_TOKEN")?,
uplink_url: env_or("CONTINUUM_UPLINK_URL", "wss://api.continuum.local/ws/edge/v1"),
go2rtc_bin: env_or("CONTINUUM_GO2RTC_BIN", "go2rtc"),
go2rtc_config: env_or("CONTINUUM_GO2RTC_CONFIG", "./go2rtc.yaml"),
go2rtc_api_listen: env_or("CONTINUUM_GO2RTC_API_LISTEN", ":1984"),
stream_base_url: env::var("CONTINUUM_STREAM_BASE_URL").ok().filter(|v| !v.is_empty()),
printers_path: env_or("CONTINUUM_PRINTERS_PATH", "printers.toml"),
gateway_id: require("CONTINUUM_GATEWAY_ID", &file.gateway_id, "gateway_id")?,
farm_id: require("CONTINUUM_FARM_ID", &file.farm_id, "farm_id")?,
gateway_token: require("CONTINUUM_GATEWAY_TOKEN", &file.gateway_token, "gateway_token")?,
uplink_url: or_default("CONTINUUM_UPLINK_URL", &file.uplink_url, "wss://api.continuum.local/ws/edge/v1"),
go2rtc_bin: or_default("CONTINUUM_GO2RTC_BIN", &file.go2rtc_bin, "go2rtc"),
go2rtc_config: or_default("CONTINUUM_GO2RTC_CONFIG", &file.go2rtc_config, "./go2rtc.yaml"),
go2rtc_api_listen: or_default("CONTINUUM_GO2RTC_API_LISTEN", &file.go2rtc_api_listen, ":1984"),
// An empty value means "unset" at every layer, so an env var
// exported as empty can switch the advertisement off even when
// the file sets one.
stream_base_url: env("CONTINUUM_STREAM_BASE_URL")
.or_else(|| file.stream_base_url.clone())
.filter(|v| !v.is_empty()),
printers_path: or_default("CONTINUUM_PRINTERS_PATH", &file.printers_path, "printers.toml"),
store_path: or_default("CONTINUUM_STORE_PATH", &file.store_path, "continuum.db"),
discovery: discovery_settings(file.discovery.as_ref(), env)?,
})
}
}
fn require(key: &str) -> anyhow::Result<String> {
env::var(key).map_err(|_| anyhow::anyhow!("missing required env var {key}"))
/// Layered like everything else, except that the default is "do nothing" and
/// every layer has to say so explicitly to change that.
///
/// Note what is *not* here: no way for a missing or unparseable value to end
/// up as "on". `CONTINUUM_DISCOVERY_ENABLED=yes` is an error rather than a
/// shrug, because the two readings of a typo are not equally safe - treating
/// it as false surprises an operator who wanted scanning, and treating it as
/// true starts scanning a network nobody authorised. Refusing to start says
/// so while someone is still looking.
fn discovery_settings(
file: Option<&DiscoveryFile>,
env: &dyn Fn(&str) -> Option<String>,
) -> anyhow::Result<DiscoverySettings> {
let defaults = DiscoverySettings::default();
let empty = DiscoveryFile::default();
let file = file.unwrap_or(&empty);
let flag = |key: &str, field: Option<bool>, default: bool| -> anyhow::Result<bool> {
match env(key) {
Some(raw) => raw
.trim()
.parse::<bool>()
.map_err(|_| anyhow::anyhow!("{key} must be `true` or `false`, not {raw:?}")),
None => Ok(field.unwrap_or(default)),
}
};
let enabled = flag("CONTINUUM_DISCOVERY_ENABLED", file.enabled, defaults.enabled)?;
let subnets = match env("CONTINUUM_DISCOVERY_SUBNETS") {
// Comma-separated, and an empty value means "none" rather than one
// empty subnet - the same "empty env var switches it off" rule
// `stream_base_url` follows above.
Some(raw) => raw
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect(),
None => file.subnets.clone().unwrap_or_default(),
};
let interval_minutes = match env("CONTINUUM_DISCOVERY_INTERVAL_MINUTES") {
Some(raw) => Some(
raw.trim()
.parse::<u64>()
.map_err(|_| anyhow::anyhow!("CONTINUUM_DISCOVERY_INTERVAL_MINUTES must be a whole number of minutes, not {raw:?}"))?,
),
None => file.interval_minutes,
};
let settings = DiscoverySettings {
enabled,
subnets,
ssdp: flag("CONTINUUM_DISCOVERY_SSDP", file.ssdp, defaults.ssdp)?,
serial: flag("CONTINUUM_DISCOVERY_SERIAL", file.serial, defaults.serial)?,
interval: interval_minutes
.map(|m| Duration::from_secs(m * 60))
.unwrap_or(defaults.interval),
};
// The env path bypasses `DiscoveryFile::validate`, so re-run the checks
// it makes on whatever actually came out of the layering. Only when
// enabled: a stale `CONTINUUM_DISCOVERY_SUBNETS` left in someone's shell
// profile should not stop a gateway that is not scanning anything.
if settings.enabled {
let effective = DiscoveryFile {
subnets: Some(settings.subnets.clone()),
interval_minutes,
..DiscoveryFile::default()
};
effective.validate()?;
if settings.subnets.is_empty() && !settings.ssdp && !settings.serial {
// Enabled and told to look nowhere. Almost certainly a config
// that was meant to do something, so say so instead of running a
// timer that reports an empty list forever.
anyhow::bail!(
"discovery is enabled but has nothing to look at: set `discovery.subnets`, or turn on `discovery.ssdp` or `discovery.serial`"
);
}
}
Ok(settings)
}
fn env_or(key: &str, default: &str) -> String {
env::var(key).unwrap_or_else(|_| default.to_string())
#[cfg(test)]
mod tests {
use super::*;
fn no_env(_: &str) -> Option<String> {
None
}
fn file(text: &str) -> ProxyFile {
ProxyFile::parse(text).expect("test config parses")
}
const MINIMAL: &str = "gateway_id = \"g\"\nfarm_id = \"f\"\ngateway_token = \"t\"\n";
#[test]
fn with_no_file_and_no_env_the_required_settings_are_reported_missing() {
let err = Config::layered(None, &no_env).unwrap_err().to_string();
assert!(err.contains("CONTINUUM_GATEWAY_ID"), "{err}");
}
#[test]
fn the_environment_alone_still_configures_the_proxy_as_it_did_before_the_file_existed() {
let env = |k: &str| match k {
"CONTINUUM_GATEWAY_ID" => Some("g".into()),
"CONTINUUM_FARM_ID" => Some("f".into()),
"CONTINUUM_GATEWAY_TOKEN" => Some("t".into()),
_ => None,
};
let config = Config::layered(None, &env).unwrap();
assert_eq!(config.gateway_id, "g");
assert_eq!(config.go2rtc_api_listen, ":1984");
assert_eq!(config.printers_path, "printers.toml");
}
#[test]
fn a_file_alone_is_enough_and_unset_keys_take_the_defaults() {
let config = Config::layered(Some(&file(MINIMAL)), &no_env).unwrap();
assert_eq!(config.farm_id, "f");
assert_eq!(config.go2rtc_bin, "go2rtc");
}
#[test]
fn an_environment_variable_overrides_the_same_key_in_the_file() {
let text = format!("{MINIMAL}uplink_url = \"wss://from-file\"\n");
let env = |k: &str| (k == "CONTINUUM_UPLINK_URL").then(|| "wss://from-env".to_string());
let config = Config::layered(Some(&file(&text)), &env).unwrap();
assert_eq!(config.uplink_url, "wss://from-env");
}
#[test]
fn a_file_key_the_environment_does_not_mention_survives_the_overlay() {
let text = format!("{MINIMAL}go2rtc_bin = \"/opt/go2rtc\"\n");
let env = |k: &str| (k == "CONTINUUM_UPLINK_URL").then(|| "wss://x".to_string());
let config = Config::layered(Some(&file(&text)), &env).unwrap();
assert_eq!(config.go2rtc_bin, "/opt/go2rtc");
}
#[test]
fn an_empty_stream_base_url_in_the_environment_switches_off_one_set_in_the_file() {
let text = format!("{MINIMAL}stream_base_url = \"https://cam.example\"\n");
let env = |k: &str| (k == "CONTINUUM_STREAM_BASE_URL").then(String::new);
let config = Config::layered(Some(&file(&text)), &env).unwrap();
assert_eq!(config.stream_base_url, None);
}
#[test]
fn a_misspelled_key_is_refused_rather_than_silently_ignored() {
let err = ProxyFile::parse(&format!("{MINIMAL}uplink_ulr = \"x\"\n")).unwrap_err().to_string();
assert!(err.contains("uplink_ulr"), "{err}");
}
#[test]
fn a_static_network_block_without_an_address_is_refused() {
let err = ProxyFile::parse("[network]\nmode = \"static\"\n").unwrap_err().to_string();
assert!(err.contains("network.ip"), "{err}");
}
#[test]
fn a_static_network_block_with_an_impossible_prefix_is_refused() {
let text = "[network]\nmode = \"static\"\nip = \"10.0.0.5\"\nprefix_len = 40\n";
assert!(ProxyFile::parse(text).is_err());
}
#[test]
fn a_complete_network_block_is_parsed_and_exposed_but_not_applied() {
let text = "[network]\nmode = \"static\"\nip = \"10.0.0.5\"\nprefix_len = 24\ngateway = \"10.0.0.1\"\ndns = [\"1.1.1.1\"]\n";
let network = ProxyFile::parse(text).unwrap().network.unwrap();
assert_eq!(network.ip, Some("10.0.0.5".parse().unwrap()));
assert_eq!(network.dns.len(), 1);
}
#[test]
fn the_wifi_password_never_appears_in_debug_output() {
let text = "[network]\nmode = \"dhcp\"\n[network.wifi]\nssid = \"farm\"\npsk = \"hunter2hunter2\"\n";
let parsed = ProxyFile::parse(text).unwrap();
let shown = format!("{parsed:?}");
assert!(shown.contains("farm") && !shown.contains("hunter2"), "{shown}");
}
/// The one default in this file that is a security property rather than a
/// convenience, so it is pinned from several directions.
mod discovery_is_off_unless_asked {
use super::*;
#[test]
fn with_no_config_at_all() {
let env = |k: &str| match k {
"CONTINUUM_GATEWAY_ID" => Some("g".into()),
"CONTINUUM_FARM_ID" => Some("f".into()),
"CONTINUUM_GATEWAY_TOKEN" => Some("t".into()),
_ => None,
};
assert!(!Config::layered(None, &env).unwrap().discovery.enabled);
}
#[test]
fn with_a_file_that_says_nothing_about_it() {
let config = Config::layered(Some(&file(MINIMAL)), &no_env).unwrap();
assert!(!config.discovery.enabled);
assert!(config.discovery.subnets.is_empty());
assert!(!config.discovery.ssdp);
assert!(!config.discovery.serial);
}
#[test]
fn with_a_discovery_block_that_only_sets_a_subnet() {
// Naming a subnet is not consent to scan it. Someone who wrote
// the address down and left `enabled` out gets nothing, which is
// the safe reading of an incomplete instruction.
let text = format!("{MINIMAL}[discovery]\nsubnets = [\"192.168.0.0/24\"]\n");
let config = Config::layered(Some(&file(&text)), &no_env).unwrap();
assert!(!config.discovery.enabled);
}
}
#[test]
fn a_file_can_switch_discovery_on_and_say_where_to_look() {
let text = format!(
"{MINIMAL}[discovery]\nenabled = true\nsubnets = [\"192.168.0.0/24\"]\nserial = true\ninterval_minutes = 5\n"
);
let config = Config::layered(Some(&file(&text)), &no_env).unwrap();
assert!(config.discovery.enabled);
assert_eq!(config.discovery.subnets, ["192.168.0.0/24"]);
assert!(config.discovery.serial);
assert!(!config.discovery.ssdp);
assert_eq!(config.discovery.interval, Duration::from_secs(300));
}
#[test]
fn the_environment_can_switch_discovery_off_over_a_file_that_enables_it() {
// Which direction matters: the override that must work is the one
// that stops the scanning, so an operator who finds their gateway
// probing a network can kill it without editing a file they may not
// be able to edit (a Proxy Box's config is signed in the cloud).
let text = format!("{MINIMAL}[discovery]\nenabled = true\nserial = true\n");
let env = |k: &str| (k == "CONTINUUM_DISCOVERY_ENABLED").then(|| "false".to_string());
let config = Config::layered(Some(&file(&text)), &env).unwrap();
assert!(!config.discovery.enabled);
}
#[test]
fn a_discovery_flag_that_is_neither_true_nor_false_is_an_error_rather_than_a_guess() {
// The two readings of a typo are not equally safe: false surprises
// someone who wanted scanning, true scans a network nobody
// authorised. Refusing to start says so while someone is watching.
let env = |k: &str| match k {
"CONTINUUM_GATEWAY_ID" => Some("g".into()),
"CONTINUUM_FARM_ID" => Some("f".into()),
"CONTINUUM_GATEWAY_TOKEN" => Some("t".into()),
"CONTINUUM_DISCOVERY_ENABLED" => Some("yes".into()),
_ => None,
};
let err = Config::layered(None, &env).unwrap_err().to_string();
assert!(err.contains("CONTINUUM_DISCOVERY_ENABLED"), "{err}");
}
#[test]
fn a_subnet_wider_than_a_slash_24_is_refused_when_the_file_is_read() {
// At import time, which on a Proxy Box is while the operator is still
// holding the stick - rather than at 03:00 when the timer first
// fires and tries to probe 65k addresses.
let text = format!("{MINIMAL}[discovery]\nenabled = true\nsubnets = [\"10.0.0.0/16\"]\n");
let err = ProxyFile::parse(&text).unwrap_err().to_string();
assert!(err.contains("discovery.subnets"), "{err}");
}
#[test]
fn discovery_enabled_with_nowhere_to_look_is_refused_as_the_mistake_it_is() {
let text = format!("{MINIMAL}[discovery]\nenabled = true\n");
let err = Config::layered(Some(&file(&text)), &no_env).unwrap_err().to_string();
assert!(err.contains("nothing to look at"), "{err}");
}
#[test]
fn a_misspelled_discovery_key_is_refused_like_every_other_one() {
let text = format!("{MINIMAL}[discovery]\nenabled = true\nsubnet = [\"192.168.0.0/24\"]\n");
assert!(ProxyFile::parse(&text).is_err());
}
#[test]
fn a_stale_subnet_in_the_environment_does_not_stop_a_gateway_that_is_not_scanning() {
// Validation of the subnets only runs when discovery is on. A
// leftover export in someone's shell profile should not be fatal to
// a gateway that will never read it.
let env = |k: &str| match k {
"CONTINUUM_GATEWAY_ID" => Some("g".into()),
"CONTINUUM_FARM_ID" => Some("f".into()),
"CONTINUUM_GATEWAY_TOKEN" => Some("t".into()),
"CONTINUUM_DISCOVERY_SUBNETS" => Some("not-a-subnet".into()),
_ => None,
};
let config = Config::layered(None, &env).unwrap();
assert!(!config.discovery.enabled);
}
}
+1830
View File
File diff suppressed because it is too large Load Diff
+111 -12
View File
@@ -72,13 +72,15 @@
//! shows its working rather than only its conclusion.
pub mod net;
pub mod report;
pub mod scan;
pub mod serial;
use std::net::{Ipv4Addr, SocketAddr, UdpSocket as StdUdpSocket};
use std::time::Duration;
use tokio::net::UdpSocket;
use tracing::{debug, warn};
use tracing::{debug, info, warn};
/// The SSDP multicast group, which is the standard one - Bambu's discovery is
/// ordinary SSDP with vendor headers, not a protocol of its own.
@@ -163,6 +165,21 @@ pub enum Source {
SerialHandshake,
}
impl Source {
/// The `source` value in a `discovered_printers` message - see the
/// backend's `discovered_printer.schema.json`, which is an enum there, so
/// these strings are a contract and not a label.
pub fn wire_value(self) -> &'static str {
match self {
Source::Ssdp => "ssdp",
Source::Certificate => "certificate",
Source::HttpApi => "http_api",
Source::UsbDescriptor => "usb_descriptor",
Source::SerialHandshake => "serial_handshake",
}
}
}
/// A printer found on the network or on a USB port, minus its credential.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Candidate {
@@ -212,6 +229,28 @@ impl Candidate {
}
}
/// The wire form for `ClientMessage::DiscoveredPrinters`.
///
/// `bind_state` is deliberately dropped. It is a Bambu-only fact about a
/// cloud account, the cloud already knows more about its own accounts
/// than an SSDP packet does, and the one thing it is good for here -
/// explaining a printer that is visible and then refuses to connect -
/// is a diagnosis for someone standing in front of the machine, which is
/// what `examples/discover_printers.rs` prints it for.
pub fn to_wire(&self) -> crate::uplink::DiscoveredPrinter {
crate::uplink::DiscoveredPrinter {
vendor: self.vendor.toml_value(),
address: self.host.clone(),
serial: self.serial.clone(),
model: self.model.clone(),
name: self.name.clone(),
detail: self.detail.clone(),
ambiguous_with: self.ambiguous_with.map(Vendor::toml_value),
source: self.source.wire_value(),
driver_ready: self.vendor.driver_is_implemented(),
}
}
/// A `[[printer]]` block to paste into `printers.toml`.
///
/// Each vendor gets the fields `fleet.rs` actually requires of it and no
@@ -370,10 +409,10 @@ pub(crate) fn valid_serial(value: String) -> Option<String> {
/// one thing in a datagram that cannot be mistyped.
///
/// Tolerant on purpose: it extracts whatever headers are present rather than
/// requiring a full set. The exact header spelling is from Bambu's published
/// discovery format and has not been checked against a real announcement
/// here, so a missing vendor header has to mean "one fewer field filled in",
/// not "no printer found".
/// requiring a full set. The vendor header spelling was checked against a
/// real H2C's `200 OK` reply to a unicast M-SEARCH (2026-10-29); an
/// unsolicited NOTIFY has not been seen, so a missing vendor header has to
/// mean "one fewer field filled in", not "no printer found".
pub fn parse_ssdp(message: &str, from: Ipv4Addr) -> Option<Candidate> {
let mut lines = message.lines();
let start = lines.next()?.trim();
@@ -484,18 +523,47 @@ pub async fn listen_ssdp(port: u16, window: Duration, raw: bool) -> anyhow::Resu
Ok(found)
}
/// Binds the SSDP port and joins the group on every interface.
/// Binds the SSDP port and joins the group on every IPv4 interface.
///
/// `Ipv4Addr::UNSPECIFIED` as the interface asks the kernel to pick, which is
/// right for a gateway appliance: it may well have two interfaces and only
/// the operator knows which one faces the printers.
/// Passing `UNSPECIFIED` as the interface asks the OS to pick one, and that
/// is the wrong default on a machine with several: a Windows PC with Wi-Fi
/// plus a VPN, Hyper-V or WSL adapter joins on whichever it prefers, and
/// then hears nothing from printers on the other while looking perfectly
/// healthy. Only the operator knows which one faces the printers, so join on
/// all of them and say which were joined. Falls back to the OS's choice if
/// the interfaces cannot be listed.
fn bind_ssdp(port: u16) -> anyhow::Result<UdpSocket> {
let socket = StdUdpSocket::bind((Ipv4Addr::UNSPECIFIED, port)).map_err(|err| {
anyhow::anyhow!("could not bind UDP :{port} for discovery: {err} (is another process using it?)")
})?;
socket
.join_multicast_v4(&SSDP_GROUP, &Ipv4Addr::UNSPECIFIED)
.map_err(|err| anyhow::anyhow!("could not join {SSDP_GROUP} on :{port}: {err}"))?;
let mut interfaces: Vec<Ipv4Addr> = if_addrs::get_if_addrs()
.unwrap_or_default()
.into_iter()
.filter(|interface| !interface.is_loopback())
.filter_map(|interface| match interface.ip() {
std::net::IpAddr::V4(v4) => Some(v4),
std::net::IpAddr::V6(_) => None,
})
.collect();
if interfaces.is_empty() {
interfaces.push(Ipv4Addr::UNSPECIFIED);
}
let mut joined = Vec::new();
for interface in &interfaces {
match socket.join_multicast_v4(&SSDP_GROUP, interface) {
Ok(()) => joined.push(*interface),
// One adapter that refuses (down, no multicast) is not a reason to
// stop listening on the others.
Err(err) => debug!(%interface, ?err, "could not join the SSDP group on this interface"),
}
}
if joined.is_empty() {
anyhow::bail!("could not join {SSDP_GROUP} on :{port} on any of {interfaces:?}");
}
info!(port, interfaces = ?joined, "listening for SSDP");
socket.set_nonblocking(true)?;
Ok(UdpSocket::from_std(socket)?)
}
@@ -546,6 +614,37 @@ mod tests {
DevConnect.bambu.com: lan\r\n\
DevBind.bambu.com: free\r\n";
/// The reply a real H2C sent to a unicast M-SEARCH on 1990 and on 2021
/// (2026-10-29), byte for byte in shape; the serial, name and addresses
/// are made up. It is a `200 OK`, not a NOTIFY, and carries
/// `Devseclink`/`DevInf`/`DevCap`, which `NOTIFY` above does not.
#[test]
fn a_real_printers_reply_to_an_msearch_parses() {
let reply = "HTTP/1.1 200 OK\r\n\
Server: UPnP/1.0\r\n\
Date: Mon, 26 Oct 2026 17:21:14 GMT\r\n\
Location: 192.168.0.99\r\n\
ST: urn:bambulab-com:device:3dprinter:1\r\n\
EXT: \r\n\
USN: 31B8BP5A0000000\r\n\
Cache-Control: max-age=1800\r\n\
DevModel.bambu.com: O1C2\r\n\
DevName.bambu.com: Workshop\r\n\
DevConnect.bambu.com: lan\r\n\
DevBind.bambu.com: free\r\n\
Devseclink.bambu.com: secure\r\n\
DevInf.bambu.com: wlan0\r\n\
DevVersion.bambu.com: 01.08.00.00\r\n\
DevCap.bambu.com: 1\r\n\r\n";
let found = parse_ssdp(reply, Ipv4Addr::new(10, 0, 0, 1)).expect("a Bambu reply");
assert_eq!(found.host, "192.168.0.99");
assert_eq!(found.serial.as_deref(), Some("31B8BP5A0000000"));
assert_eq!(found.model.as_deref(), Some("O1C2"));
assert_eq!(found.name.as_deref(), Some("Workshop"));
assert_eq!(found.bind_state.as_deref(), Some("free"));
assert_eq!(found.detail.as_deref(), Some("Bambu firmware 01.08.00.00"));
}
#[test]
fn an_announcement_yields_every_field_it_carries() {
let found = parse_ssdp(NOTIFY, Ipv4Addr::new(10, 0, 0, 1)).expect("a Bambu announcement");
+182
View File
@@ -0,0 +1,182 @@
//! The task that tells the cloud what this gateway can see.
//!
//! Its reason for existing is small and specific: adding a printer in the web
//! console should not begin with someone walking to a machine to read its IP
//! address off the screen. It cannot finish the job - the credential is on
//! that screen and no probe can read it - but it removes the address, the
//! serial number and the guesswork about which vendor the thing is.
//!
//! # It does nothing unless asked
//!
//! `discovery.enabled` is false by default, and this task then parks forever
//! rather than looping over a disabled check. The reason is in
//! `DiscoverySettings`: a sweep is unsolicited traffic on someone else's
//! network, and a gateway that did it by default would be scanning networks
//! its operator never meant to point it at.
//!
//! Note which way the switch faces. It is read from the gateway's own config
//! and the cloud cannot set it, because a setting whose entire purpose is to
//! bound what this process does to the local network has to be decided where
//! that network is understood.
use std::time::Duration;
use tokio::sync::mpsc::Sender;
use crate::config::DiscoverySettings;
use crate::uplink::ClientMessage;
/// Scans on a timer and reports each pass, forever.
///
/// Never returns, so it sits in `main`'s `select!` alongside the other
/// long-running tasks. With discovery off it parks on a future that never
/// completes, which costs nothing and keeps the "every arm runs forever"
/// property of that `select!` true.
pub async fn run(
settings: DiscoverySettings,
printers_path: String,
store: Option<std::sync::Arc<crate::store::Store>>,
outbound: Sender<ClientMessage>,
) {
if !settings.enabled {
tracing::info!("printer discovery is off (discovery.enabled); not scanning this network");
std::future::pending::<()>().await;
return;
}
tracing::info!(
subnets = ?settings.subnets,
ssdp = settings.ssdp,
serial = settings.serial,
every = ?settings.interval,
"printer discovery is ON; this gateway will probe the network above"
);
let mut ticker = tokio::time::interval(settings.interval);
// A pass takes tens of seconds - an SSDP window alone is 32 - so a tick
// can come due while one is still running. `Delay` drops the ones that
// came due rather than firing them back to back afterwards, which is what
// `Burst` (the default) would do and would turn a slow scan into a
// continuous one.
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
// Fires immediately the first time, so a gateway reports what it can
// see shortly after boot rather than one interval later.
ticker.tick().await;
// Re-read every pass, not once at startup. Nothing re-reads the fleet
// file on a running daemon yet, so adding a printer still means a
// restart - but someone who has *edited* the file and not restarted
// should not keep being offered the printer they just configured.
let configured = match crate::fleet::configured(std::path::Path::new(&printers_path)) {
Ok(mut configured) => {
// Printers added from the console live in the store, not the
// file, and are just as "already configured".
if let Some(store) = &store {
match store.printers() {
Ok(stored) => configured.add_entries(stored),
Err(err) => {
tracing::warn!(%err, "cannot read the local store; skipping this discovery pass");
continue;
}
}
}
configured
}
// Fail the pass rather than report against an empty set. The
// alternative offers every printer the farm already runs as a
// fresh find, which is worse than no report: it is a list of
// things to add that are already added.
Err(err) => {
tracing::warn!(%err, "cannot tell which printers are already configured; skipping this discovery pass");
continue;
}
};
let scan = super::scan::scan(&settings, &configured).await;
if !scan.looked() {
// Every enabled source failed. An empty report would assert that
// this network has no unconfigured printers, which is a claim
// about the farm we have no evidence for - see `Scan::looked`.
tracing::warn!(
attempted = scan.attempted,
"every discovery source failed; not reporting, because an empty report would mean 'nothing is there'"
);
continue;
}
let printers: Vec<_> = scan.candidates.iter().map(super::Candidate::to_wire).collect();
tracing::info!(count = printers.len(), "reporting discovered printers");
let message = ClientMessage::DiscoveredPrinters {
printers,
scanned_at: now_rfc3339(),
};
// `send`, not `try_send`: the queue outlives any one connection, so
// waiting here is waiting for the cloud to come back, which is
// exactly the right thing to do with a scan result. If the channel is
// ever closed the uplink is gone and so is the process.
if outbound.send(message).await.is_err() {
tracing::warn!("uplink queue closed; stopping discovery reports");
return;
}
}
}
/// RFC 3339 in UTC, which is what the schema's `date-time` means.
///
/// Public so `examples/discover_printers.rs` can print a frame with a real
/// timestamp in it - a capture is meant to be feedable straight to the
/// backend's validator, and a placeholder there would not be.
pub fn now_rfc3339() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_secs();
format_rfc3339(secs)
}
/// Formats a Unix second count, with the clock passed in so it is testable.
///
/// Hand-rolled because this crate has no date library and one field does not
/// justify adding one - `scanned_at` is read by a human looking at a "found
/// 20 minutes ago" label, not by arithmetic.
fn format_rfc3339(secs: u64) -> String {
let (days, time) = (secs / 86_400, secs % 86_400);
let (hour, minute, second) = (time / 3600, (time % 3600) / 60, time % 60);
// Civil date from a day count, by the algorithm in Howard Hinnant's
// `civil_from_days`: shift the epoch to 0000-03-01 so a leap day lands at
// the end of the cycle and the month-length pattern repeats cleanly.
let z = days as i64 + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn timestamps_match_what_date_u_prints() {
// Pinned against `date -u -d @N`: an off-by-one in the civil date
// arithmetic is invisible until it is a day or a year out.
assert_eq!(format_rfc3339(0), "1970-01-01T00:00:00Z");
// A leap day, which is the whole reason this arithmetic needs care.
assert_eq!(format_rfc3339(1_709_164_800), "2024-02-29T00:00:00Z");
assert_eq!(format_rfc3339(1_792_413_296), "2026-10-19T12:34:56Z");
// Past 2100, where the "divisible by 100 is not a leap year" rule
// bites and a naive every-four-years version drifts.
assert_eq!(format_rfc3339(4_102_444_800), "2100-01-01T00:00:00Z");
// The last second of a year, which catches a December/January slip.
assert_eq!(format_rfc3339(1_767_225_599), "2025-12-31T23:59:59Z");
}
}
+159
View File
@@ -0,0 +1,159 @@
//! One pass of discovery, as a background task runs it - which is a narrower
//! thing than what `examples/discover_printers.rs` can do, and deliberately.
//!
//! The example has a human standing in front of the printers who can be asked
//! for consent and can read a warning. This has a timer. So the two differ on
//! exactly the operations where that distinction matters:
//!
//! * No `M115`. Opening a serial port toggles DTR and reboots an
//! Arduino-based board, which on a machine mid-job ruins the print. The
//! example will do it behind `--yes-reset-the-printer`; nothing on a
//! schedule gets that option, because there is nobody to type it.
//! * No subnet the operator did not name. `DiscoverySettings::subnets` is
//! explicit rather than derived from the gateway's own interfaces, so a
//! second NIC or a VPN cannot quietly widen what gets swept.
//!
//! What it produces is candidates with no credential in them, which is not a
//! simplification - see `uplink::DiscoveredPrinter` for why no probe can
//! learn one.
use std::time::Duration;
use super::{Candidate, net, serial};
use crate::config::DiscoverySettings;
use crate::fleet::Configured;
/// How long to sit on the SSDP group.
///
/// Has to exceed a Bambu printer's announce interval, which is around 30
/// seconds, or a scan can open its window just after a printer announced and
/// conclude the printer is not there. Nothing here is urgent enough for that
/// to be worth shaving.
const SSDP_WINDOW: Duration = Duration::from_secs(32);
/// Per-host budget for the TLS and HTTP probes. Generous, because it is
/// spread across a batch that runs concurrently - see `net::sweep`.
const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
/// The schema's `maxItems`. A gateway that somehow found more than this is
/// pointed at something that is not a printer farm, and a truncated list
/// beats a message the backend rejects whole.
const MAX_REPORTED: usize = 256;
/// The result of one pass, which is not just the candidates.
///
/// "Found nothing" and "could not look" are different answers, and an empty
/// candidate list alone cannot tell them apart. The distinction has to
/// survive as far as the cloud, because an empty report means *this gateway
/// looked and its network has no unconfigured printers* - so publishing one
/// after every probe failed would state something untrue about the farm.
/// Hence the counts: [`Scan::looked`] is what the reporter checks before it
/// sends anything.
#[derive(Debug, Default)]
pub struct Scan {
pub candidates: Vec<Candidate>,
/// How many sources were enabled - subnets, the SSDP ports, serial.
pub attempted: usize,
/// How many of them failed outright.
pub failed: usize,
}
impl Scan {
/// Whether this pass is evidence about the network at all.
///
/// False when every enabled source failed, which is the case an empty
/// report must not be sent for. A partial failure is still evidence: a
/// gateway that swept one of two subnets found what it found, and
/// withholding that until every source works would mean a container that
/// can never bind SSDP suppresses a perfectly good sweep forever.
pub fn looked(&self) -> bool {
self.attempted > 0 && self.failed < self.attempted
}
}
/// Everything enabled, merged, with the already-configured removed.
///
/// One failing source does not fail the pass: an unreachable subnet, or an
/// SSDP socket a container will not let us bind, should still let the rest
/// report what it found. Every failure is logged, and counted in the result
/// so the caller can tell a quiet network from a blind gateway.
pub async fn scan(settings: &DiscoverySettings, configured: &Configured) -> Scan {
let mut out = Scan::default();
if !settings.enabled {
return out;
}
let mut found = Vec::new();
if settings.ssdp {
// Both ports Bambu has shipped announcements on, concurrently: a
// printer uses one or the other depending on firmware, and listening
// on them in series would double the window for no reason.
let (first, second) = tokio::join!(
super::listen_ssdp(super::SSDP_PORTS[0], SSDP_WINDOW, false),
super::listen_ssdp(super::SSDP_PORTS[1], SSDP_WINDOW, false),
);
for result in [first, second] {
out.attempted += 1;
match result {
Ok(candidates) => found.extend(candidates),
// Expected on a host that cannot join a multicast group, and
// in a container whose network is a veth pair to a router -
// which is also why a sweep, not SSDP, is what works on a
// real farm network.
Err(err) => {
out.failed += 1;
tracing::warn!(%err, "SSDP listen failed");
}
}
}
}
for subnet in &settings.subnets {
out.attempted += 1;
match net::sweep(subnet, PROBE_TIMEOUT).await {
Ok(candidates) => found.extend(candidates),
Err(err) => {
out.failed += 1;
tracing::warn!(%err, subnet, "subnet sweep failed");
}
}
}
if settings.serial {
out.attempted += 1;
match serial::list_ports() {
// Every USB-attached port, not only the ones whose vendor id says
// "Prusa". The alternative misses most real Marlin printers,
// because the common boards sit behind a generic CH340 or FTDI
// bridge that is indistinguishable from any other USB serial
// device without the `M115` this task will not send. So the
// unavoidable error is made the harmless one: a candidate's
// `detail` says outright that it could be a printer or anything
// else, and an extra row in a list someone is choosing from costs
// far less than a printer that never appears.
Ok(ports) => found.extend(ports.iter().map(serial::SerialDevice::to_candidate)),
Err(err) => {
out.failed += 1;
tracing::warn!(%err, "serial port enumeration failed");
}
}
}
out.candidates = super::merge(found);
let candidates = &mut out.candidates;
// Last, not first: merging can fill in a serial that one source knew and
// another did not, and a candidate the fleet file names by serial is only
// recognisable as such once it has that serial.
candidates.retain(|candidate| {
!configured.mentions(Some(&candidate.host), candidate.serial.as_deref())
});
if candidates.len() > MAX_REPORTED {
tracing::warn!(
found = candidates.len(),
reported = MAX_REPORTED,
"truncating the discovery report"
);
candidates.truncate(MAX_REPORTED);
}
out
}
+11
View File
@@ -485,6 +485,7 @@ mod tests {
/// The one part of serial discovery this environment can demonstrate:
/// with no ports attached it finds none, and does not error doing it.
#[cfg(target_os = "linux")]
#[test]
fn listing_ports_where_there_are_none_is_empty_and_not_an_error() {
let ports = list_ports().expect("sysfs exists on this host");
@@ -492,4 +493,14 @@ mod tests {
assert!(port.path.starts_with("/dev/"), "{port:?}");
}
}
/// Port listing reads Linux sysfs, so elsewhere it must say so rather
/// than report "no ports" - which would look like a clean scan (found on
/// Windows CI, run 415, where the Linux test above failed).
#[cfg(not(target_os = "linux"))]
#[test]
fn listing_ports_off_linux_is_an_error_not_an_empty_list() {
let err = list_ports().expect_err("there is no sysfs here");
assert!(err.to_string().contains("not a Linux host"), "{err}");
}
}
+188
View File
@@ -0,0 +1,188 @@
//! `continuum-proxy enrol <cloud-url> <code> [name]`: connect this gateway to
//! a farm with the one-time code the console shows, instead of copying a
//! farm id and token between machines.
//!
//! The code is traded at `POST /auth/enrol` for the farm id and the gateway
//! token, which are written into the proxy's config file (owner-only, since
//! the token is a credential) next to whatever else it already holds. The
//! next start reads them like any other setting.
//!
//! PC mode only. A Proxy Box is configured by a signed bundle, and writing
//! its installed config from here would bypass exactly the check that exists
//! to stop that.
use std::path::Path;
use anyhow::{bail, Context};
use serde_json::Value;
pub struct Enrolment {
pub farm_id: String,
pub gateway_token: String,
}
pub const USAGE: &str = "usage: continuum-proxy enrol <cloud-url> <code> [gateway-name]\n\
\n\
Connects this gateway to a farm using the one-time code shown in the console\n\
(valid for 15 minutes, usable once). <cloud-url> is the API's address, e.g.\n\
https://api.example.com. Settings are written to the proxy's config file.";
/// The websocket address of the uplink for an API at `cloud`.
pub fn uplink_url(cloud: &str) -> anyhow::Result<String> {
let cloud = cloud.trim().trim_end_matches('/');
let rest = if let Some(rest) = cloud.strip_prefix("https://") {
format!("wss://{rest}")
} else if let Some(rest) = cloud.strip_prefix("http://") {
format!("ws://{rest}")
} else {
bail!("the cloud address must start with http:// or https:// (got '{cloud}')");
};
Ok(format!("{rest}/ws/edge/v1"))
}
pub fn parse_response(body: &str) -> anyhow::Result<Enrolment> {
let value: Value = serde_json::from_str(body).context("the cloud's answer was not JSON")?;
let field = |name: &str| {
value
.get(name)
.and_then(Value::as_str)
.filter(|v| !v.is_empty())
.map(str::to_string)
.with_context(|| format!("the cloud's answer has no '{name}'"))
};
Ok(Enrolment { farm_id: field("farmId")?, gateway_token: field("gatewayToken")? })
}
/// The config file's new text: the four enrolment settings set, everything
/// else the file already said kept. (Comments are not preserved - the file is
/// parsed and written back.)
pub fn merge_config(existing: Option<&str>, gateway_id: &str, enrolment: &Enrolment, uplink_url: &str) -> anyhow::Result<String> {
let mut table: toml::Table = match existing {
Some(text) => toml::from_str(text).context("the existing config file is not valid TOML; not touching it")?,
None => toml::Table::new(),
};
table.insert("gateway_id".into(), gateway_id.into());
table.insert("farm_id".into(), enrolment.farm_id.clone().into());
table.insert("gateway_token".into(), enrolment.gateway_token.clone().into());
table.insert("uplink_url".into(), uplink_url.into());
Ok(toml::to_string(&table)?)
}
/// Trades `code` for the farm's credentials.
pub async fn exchange(cloud: &str, code: &str) -> anyhow::Result<Enrolment> {
let url = format!("{}/auth/enrol", cloud.trim().trim_end_matches('/'));
let body = serde_json::json!({ "code": code }).to_string();
let response = reqwest::Client::new()
.post(&url)
.header("Content-Type", "application/json")
.timeout(std::time::Duration::from_secs(20))
.body(body)
.send()
.await
.with_context(|| format!("could not reach {cloud}"))?;
let status = response.status();
let text = response.text().await.unwrap_or_default();
if !status.is_success() {
let message = serde_json::from_str::<Value>(&text)
.ok()
.and_then(|v| v.pointer("/error/message").and_then(Value::as_str).map(str::to_string))
.unwrap_or_else(|| format!("HTTP {}", status.as_u16()));
bail!("the cloud refused the code: {message}");
}
parse_response(&text)
}
/// Owner-only, and replaced atomically so a crash cannot leave half a file.
fn write_private(path: &Path, text: &str) -> anyhow::Result<()> {
use std::io::Write;
let tmp = path.with_extension("tmp");
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(&tmp).with_context(|| format!("writing {tmp:?}"))?;
file.write_all(text.as_bytes())?;
file.sync_all()?;
std::fs::rename(&tmp, path).with_context(|| format!("replacing {path:?}"))?;
Ok(())
}
fn default_gateway_name() -> String {
std::env::var("HOSTNAME")
.ok()
.or_else(|| std::fs::read_to_string("/etc/hostname").ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "gateway".to_string())
}
/// The `enrol` subcommand. `args` are what follows the word `enrol`.
pub async fn run(args: &[String], bootstrap: &crate::bootstrap::Bootstrap) -> anyhow::Result<()> {
if args.iter().any(|a| a == "--help" || a == "-h") {
println!("{USAGE}");
return Ok(());
}
let [cloud, code, rest @ ..] = args else {
bail!("{USAGE}");
};
if bootstrap.mode == crate::bootstrap::Mode::ProxyBox {
bail!("this is a Proxy Box: it is configured by a signed config bundle on a pendrive, not by enrol");
}
let uplink = uplink_url(cloud)?;
let enrolment = exchange(cloud, code).await?;
let path = &bootstrap.config_path;
let existing = match std::fs::read_to_string(path) {
Ok(text) => Some(text),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
Err(err) => bail!("could not read {path:?}: {err}"),
};
let name = rest.first().cloned().unwrap_or_else(default_gateway_name);
let text = merge_config(existing.as_deref(), &name, &enrolment, &uplink)?;
write_private(path, &text)?;
println!("Enrolled gateway '{name}' in farm {}. Settings written to {path:?}.", enrolment.farm_id);
println!("Start the proxy to connect it.");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_uplink_follows_the_api_scheme() {
assert_eq!(uplink_url("https://api.example.com/").unwrap(), "wss://api.example.com/ws/edge/v1");
assert_eq!(uplink_url("http://localhost:3060").unwrap(), "ws://localhost:3060/ws/edge/v1");
assert!(uplink_url("api.example.com").is_err());
}
#[test]
fn the_answer_needs_both_fields() {
let ok = parse_response(r#"{"farmId":"f","gatewayToken":"t"}"#).unwrap();
assert_eq!((ok.farm_id.as_str(), ok.gateway_token.as_str()), ("f", "t"));
assert!(parse_response(r#"{"farmId":"f"}"#).is_err());
assert!(parse_response(r#"{"farmId":"f","gatewayToken":""}"#).is_err());
assert!(parse_response("not json").is_err());
}
#[test]
fn enrolling_keeps_the_rest_of_the_file_and_the_result_parses_as_a_config() {
let enrolment = Enrolment { farm_id: "farm-1".into(), gateway_token: "secret".into() };
let existing = "go2rtc_bin = \"/opt/go2rtc\"\ngateway_token = \"old\"\n[discovery]\nenabled = false\n";
let text = merge_config(Some(existing), "bench", &enrolment, "wss://x/ws/edge/v1").unwrap();
let parsed = crate::config::ProxyFile::parse(&text).unwrap();
assert_eq!(parsed.gateway_token.as_deref(), Some("secret"), "the new token replaces the old");
assert_eq!(parsed.farm_id.as_deref(), Some("farm-1"));
assert_eq!(parsed.gateway_id.as_deref(), Some("bench"));
assert_eq!(parsed.go2rtc_bin.as_deref(), Some("/opt/go2rtc"));
assert!(parsed.discovery.is_some());
}
#[test]
fn a_file_that_is_not_toml_is_left_alone() {
let enrolment = Enrolment { farm_id: "f".into(), gateway_token: "t".into() };
assert!(merge_config(Some("= nonsense ["), "g", &enrolment, "ws://x").is_err());
}
}
+124 -14
View File
@@ -3,9 +3,10 @@
//! by default) is gitignored: it holds real hostnames and access codes for
//! your printers, which don't belong in version control.
use std::collections::HashSet;
use std::path::Path;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use crate::printer::{
BambuPrinter, BambuProtocolPreference, BambuTls, KlipperPrinter, OctoPrintFlavour, OctoPrintPrinter,
@@ -18,10 +19,14 @@ struct FleetFile {
printers: Vec<PrinterEntry>,
}
#[derive(Debug, Deserialize)]
struct PrinterEntry {
id: String,
name: String,
/// One printer's connection details, as written in `printers.toml` or stored
/// in the local store (src/store.rs). `Debug` is written by hand below: the
/// derived one would print `access_code` and `api_key` into any log line that
/// formats an entry.
#[derive(Clone, Deserialize, Serialize)]
pub struct PrinterEntry {
pub id: String,
pub name: String,
/// One of: "bambu" (auto-detects MQTT v5-vs-v3.1.1/BambuMQTT v2-vs-v1 -
/// the default, since a firmware update can move the same physical
/// printer between them), "bambu_v1"/"bambu_v2" (force a specific one),
@@ -31,23 +36,38 @@ struct PrinterEntry {
/// flavour - see `OctoPrintFlavour`. Pick `octoprint` if it is OctoPrint
/// on a Pi and `prusa_link` if it is an MK4/XL/MINI serving its own API;
/// discovery cannot tell, so it suggests `octoprint` and says so.
vendor: String,
host: Option<String>,
access_code: Option<String>,
pub vendor: String,
pub host: Option<String>,
pub access_code: Option<String>,
/// The printer's serial number - Bambu's real MQTT topics are addressed
/// by serial (`device/{sn}/report`), not by our own `id`.
sn: Option<String>,
api_key: Option<String>,
pub sn: Option<String>,
pub api_key: Option<String>,
/// Serial device path, e.g. `/dev/ttyACM0` on Linux.
port: Option<String>,
pub port: Option<String>,
#[serde(default = "default_baud_rate")]
baud_rate: u32,
pub baud_rate: u32,
/// Path to a printer-specific CA certificate. Leave unset to use the
/// bundled Bambu CA - only needed for a printer that doesn't chain to
/// it (P1P, at least; see certs/README.md).
ca_cert_path: Option<String>,
pub ca_cert_path: Option<String>,
#[serde(default = "default_true")]
require_valid_cert: bool,
pub require_valid_cert: bool,
}
impl std::fmt::Debug for PrinterEntry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let secret = |value: &Option<String>| value.as_ref().map(|_| "<redacted>");
f.debug_struct("PrinterEntry")
.field("id", &self.id)
.field("name", &self.name)
.field("vendor", &self.vendor)
.field("host", &self.host)
.field("access_code", &secret(&self.access_code))
.field("sn", &self.sn)
.field("api_key", &secret(&self.api_key))
.finish_non_exhaustive()
}
}
fn default_true() -> bool {
@@ -71,6 +91,96 @@ pub fn load(path: &Path) -> anyhow::Result<Vec<PrinterHandle>> {
file.printers.into_iter().map(build_printer).collect()
}
/// The entries in a printers file, or none if there is no file.
///
/// A missing file is normal now: a gateway whose printers were all added from
/// the console has only the store. A file that exists but does not parse is
/// still an error - that is a typo to surface, not a fleet to ignore.
pub fn entries(path: &Path) -> anyhow::Result<Vec<PrinterEntry>> {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(err) => return Err(anyhow::anyhow!("failed to read {path:?}: {err}")),
};
Ok(toml::from_str::<FleetFile>(&text)?.printers)
}
/// Builds the client for one entry. Public so a printer added while the
/// gateway is running goes through exactly the checks a startup one does.
pub fn build(entry: PrinterEntry) -> anyhow::Result<PrinterHandle> {
build_printer(entry)
}
/// Everything the fleet file already names, for telling a printer the
/// operator has dealt with from one they have never seen.
///
/// Three sets rather than one, because the three identify a printer with
/// very different confidence. A serial is the machine. A host is the address
/// it answers on today. A device path is which USB socket it is plugged
/// into. A match on any of them is enough to mean "already known", which is
/// the conservative direction: the cost of a false match is one printer
/// missing from a suggestion list, and the cost of a false miss is the
/// console offering to add a printer that is already running jobs.
#[derive(Debug, Default)]
pub struct Configured {
pub hosts: HashSet<String>,
pub serials: HashSet<String>,
pub ports: HashSet<String>,
}
impl Configured {
/// Lowercased on both sides: hostnames are case-insensitive, and a Bambu
/// serial read from a certificate has no guaranteed case either.
pub fn mentions(&self, host: Option<&str>, serial: Option<&str>) -> bool {
let known = |set: &HashSet<String>, value: Option<&str>| {
value.is_some_and(|value| set.contains(&value.to_lowercase()))
};
known(&self.hosts, host) || known(&self.serials, serial) || known(&self.ports, host)
}
}
/// What the fleet file mentions, without building a single printer client.
///
/// Parse level on purpose, and the difference matters: [`load`] refuses an
/// entry with a blank `access_code`, but that entry is exactly the case this
/// needs to catch - a printer discovery already suggested, that the operator
/// pasted in and has not yet typed the credential for. It is not a printer
/// they have never seen, so reporting it again as a fresh find would be
/// noise at the one moment they are mid-way through dealing with it.
///
/// A missing file is an empty set rather than an error: a gateway with no
/// fleet yet is the case discovery exists for.
pub fn configured(path: &Path) -> anyhow::Result<Configured> {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Configured::default()),
Err(err) => return Err(anyhow::anyhow!("failed to read {path:?}: {err}")),
};
let file: FleetFile = toml::from_str(&text)?;
let mut out = Configured::default();
out.add_entries(file.printers);
Ok(out)
}
impl Configured {
/// Folds more entries in - the file's, and the local store's.
pub fn add_entries(&mut self, entries: impl IntoIterator<Item = PrinterEntry>) {
let add = |set: &mut HashSet<String>, value: Option<String>| {
if let Some(value) = value {
let value = value.trim().to_lowercase();
if !value.is_empty() {
set.insert(value);
}
}
};
for entry in entries {
add(&mut self.hosts, entry.host);
add(&mut self.serials, entry.sn);
add(&mut self.ports, entry.port);
}
}
}
fn build_printer(entry: PrinterEntry) -> anyhow::Result<PrinterHandle> {
match entry.vendor.as_str() {
"bambu" => build_bambu(entry, BambuProtocolPreference::Auto),
+135
View File
@@ -1,3 +1,4 @@
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
@@ -6,6 +7,76 @@ use tokio::process::Command;
use tokio::sync::Notify;
use tracing::{error, info, warn};
/// Where the running go2rtc's pid is recorded: next to its config, because
/// that is the one path this process already owns and the one a restarted
/// proxy is certain to compute again.
fn pid_path(config_path: &str) -> PathBuf {
PathBuf::from(format!("{config_path}.pid"))
}
/// Removes the pid file when the supervisor goes away, however it does:
/// the watchdog is dropped, not returned from, on a normal shutdown.
struct PidFile(PathBuf);
impl Drop for PidFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
/// Whether a `/proc/<pid>/cmdline` is the go2rtc this proxy started: the
/// configured binary as the program (or as the script an interpreter was
/// given, which is what a wrapper looks like) *and* our own config path after
/// `-config`. Both, because a pid alone proves nothing once the number has
/// been reused, and "some go2rtc" is not necessarily ours - another proxy on
/// the machine would run one against its own config.
fn is_our_go2rtc(cmdline: &[u8], bin: &str, config_path: &str) -> bool {
let args: Vec<&[u8]> = cmdline.split(|b| *b == 0).filter(|a| !a.is_empty()).collect();
let launched = args.iter().take(2).any(|a| *a == bin.as_bytes());
let configured = args.windows(2).any(|w| w[0] == b"-config" && w[1] == config_path.as_bytes());
launched && configured
}
/// Stops a go2rtc left behind by a proxy that was killed without a chance to
/// stop it (SIGKILL, the OOM killer, a power cut that kept the container).
///
/// Only acts on a pid this proxy recorded, and only after `/proc` confirms
/// that pid is still our go2rtc (`is_our_go2rtc`). Anywhere without `/proc`
/// it cannot confirm that, so it leaves the process alone: a leftover
/// go2rtc is a nuisance, killing someone else's process is not. The orphan
/// matters because it keeps :1984 and :8555 bound - so the new one cannot
/// start - and holds a printer's access code in memory.
async fn reclaim_orphan(bin: &str, config_path: &str) {
let pid_file = pid_path(config_path);
let Ok(text) = std::fs::read_to_string(&pid_file) else { return };
let _ = std::fs::remove_file(&pid_file);
let Ok(pid) = text.trim().parse::<u32>() else { return };
let ours = || {
std::fs::read(format!("/proc/{pid}/cmdline"))
.map(|cmdline| is_our_go2rtc(&cmdline, bin, config_path))
.unwrap_or(false)
};
if !ours() {
return;
}
warn!(pid, "a go2rtc left by an earlier proxy is still running; stopping it");
// The `kill` program rather than a libc call: signalling is the only
// reason this would need `unsafe` and a new dependency.
for (signal, patience) in [("-TERM", 30), ("-KILL", 30)] {
let _ = Command::new("kill").arg(signal).arg(pid.to_string()).status().await;
for _ in 0..patience {
if !ours() {
info!(pid, "the old go2rtc is gone");
return;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
error!(pid, "could not stop the old go2rtc; the new one may fail to bind its ports");
}
/// Supervises the go2rtc subprocess used for camera restreaming
/// (RTSP -> WebRTC). Restarts it with a fixed backoff whenever it exits, and
/// immediately when `restart` fires.
@@ -16,6 +87,11 @@ use tracing::{error, info, warn};
/// Before that existed this watchdog supervised a process whose config file
/// nothing wrote - it restarted go2rtc forever over a file that did not exist.
pub async fn watchdog(bin: String, config_path: String, restart: Arc<Notify>) {
// Before the first spawn: whatever the last proxy left behind owns the
// ports this one is about to ask for.
reclaim_orphan(&bin, &config_path).await;
let _pid_file = PidFile(pid_path(&config_path));
loop {
info!(%bin, %config_path, "starting go2rtc");
@@ -29,6 +105,9 @@ pub async fn watchdog(bin: String, config_path: String, restart: Arc<Notify>) {
match spawned {
Ok(mut child) => {
if let Some(Err(err)) = child.id().map(|pid| std::fs::write(pid_path(&config_path), pid.to_string())) {
warn!(?err, "could not record go2rtc's pid; a hard kill of the proxy would orphan it unnoticed");
}
tokio::select! {
// Whichever happens first. `kill_on_drop` means the
// child dies when `child` goes out of scope at the end
@@ -61,3 +140,59 @@ pub async fn watchdog(bin: String, config_path: String, restart: Arc<Notify>) {
tokio::time::sleep(Duration::from_secs(5)).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cmdline(args: &[&str]) -> Vec<u8> {
args.iter().flat_map(|a| a.bytes().chain([0])).collect()
}
#[test]
fn only_our_binary_on_our_config_counts() {
let ours = cmdline(&["go2rtc", "-config", "./go2rtc.yaml"]);
assert!(is_our_go2rtc(&ours, "go2rtc", "./go2rtc.yaml"));
// A wrapper script puts the interpreter first.
assert!(is_our_go2rtc(&cmdline(&["/bin/sh", "go2rtc", "-config", "./go2rtc.yaml"]), "go2rtc", "./go2rtc.yaml"));
// Another proxy's go2rtc, an unrelated process, and a reused pid.
assert!(!is_our_go2rtc(&cmdline(&["go2rtc", "-config", "/other.yaml"]), "go2rtc", "./go2rtc.yaml"));
assert!(!is_our_go2rtc(&cmdline(&["sleep", "-config", "./go2rtc.yaml"]), "go2rtc", "./go2rtc.yaml"));
assert!(!is_our_go2rtc(&cmdline(&["vim", "go2rtc.yaml"]), "go2rtc", "./go2rtc.yaml"));
assert!(!is_our_go2rtc(b"", "go2rtc", "./go2rtc.yaml"), "a zombie has an empty cmdline");
}
/// A stand-in go2rtc that runs until killed, started the way the real
/// one is, whose pid file is then found by a "restarted proxy".
#[cfg(target_os = "linux")]
#[tokio::test]
async fn a_leftover_go2rtc_is_stopped_and_a_stranger_is_not() {
let dir = std::env::temp_dir().join(format!("continuum-go2rtc-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let script = dir.join("go2rtc");
// Stays alive until signalled, and (run as `sh <script> -config ..`)
// looks to /proc the way the real one does behind an interpreter.
std::fs::write(&script, "#!/bin/sh\nwhile :; do sleep 1; done\n").unwrap();
let (bin, config) = (script.to_string_lossy().into_owned(), dir.join("go2rtc.yaml").to_string_lossy().into_owned());
let mut orphan = std::process::Command::new("/bin/sh").args([&bin, "-config", &config]).spawn().unwrap();
let stranger = std::process::Command::new("sleep").arg("60").spawn().unwrap();
// The stranger's pid in the file: refused, and the file is cleared.
std::fs::write(pid_path(&config), stranger.id().to_string()).unwrap();
reclaim_orphan(&bin, &config).await;
assert!(std::fs::read(format!("/proc/{}/cmdline", stranger.id())).is_ok_and(|c| !c.is_empty()), "an unrelated process must survive");
assert!(!pid_path(&config).exists());
// The orphan's pid: stopped.
std::fs::write(pid_path(&config), orphan.id().to_string()).unwrap();
reclaim_orphan(&bin, &config).await;
let status = orphan.wait().unwrap();
assert!(!status.success(), "the leftover go2rtc was terminated by a signal");
let mut stranger = stranger;
let _ = stranger.kill();
let _ = stranger.wait();
let _ = std::fs::remove_dir_all(&dir);
}
}
+185
View File
@@ -0,0 +1,185 @@
//! Following the jobs the cloud has handed this gateway, so their outcomes
//! reach the cloud even if it was unreachable when they happened.
//!
//! The cloud owns the queue; this only reports. When a `start_print` is
//! accepted the job is recorded against its printer, and each tick the
//! printer's state is compared with it. What the cloud should hear -
//! started, finished, failed - goes into the store's outbox, and the uplink
//! re-sends the outbox on every connection until the cloud acknowledges each
//! event. An outcome reached during an outage, or before a restart, is
//! therefore still delivered, instead of the cloud guessing from whatever
//! telemetry it happens to see next.
use std::time::Duration;
use tracing::{info, warn};
use crate::printer::GenericPrinter;
use crate::store::{ActiveJob, Store};
use crate::uplink::SharedFleet;
const TICK: Duration = Duration::from_secs(5);
/// A job whose printer never reported printing within this long is given up on.
const NEVER_STARTED_AFTER_MS: i64 = 30 * 60 * 1000;
pub fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
#[derive(Debug, PartialEq)]
pub enum Step {
Nothing,
Started,
Finished,
Failed(String),
}
/// What a printer's reported state means for the job it was handed.
///
/// Nothing is concluded from a state until the job has been seen running: a
/// printer that has just been told to start still reports the *previous*
/// job's `FINISH` (or `FAILED`) for a while, and reading that as this job's
/// outcome would report a print that has not begun as done.
pub fn next_step(job: &ActiveJob, state: Option<&str>, now_ms: i64) -> Step {
let state = state.map(str::to_ascii_uppercase);
let running = matches!(state.as_deref(), Some("RUNNING" | "PAUSE" | "PREPARE" | "SLICING"));
if !job.started {
if running {
return Step::Started;
}
if now_ms - job.since_ms > NEVER_STARTED_AFTER_MS {
return Step::Failed("the printer never started printing it".into());
}
return Step::Nothing;
}
match state.as_deref() {
Some("FINISH") => Step::Finished,
Some("FAILED") => Step::Failed("the printer reported a fault".into()),
// Not a finish, not a fault, and no longer printing: stopped on the
// machine itself. Klipper and OctoPrint report a cancelled print as
// idle.
Some("IDLE") => Step::Failed("the printer stopped without finishing it".into()),
_ => Step::Nothing,
}
}
/// The gateway accepted a `start_print` for `job_id`.
pub fn accepted(store: &Store, printer_id: &str, job_id: &str) {
if let Err(err) = store.active_job_set(printer_id, job_id, now_ms()) {
warn!(%err, %printer_id, %job_id, "could not record the job against its printer");
}
}
/// The accepted start then failed (download, transfer, start).
pub fn start_failed(store: &Store, printer_id: &str, job_id: &str, reason: &str) {
if let Err(err) = store.outbox_push(job_id, "failed", Some(reason), now_ms()) {
warn!(%err, %job_id, "could not queue the failure for the cloud");
}
let _ = store.active_job_clear(printer_id);
}
pub async fn run(fleet: SharedFleet, store: std::sync::Arc<Store>) {
let mut ticker = tokio::time::interval(TICK);
loop {
ticker.tick().await;
let Ok(active) = store.active_jobs() else { continue };
if active.is_empty() {
continue;
}
let states: Vec<(ActiveJob, Option<String>)> = {
let fleet = fleet.lock().await;
let mut out = Vec::new();
for job in active {
let state = match fleet.iter().find(|p| p.base().id == job.printer_id) {
Some(printer) => printer.status().await.and_then(|s| s.state),
None => None,
};
out.push((job, state));
}
out
};
for (job, state) in states {
apply(&store, &job, next_step(&job, state.as_deref(), now_ms()));
}
}
}
fn apply(store: &Store, job: &ActiveJob, step: Step) {
let result = match &step {
Step::Nothing => return,
Step::Started => store
.outbox_push(&job.job_id, "started", None, now_ms())
.and_then(|_| store.active_job_started(&job.printer_id)),
Step::Finished => store
.outbox_push(&job.job_id, "finished", None, now_ms())
.and_then(|_| store.active_job_clear(&job.printer_id)),
Step::Failed(reason) => store
.outbox_push(&job.job_id, "failed", Some(reason), now_ms())
.and_then(|_| store.active_job_clear(&job.printer_id)),
};
match result {
Ok(_) => info!(printer = %job.printer_id, job = %job.job_id, ?step, "job event queued for the cloud"),
Err(err) => warn!(%err, job = %job.job_id, "could not queue a job event"),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn job(started: bool, since_ms: i64) -> ActiveJob {
ActiveJob { printer_id: "p1".into(), job_id: "j1".into(), since_ms, started }
}
#[test]
fn a_stale_finish_from_the_previous_job_is_not_this_jobs_outcome() {
assert_eq!(next_step(&job(false, 0), Some("FINISH"), 10_000), Step::Nothing);
assert_eq!(next_step(&job(false, 0), Some("FAILED"), 10_000), Step::Nothing);
assert_eq!(next_step(&job(false, 0), Some("IDLE"), 10_000), Step::Nothing);
}
#[test]
fn running_marks_it_started_and_then_finish_finishes_it() {
assert_eq!(next_step(&job(false, 0), Some("RUNNING"), 10_000), Step::Started);
assert_eq!(next_step(&job(true, 0), Some("RUNNING"), 10_000), Step::Nothing);
assert_eq!(next_step(&job(true, 0), Some("PAUSE"), 10_000), Step::Nothing);
assert_eq!(next_step(&job(true, 0), Some("FINISH"), 10_000), Step::Finished);
}
#[test]
fn a_fault_or_a_stop_after_starting_is_a_failure() {
assert!(matches!(next_step(&job(true, 0), Some("FAILED"), 1), Step::Failed(_)));
assert!(matches!(next_step(&job(true, 0), Some("IDLE"), 1), Step::Failed(_)));
}
#[test]
fn a_printer_that_goes_quiet_leaves_the_job_alone() {
assert_eq!(next_step(&job(true, 0), None, 1), Step::Nothing);
}
#[test]
fn a_job_that_never_starts_is_eventually_given_up_on() {
assert_eq!(next_step(&job(false, 0), Some("IDLE"), NEVER_STARTED_AFTER_MS), Step::Nothing);
assert!(matches!(next_step(&job(false, 0), Some("IDLE"), NEVER_STARTED_AFTER_MS + 1), Step::Failed(_)));
}
#[test]
fn applying_steps_fills_the_outbox_and_clears_the_active_job() {
let dir = std::env::temp_dir().join(format!("continuum-jobs-test-{}", std::process::id()));
let store = Store::open(&dir.join("s.db")).unwrap();
accepted(&store, "p1", "j1");
let active = store.active_jobs().unwrap().remove(0);
apply(&store, &active, Step::Started);
let active = store.active_jobs().unwrap().remove(0);
assert!(active.started);
apply(&store, &active, Step::Finished);
assert!(store.active_jobs().unwrap().is_empty());
let events: Vec<_> = store.outbox_pending().unwrap().into_iter().map(|e| e.event).collect();
assert_eq!(events, ["started", "finished"]);
let _ = std::fs::remove_dir_all(dir);
}
}
+6
View File
@@ -8,10 +8,16 @@
//! should serve is not - that is `camera`, which reads the fleet and is worth
//! testing, so it lives here.
pub mod bootstrap;
pub mod camera;
pub mod completion;
pub mod config;
pub mod config_import;
pub mod enrol;
pub mod discovery;
pub mod fleet;
pub mod jobs;
pub mod printer;
pub mod provision;
pub mod store;
pub mod uplink;
+137 -12
View File
@@ -9,9 +9,11 @@ mod go2rtc;
use tracing::info;
use tracing_subscriber::EnvFilter;
use continuum_proxy::bootstrap::{self, Bootstrap};
use continuum_proxy::config::Config;
use continuum_proxy::config_import;
use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
use continuum_proxy::{camera, completion, fleet, uplink};
use continuum_proxy::{camera, completion, fleet, jobs, uplink};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
@@ -20,14 +22,83 @@ async fn main() -> anyhow::Result<()> {
.init();
dotenvy::dotenv().ok();
let config = Config::from_env()?;
// `continuum-proxy enrol ...` connects this gateway to a farm and exits;
// it needs the bootstrap (for where the config file is) but no settings.
let args: Vec<String> = std::env::args().skip(1).collect();
if args.first().map(String::as_str) == Some("enrol") {
return continuum_proxy::enrol::run(&args[1..], &Bootstrap::discover()?).await;
}
// Order matters: import first, so a stick present at boot is already
// the installed config by the time it is read. A rejected stick is
// logged inside and does not stop startup - the box runs on what it has.
let bootstrap = Bootstrap::discover()?;
// Plaintext bundles are refused on a Proxy Box and signatures are
// mandatory once a signing key is provisioned; see the config_import
// module docs. An unprovisioned box is allowed to start - it runs on its
// installed config - but says loudly what it cannot check.
//
// Loaded only on a box: on a PC nothing imports from removable media, and
// a stray or malformed file under /etc/continuum must not stop a PC user's
// proxy starting over provisioning it never uses.
let policy = if bootstrap.mode == bootstrap::Mode::ProxyBox {
config_import::load_provisioning()?
} else {
config_import::BundlePolicy::unencrypted_only()
};
if bootstrap.mode == bootstrap::Mode::ProxyBox {
if policy.signing_keys.is_empty() {
tracing::warn!("no signing key provisioned: config bundles will NOT be authenticated");
}
if policy.box_id.is_none() {
tracing::warn!("no box id provisioned: signed config bundles cannot be accepted");
}
}
for outcome in config_import::import_from_pendrives(&bootstrap, &policy) {
config_import::log_outcome(&outcome);
}
let file = bootstrap.load_config_file()?;
let config = Config::from_file_and_env(file.as_ref())?;
info!(mode = ?bootstrap.mode, config_file = ?file.as_ref().map(|_| &bootstrap.config_path), "configuration loaded");
// BOUNDARY: network settings are parsed and validated, and stop here.
// Applying them (static IP, DNS, wifi) is the appliance image's job, not
// this process's - see the module docs of `config`. Logged so that a box
// given a network block does not look like it acted on it.
if let Some(network) = file.as_ref().and_then(|f| f.network.as_ref()) {
tracing::warn!(?network, "network settings are present but this build does NOT apply them");
}
tracing::info!(gateway_id = %config.gateway_id, farm_id = %config.farm_id, "starting continuum-proxy");
// Loaded and connected here, once, at startup - nothing yet re-reads
// printers.toml on a running daemon, so adding or removing a printer
// still means a restart.
let mut fleet = fleet::load(std::path::Path::new(&config.printers_path))?;
// Two sources, one fleet: `printers.toml` (a hand-written file - strict,
// a typo stops startup) and the local store (printers added from the
// console, which were checked before they were saved). The store is
// opened first and is what lets the gateway come up with no file at all.
let store = std::sync::Arc::new(continuum_proxy::store::Store::open(std::path::Path::new(&config.store_path))?);
let mut fleet = Vec::new();
for entry in fleet::entries(std::path::Path::new(&config.printers_path))? {
fleet.push(fleet::build(entry)?);
}
for entry in store.printers()? {
if fleet.iter().any(|printer| printer.base().id == entry.id) {
tracing::warn!(printer = %entry.id, "in both printers.toml and the local store; using printers.toml");
continue;
}
// Skipped, not fatal: one entry the build no longer accepts should
// not keep the rest of a farm offline.
match fleet::build(entry) {
Ok(printer) => fleet.push(printer),
Err(err) => tracing::error!(%err, "a printer in the local store could not be built; skipping it"),
}
}
if fleet.is_empty() {
tracing::warn!("no printers yet - add one from the console, or list some in printers.toml");
}
connect_fleet(&mut fleet).await;
// The fleet is now shared with the uplink rather than just parked here
@@ -37,13 +108,18 @@ async fn main() -> anyhow::Result<()> {
// see `uplink::SharedFleet` for why that type and what it costs.
let fleet: uplink::SharedFleet = std::sync::Arc::new(tokio::sync::Mutex::new(fleet));
// Four long-running tasks: the cloud uplink, the go2rtc subprocess
// watchdog, the camera-config builder that tells it what to serve, and
// the print-completion watcher that blinks a printer's lights when it
// finishes. Every printer's MQTT session is a fifth thing running
// concurrently, but implicitly - each one has its own background poll
// task spawned by `connect()`, kept alive by the fleet above. More will
// join these later (printer discovery, the plate changer).
// Five long-running tasks: the cloud uplink, the go2rtc subprocess
// watchdog, the camera-config builder that tells it what to serve, the
// print-completion watcher that blinks a printer's lights when it
// finishes, and printer discovery. Every printer's MQTT session is a
// sixth thing running concurrently, but implicitly - each one has its own
// background poll task spawned by `connect()`, kept alive by the fleet
// above. More will join these later (the plate changer).
//
// Discovery parks on a future that never completes unless it is switched
// on, which it is not by default - see `discovery::report::run`. So this
// arm is usually a task that costs nothing, and that is the intended
// steady state, not a disabled feature waiting to be finished.
//
// The completion watcher is deliberately its own task rather than part
// of the uplink: it must keep working with the cloud connection down,
@@ -61,7 +137,7 @@ async fn main() -> anyhow::Result<()> {
let restart_go2rtc = std::sync::Arc::new(tokio::sync::Notify::new());
// Built once, here, rather than inside the rebuild loop: the config is
// re-rendered every 30s and compared byte-for-byte to decide whether to
// re-rendered every few seconds and compared byte-for-byte to decide whether to
// restart go2rtc, so a credential that changed per render would restart
// it - and drop every viewer - on every tick.
let camera_auth = camera::ApiAuth::new()?;
@@ -71,8 +147,30 @@ async fn main() -> anyhow::Result<()> {
"go2rtc's API requires basic auth; the password is in its config file"
);
// Created here rather than inside the uplink so discovery can hand it
// messages without the uplink knowing what discovery is. It outlives any
// one connection, so a scan that finishes while the cloud is unreachable
// waits for the next socket instead of being thrown away.
let (outbound, inbox) = uplink::channel();
tokio::select! {
_ = uplink::run(config.clone(), fleet.clone()) => {}
// Ends the process through a normal return, so the supervisors below
// are dropped and go2rtc's `kill_on_drop` actually fires. Without this
// arm SIGTERM - what `kill`, `docker stop` and a service manager all
// send - ends the process on the spot with no destructor run, and
// go2rtc is orphaned: it keeps :1984 and :8555 bound, so the restarted
// proxy cannot start its own, and it keeps a printer's access code in
// memory. Found when a test run left one behind.
_ = shutdown_requested() => {
info!("stop requested; exiting so go2rtc is stopped with us");
}
_ = uplink::run(config.clone(), fleet.clone(), Some(store.clone()), outbound.clone(), inbox) => {}
_ = continuum_proxy::discovery::report::run(
config.discovery.clone(),
config.printers_path.clone(),
Some(store.clone()),
outbound.clone(),
) => {}
_ = go2rtc::watchdog(
config.go2rtc_bin.clone(),
config.go2rtc_config.clone(),
@@ -86,11 +184,38 @@ async fn main() -> anyhow::Result<()> {
restart_go2rtc.clone(),
) => {}
_ = completion::run(fleet.clone()) => {}
_ = jobs::run(fleet.clone(), store.clone()) => {}
// Returns only after installing a new config from a stick inserted
// while running. Settings are read once at startup, so the process
// ends here and relies on its supervisor (systemd `Restart=always`
// on a box) to start it again on the new config. Not exercised end
// to end - there is no supervisor or real stick in the dev
// environment.
_ = config_import::watch(bootstrap.clone(), policy.clone()) => {
info!("exiting so the supervisor restarts the proxy on the newly installed config");
}
}
Ok(())
}
/// Resolves when the process is asked to stop: SIGTERM or SIGINT on Unix,
/// Ctrl-C elsewhere.
async fn shutdown_requested() {
#[cfg(unix)]
{
use tokio::signal::unix::{SignalKind, signal};
if let Ok(mut terminate) = signal(SignalKind::terminate()) {
tokio::select! {
_ = terminate.recv() => {}
_ = tokio::signal::ctrl_c() => {}
}
return;
}
}
let _ = tokio::signal::ctrl_c().await;
}
/// Connects every configured printer concurrently (not one at a time -
/// Bambu's auto-detect fallback alone can take several seconds per
/// printer if it has to time out on MQTT v5 before trying v3.1.1;
+61 -2
View File
@@ -207,6 +207,28 @@ pub enum BambuProtocolPreference {
}
// endregion: BambuMQTTProtocol
/// One pending camera read for a P1/A1; see `bambu_camera`. Holds the access
/// code, so it has no `Debug`.
pub struct SnapshotJob {
name: String,
host: String,
access_code: String,
connector: native_tls::TlsConnector,
}
impl SnapshotJob {
/// One JPEG frame from the printer's port-6000 camera.
pub async fn run(self) -> Result<Vec<u8>, PrinterError> {
let name = self.name;
tokio::task::spawn_blocking(move || {
super::bambu_camera::snapshot(&self.host, &self.access_code, self.connector, std::time::Duration::from_secs(8))
})
.await
.map_err(|err| PrinterError(format!("{name}: camera thread failed: {err}")))?
.map_err(|err| PrinterError(format!("{name}: camera: {err}")))
}
}
/// A Bambu printer. Only one struct now, not two: the old
/// `BambuGenericPrinter` (shared fields/behavior) + `BambuV1Printer`/
/// `BambuV2Printer` (thin per-version wrappers) split existed to let two
@@ -224,6 +246,8 @@ pub struct BambuPrinter {
/// alongside the protocol version it settled on, since the two are
/// decided together and always used together - see `bambu_mqtt.rs`.
connection: Option<(super::bambu_mqtt::BambuMqttSession, BambuProtocolVersion)>,
/// See `set_ams_slot_override`.
ams_slot_override: Option<u8>,
}
/// Which plate of a multi-plate project gets printed.
@@ -469,9 +493,17 @@ impl BambuPrinter {
tls,
preference,
connection: None,
ams_slot_override: None,
}
}
/// The operator's chosen AMS slot for the next print (0-based across all
/// units), or `None` to map by the project's own filament. Set by the
/// uplink around one `start_print` and cleared after it.
pub fn set_ams_slot_override(&mut self, slot: Option<u8>) {
self.ams_slot_override = slot;
}
pub fn base(&self) -> &PrinterBase {
&self.base
}
@@ -736,7 +768,8 @@ impl BambuPrinter {
.map_err(|err| PrinterError(format!("failed to build pushall request: {err}")))?;
body["push_target"] = serde_json::Value::from(1);
session.publish("pushing", &body).await
// Unacked: nothing waits on a reply to this, and see `publish_unacked`.
session.publish_unacked("pushing", &body).await
}
/// Just the FTPS transfer: puts `data` in the printer's `cache/`
@@ -852,7 +885,18 @@ impl BambuPrinter {
// naming a filament that also happens to be on the spool should
// still use the AMS, because that is what it was sliced for.
if !trays.is_empty() {
match super::map_to_trays(&filaments, &trays) {
let mapped = match self.ams_slot_override {
Some(slot) => super::map_to_slot(&filaments, &trays, i32::from(slot)),
None => super::map_to_trays(&filaments, &trays),
};
// An explicit slot that cannot be honoured is an error, never a
// fallback to some other spool.
if self.ams_slot_override.is_some() {
if let Err(err) = &mapped {
return Err(PrinterError(format!("{}: {err}", self.base.name)));
}
}
match mapped {
Ok(mapping) => {
tracing::info!(
printer = %self.base.name,
@@ -934,6 +978,21 @@ impl BambuPrinter {
.map_err(|err| PrinterError(format!("{}: FTPS download failed: {err}", self.base.name)))
}
/// Everything needed to fetch one camera frame, owned, so the caller can
/// drop the fleet lock before the network round trip.
pub fn snapshot_job(&self) -> Result<SnapshotJob, PrinterError> {
let connector = self
.tls
.build_connector()
.map_err(|err| PrinterError(format!("{}: TLS setup failed: {err}", self.base.name)))?;
Ok(SnapshotJob {
name: self.base.name.clone(),
host: self.base.host.clone(),
access_code: self.access_code.clone(),
connector,
})
}
/// Removes one file from the printer's SD card.
///
/// Takes one explicit path, because the convenient version of this is
+134
View File
@@ -0,0 +1,134 @@
//! The camera on a Bambu P1 or A1, which is not RTSP.
//!
//! Those models publish no `rtsp_url` and refuse port 322 (confirmed on a
//! real P1P, 2026-10-15). What they have instead is a proprietary stream on
//! TCP 6000: TLS, a single 80-byte login packet, then a continuous run of
//! JPEG frames, each behind a 16-byte header.
//!
//! This reads **one frame** and hangs up. A browser cannot play that stream
//! (it is MJPEG over a custom framing, and go2rtc cannot turn it into WebRTC
//! without ffmpeg, which a gateway does not have), so the console shows a
//! refreshing picture instead and a one-shot read is all it needs.
//!
//! Same blocking-TLS shape as `bambu_ftps`: the caller hands in the printer's
//! own `native_tls` connector, so every trust decision stays in
//! `BambuTls::build_connector`.
use std::io::{Read, Write};
use std::net::ToSocketAddrs;
use std::time::Duration;
pub const PORT: u16 = 6000;
/// Same username as MQTT and FTPS.
const USER: &str = "bblp";
/// A frame this large is a corrupt header, not a picture. Real frames from a
/// P1P are tens of kilobytes.
const MAX_FRAME: usize = 4 * 1024 * 1024;
/// The 80-byte login: four little-endian u32s (0x40, 0x3000, 0, 0), then the
/// username and the access code, each zero-padded to 32 bytes.
fn auth_packet(access_code: &str) -> [u8; 80] {
let mut packet = [0u8; 80];
packet[0..4].copy_from_slice(&0x40u32.to_le_bytes());
packet[4..8].copy_from_slice(&0x3000u32.to_le_bytes());
let user = USER.as_bytes();
packet[16..16 + user.len()].copy_from_slice(user);
// An access code is 8 characters; truncating anything longer keeps the
// packet shape rather than overrunning into nothing.
let code = access_code.as_bytes();
let n = code.len().min(32);
packet[48..48 + n].copy_from_slice(&code[..n]);
packet
}
/// Reads one frame: a 16-byte header whose first u32 is the payload length,
/// then that many bytes, which must be a JPEG.
fn read_frame<R: Read>(stream: &mut R) -> anyhow::Result<Vec<u8>> {
let mut header = [0u8; 16];
stream
.read_exact(&mut header)
.map_err(|err| anyhow::anyhow!("the camera sent no frame (wrong access code, or the stream is in use?): {err}"))?;
let len = u32::from_le_bytes([header[0], header[1], header[2], header[3]]) as usize;
if !(4..=MAX_FRAME).contains(&len) {
anyhow::bail!("the camera announced a {len}-byte frame, which is not a picture");
}
let mut jpeg = vec![0u8; len];
stream.read_exact(&mut jpeg)?;
if jpeg[..2] != [0xFF, 0xD8] || jpeg[len - 2..] != [0xFF, 0xD9] {
anyhow::bail!("the camera's frame is not a complete JPEG");
}
Ok(jpeg)
}
/// Connects, logs in and returns the first frame. Blocking; call it from
/// `spawn_blocking`.
pub fn snapshot(
host: &str,
access_code: &str,
connector: native_tls::TlsConnector,
timeout: Duration,
) -> anyhow::Result<Vec<u8>> {
let addr = (host, PORT)
.to_socket_addrs()?
.next()
.ok_or_else(|| anyhow::anyhow!("{host} did not resolve"))?;
let tcp = std::net::TcpStream::connect_timeout(&addr, timeout)
.map_err(|err| anyhow::anyhow!("{host}:{PORT} did not accept a connection: {err}"))?;
tcp.set_read_timeout(Some(timeout))?;
tcp.set_write_timeout(Some(timeout))?;
let mut tls = connector
.connect(host, tcp)
.map_err(|err| anyhow::anyhow!("TLS handshake with the camera failed: {err}"))?;
tls.write_all(&auth_packet(access_code))?;
tls.flush()?;
read_frame(&mut tls)
}
#[cfg(test)]
mod tests {
use super::*;
fn frame(payload: &[u8]) -> Vec<u8> {
let mut out = (payload.len() as u32).to_le_bytes().to_vec();
out.extend_from_slice(&[0; 12]);
out.extend_from_slice(payload);
out
}
#[test]
fn the_login_packet_has_the_documented_layout() {
let p = auth_packet("12345678");
assert_eq!(&p[0..8], &[0x40, 0, 0, 0, 0, 0x30, 0, 0]);
assert_eq!(&p[8..16], &[0; 8]);
assert_eq!(&p[16..20], b"bblp");
assert!(p[20..48].iter().all(|b| *b == 0));
assert_eq!(&p[48..56], b"12345678");
assert!(p[56..].iter().all(|b| *b == 0));
}
#[test]
fn a_whole_jpeg_frame_is_returned() {
let jpeg = [0xFF, 0xD8, 1, 2, 3, 0xFF, 0xD9];
let mut input = frame(&jpeg).into_iter().chain([9, 9, 9]).collect::<Vec<_>>();
assert_eq!(read_frame(&mut input.as_slice()).unwrap(), jpeg);
input.clear();
}
#[test]
fn nonsense_is_refused_not_returned() {
// not a JPEG
assert!(read_frame(&mut frame(&[1, 2, 3, 4]).as_slice()).is_err());
// length beyond the cap
let mut huge = (u32::MAX).to_le_bytes().to_vec();
huge.extend_from_slice(&[0; 12]);
assert!(read_frame(&mut huge.as_slice()).is_err());
// truncated
let mut cut = frame(&[0xFF, 0xD8, 0, 0xFF, 0xD9]);
cut.truncate(cut.len() - 2);
assert!(read_frame(&mut cut.as_slice()).is_err());
// nothing at all (what a wrong access code looks like)
assert!(read_frame(&mut [].as_slice()).is_err());
}
}
+9 -1
View File
@@ -160,7 +160,11 @@ impl PrintReport {
self.envelope = newer.envelope;
self.msg = newer.msg;
self.ams = newer.ams.or(self.ams.take());
// A partial `ams` object (no unit list) must not wipe the units already known.
self.ams = match newer.ams {
Some(partial) if partial.ams.is_empty() => self.ams.take(),
other => other.or(self.ams.take()),
};
self.ams_exist_bits = newer.ams_exist_bits.or(self.ams_exist_bits.take());
self.insert_flag = newer.insert_flag.or(self.insert_flag.take());
self.power_on_flag = newer.power_on_flag.or(self.power_on_flag.take());
@@ -244,6 +248,10 @@ pub struct LightReport {
/// actually sends.
#[derive(serde::Deserialize, Debug, Clone)]
pub struct AmsState {
/// Absent from the partial `ams` objects a P1P pushes mid-print (found on
/// the first real dispatch, 2026-10-29: every update was dropped as
/// "missing field `ams`" and the job could never complete).
#[serde(default)]
pub ams: Vec<AmsUnit>,
}
+33 -10
View File
@@ -76,6 +76,9 @@ use super::PrinterError;
type PendingTable = Arc<Mutex<HashMap<String, oneshot::Sender<Value>>>>;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
/// How long a publish may wait for room in the MQTT request queue.
const PUBLISH_TIMEOUT: Duration = Duration::from_secs(10);
/// Both rumqttc clients default to a 10KB max packet size in *both*
/// directions - fine for a request, nowhere near enough for a reply:
/// confirmed live against a real H2C, whose `get_version` reply alone
@@ -359,18 +362,38 @@ impl BambuMqttSession {
/// `publish` above and `GenericPrinter::publish` (a raw pass-through,
/// no envelope opinion at all) are built on.
pub async fn publish_raw(&self, payload: String) -> Result<(), PrinterError> {
self.publish_with_qos(payload, true).await
}
/// Like `publish`, but QoS 0: for requests that need no acknowledgement
/// (a status refresh). QoS 1 publishes the printer never acknowledges
/// fill rumqttc's in-flight window, after which every publish blocks
/// forever - found on the first real dispatch, where that froze the whole
/// proxy about 4.5 minutes in, because callers hold the fleet lock.
pub async fn publish_unacked(&self, category: &str, body: &Value) -> Result<(), PrinterError> {
self.publish_with_qos(serde_json::json!({ category: body }).to_string(), false).await
}
async fn publish_with_qos(&self, payload: String, acked: bool) -> Result<(), PrinterError> {
let topic = format!("device/{}/request", self.sn);
match &self.client {
BambuMqttClient::V1(client) => client
.publish(topic, QoS::AtLeastOnce, false, payload)
.await
.map_err(|err| PrinterError(format!("publish failed: {err}"))),
BambuMqttClient::V2(client) => client
.publish(topic, QoSV5::AtLeastOnce, false, payload)
.await
.map_err(|err| PrinterError(format!("publish failed: {err}"))),
}
// Bounded: a publish waits for room in rumqttc's request queue, and an
// unbounded wait here wedges every caller that holds the fleet lock.
let publishing = async {
match &self.client {
BambuMqttClient::V1(client) => client
.publish(topic, if acked { QoS::AtLeastOnce } else { QoS::AtMostOnce }, false, payload)
.await
.map_err(|err| PrinterError(format!("publish failed: {err}"))),
BambuMqttClient::V2(client) => client
.publish(topic, if acked { QoSV5::AtLeastOnce } else { QoSV5::AtMostOnce }, false, payload)
.await
.map_err(|err| PrinterError(format!("publish failed: {err}"))),
}
};
tokio::time::timeout(PUBLISH_TIMEOUT, publishing)
.await
.map_err(|_| PrinterError(format!("{} publish timed out after {PUBLISH_TIMEOUT:?} - the MQTT queue is not draining", self.sn)))?
}
/// The current in-memory `print.push_status` object, if at least one
+202
View File
@@ -0,0 +1,202 @@
//! The HTTP plumbing the REST-speaking vendors (Klipper's Moonraker,
//! OctoPrint, PrusaLink) share, so each driver is only its own endpoints.
//!
//! reqwest is built without its `json` and `multipart` features (see
//! Cargo.toml), so bodies are made by hand here: JSON through `serde_json`
//! and the one multipart upload as a few lines of bytes.
use std::time::Duration;
use reqwest::Method;
use serde_json::Value;
use super::PrinterError;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
/// A project file over a Pi's Wi-Fi can be tens of megabytes.
const UPLOAD_TIMEOUT: Duration = Duration::from_secs(600);
#[derive(Clone)]
pub struct Http {
client: reqwest::Client,
base: String,
api_key: Option<String>,
}
/// `host` as the operator wrote it: a bare `192.168.0.9`, `host:port`, or a
/// full `http(s)://` URL. A bare host gets `default_port`.
fn base_url(host: &str, default_port: u16) -> String {
let host = host.trim().trim_end_matches('/');
if host.starts_with("http://") || host.starts_with("https://") {
host.to_string()
} else if host.contains(':') {
format!("http://{host}")
} else {
format!("http://{host}:{default_port}")
}
}
impl Http {
pub fn new(host: &str, default_port: u16, api_key: Option<&str>) -> Self {
Self {
client: reqwest::Client::new(),
base: base_url(host, default_port),
api_key: api_key.filter(|k| !k.is_empty()).map(str::to_string),
}
}
async fn send(
&self,
method: Method,
path: &str,
content_type: Option<&str>,
extra_headers: &[(&str, &str)],
body: Option<Vec<u8>>,
timeout: Duration,
) -> Result<Value, PrinterError> {
let url = format!("{}{path}", self.base);
let mut request = self.client.request(method.clone(), &url).timeout(timeout);
if let Some(key) = &self.api_key {
request = request.header("X-Api-Key", key);
}
if let Some(content_type) = content_type {
request = request.header("Content-Type", content_type);
}
for (name, value) in extra_headers {
request = request.header(*name, *value);
}
if let Some(body) = body {
request = request.body(body);
}
// The URL is left out of errors on purpose: it is the printer's
// address, which the caller already names.
let response = request.send().await.map_err(|err| PrinterError(format!("{method} {path}: {err}")))?;
let status = response.status();
let bytes = response.bytes().await.map_err(|err| PrinterError(format!("{method} {path}: {err}")))?;
if !status.is_success() {
let text = String::from_utf8_lossy(&bytes);
let text: String = text.trim().chars().take(200).collect();
return Err(PrinterError(format!("{method} {path}: HTTP {} {text}", status.as_u16())));
}
// An empty body (204, or a 201 with nothing) is a success, not a
// parse error; so is a non-JSON one, which only the callers that
// read the answer care about.
Ok(serde_json::from_slice(&bytes).unwrap_or(Value::Null))
}
pub async fn get_json(&self, path: &str) -> Result<Value, PrinterError> {
self.send(Method::GET, path, None, &[], None, REQUEST_TIMEOUT).await
}
pub async fn post_json(&self, path: &str, body: &Value) -> Result<Value, PrinterError> {
let body = body.to_string().into_bytes();
self.send(Method::POST, path, Some("application/json"), &[], Some(body), REQUEST_TIMEOUT).await
}
pub async fn post_empty(&self, path: &str) -> Result<Value, PrinterError> {
self.send(Method::POST, path, None, &[], None, REQUEST_TIMEOUT).await
}
pub async fn put_empty(&self, path: &str) -> Result<Value, PrinterError> {
self.send(Method::PUT, path, None, &[], None, REQUEST_TIMEOUT).await
}
pub async fn delete(&self, path: &str) -> Result<Value, PrinterError> {
self.send(Method::DELETE, path, None, &[], None, REQUEST_TIMEOUT).await
}
pub async fn put_bytes(&self, path: &str, headers: &[(&str, &str)], data: Vec<u8>) -> Result<Value, PrinterError> {
self.send(Method::PUT, path, Some("application/octet-stream"), headers, Some(data), UPLOAD_TIMEOUT).await
}
/// A `multipart/form-data` POST: the text `fields`, then `data` as the
/// part named `file_field`.
pub async fn post_file(
&self,
path: &str,
fields: &[(&str, &str)],
file_field: &str,
file_name: &str,
data: &[u8],
) -> Result<Value, PrinterError> {
let boundary = format!(
"continuum-{:x}{:x}",
std::process::id(),
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0)
);
let body = multipart_body(&boundary, fields, file_field, file_name, data);
let content_type = format!("multipart/form-data; boundary={boundary}");
self.send(Method::POST, path, Some(&content_type), &[], Some(body), UPLOAD_TIMEOUT).await
}
}
fn multipart_body(boundary: &str, fields: &[(&str, &str)], file_field: &str, file_name: &str, data: &[u8]) -> Vec<u8> {
let mut body = Vec::with_capacity(data.len() + 512);
for (name, value) in fields {
body.extend_from_slice(
format!("--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n").as_bytes(),
);
}
body.extend_from_slice(
format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"{file_field}\"; filename=\"{file_name}\"\r\nContent-Type: application/octet-stream\r\n\r\n"
)
.as_bytes(),
);
body.extend_from_slice(data);
body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
body
}
/// The file name to give the printer: the last path component, and nothing
/// that could break out of a multipart header or a URL path.
pub fn safe_file_name(file_name: &str) -> Result<String, PrinterError> {
let name = file_name.rsplit(['/', '\\']).next().unwrap_or("").trim();
if name.is_empty() || name.starts_with('.') || name.chars().any(|c| c.is_control() || c == '"') {
return Err(PrinterError(format!("'{file_name}' is not a usable file name")));
}
Ok(name.to_string())
}
/// Percent-encodes `name` for use as one URL path segment.
pub fn encode_segment(name: &str) -> String {
let mut out = String::new();
for byte in name.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
out.push(byte as char);
} else {
out.push_str(&format!("%{byte:02X}"));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_bare_host_gets_the_default_port() {
assert_eq!(base_url("192.168.0.9", 7125), "http://192.168.0.9:7125");
assert_eq!(base_url("printer.lan:80", 7125), "http://printer.lan:80");
assert_eq!(base_url("https://p.example/", 7125), "https://p.example");
}
#[test]
fn file_names_are_reduced_to_a_safe_last_component() {
assert_eq!(safe_file_name("jobs/../a b.gcode").unwrap(), "a b.gcode");
assert!(safe_file_name("..").is_err());
assert!(safe_file_name("x\".gcode").is_err());
assert!(safe_file_name("").is_err());
assert_eq!(encode_segment("a b+c.gcode"), "a%20b%2Bc.gcode");
}
#[test]
fn the_multipart_body_has_fields_then_the_file() {
let body = multipart_body("B", &[("print", "true")], "file", "x.gcode", b"G1");
let text = String::from_utf8(body).unwrap();
assert!(text.starts_with("--B\r\nContent-Disposition: form-data; name=\"print\"\r\n\r\ntrue\r\n"));
assert!(text.contains("name=\"file\"; filename=\"x.gcode\""));
assert!(text.ends_with("G1\r\n--B--\r\n"));
}
}
+263 -34
View File
@@ -1,75 +1,304 @@
use super::{GenericPrinter, PrinterBase, PrinterError};
//! Klipper, through Moonraker's HTTP API (default port 7125).
//!
//! Written from Moonraker's published API and exercised against a mock
//! server in the tests below - no real Klipper machine has been on the
//! bench, so treat the field names as unconfirmed until one has.
use std::time::Duration;
use serde_json::{json, Value};
use super::http::{safe_file_name, Http};
use super::polled::StatusFeed;
use super::{GenericPrinter, PrintControl, PrinterBase, PrinterError, PrinterStatus};
const MOONRAKER_PORT: u16 = 7125;
const POLL_EVERY: Duration = Duration::from_secs(2);
/// What one `objects/query` asks for - everything `parse_status` reads.
const STATUS_QUERY: &str =
"/printer/objects/query?print_stats&display_status&extruder&heater_bed&virtual_sdcard";
pub struct KlipperPrinter {
base: PrinterBase,
http: Http,
feed: Option<StatusFeed>,
}
impl KlipperPrinter {
pub fn new(id: &str, name: &str, host: &str) -> Self {
Self {
base: PrinterBase { id: id.into(), name: name.into(), host: host.into() },
http: Http::new(host, MOONRAKER_PORT, None),
feed: None,
}
}
pub fn status(&self) -> Option<PrinterStatus> {
self.feed.as_ref().and_then(StatusFeed::latest)
}
}
/// Moonraker's `print_stats.state` in the vocabulary the cloud already
/// understands (the strings Bambu reports; see `mapStatus` in the backend).
fn map_state(state: &str) -> Option<&'static str> {
match state {
"standby" => Some("IDLE"),
"printing" => Some("RUNNING"),
"paused" => Some("PAUSE"),
"complete" => Some("FINISH"),
// A cancelled print leaves nothing running; the machine is free.
"cancelled" => Some("IDLE"),
"error" => Some("FAILED"),
_ => None,
}
}
/// The `result.status` object of an `objects/query` answer.
fn parse_status(answer: &Value) -> Option<PrinterStatus> {
let status = answer.get("result")?.get("status")?;
let stats = status.get("print_stats")?;
let state = stats.get("state")?.as_str()?;
let number = |v: Option<&Value>| v.and_then(Value::as_f64).map(|n| n as f32);
let progress = number(status.get("virtual_sdcard").and_then(|s| s.get("progress"))).unwrap_or(0.0);
let percent = match state {
"complete" => 100,
"printing" | "paused" => (progress * 100.0).round() as i32,
_ => 0,
};
let duration = number(stats.get("print_duration")).unwrap_or(0.0);
// Klipper has no estimate of its own; this is elapsed time scaled by
// progress, which is wrong early in a print and settles as it goes.
let remaining_minutes = (matches!(state, "printing" | "paused") && progress > 0.01)
.then(|| ((duration / progress - duration) / 60.0).round() as i32);
let layer_info = stats.get("info");
let int = |v: Option<&Value>| v.and_then(Value::as_i64).map(|n| n as i32);
Some(PrinterStatus {
state: map_state(state).map(str::to_string),
percent: Some(percent),
layer: int(layer_info.and_then(|i| i.get("current_layer"))),
total_layers: int(layer_info.and_then(|i| i.get("total_layer"))),
remaining_minutes,
job_name: stats
.get("filename")
.and_then(Value::as_str)
.filter(|f| !f.is_empty())
.map(str::to_string),
nozzle_temp: number(status.get("extruder").and_then(|e| e.get("temperature"))),
nozzle_target: number(status.get("extruder").and_then(|e| e.get("target"))),
bed_temp: number(status.get("heater_bed").and_then(|e| e.get("temperature"))),
bed_target: number(status.get("heater_bed").and_then(|e| e.get("target"))),
print_error: (state == "error").then_some(1),
..PrinterStatus::default()
})
}
async fn poll(http: &Http) -> Option<PrinterStatus> {
match http.get_json(STATUS_QUERY).await {
Ok(answer) => parse_status(&answer),
// Moonraker answers 503 to object queries while Klipper itself is
// down, but still answers `/printer/info` - which is the
// difference between "off" and "faulted".
Err(_) => {
let info = http.get_json("/printer/info").await.ok()?;
let state = info.get("result")?.get("state")?.as_str()?;
matches!(state, "error" | "shutdown").then(|| PrinterStatus {
state: Some("FAILED".into()),
print_error: Some(1),
..PrinterStatus::default()
})
}
}
}
impl GenericPrinter for KlipperPrinter {
/// OVERRIDE: real version is `POST /printer/print/pause`, `/resume` or
/// `/cancel` on Moonraker - note Klipper calls it "cancel" where Bambu
/// calls it "stop".
async fn control_print(&mut self, action: super::PrintControl) -> Result<(), PrinterError> {
/// Note Klipper calls it "cancel" where Bambu calls it "stop".
async fn control_print(&mut self, action: PrintControl) -> Result<(), PrinterError> {
let endpoint = match action {
super::PrintControl::Pause => "pause",
super::PrintControl::Resume => "resume",
super::PrintControl::Stop => "cancel",
PrintControl::Pause => "pause",
PrintControl::Resume => "resume",
PrintControl::Stop => "cancel",
};
println!("[{}] POST /printer/print/{endpoint}", self.base.name);
Ok(())
self.http.post_empty(&format!("/printer/print/{endpoint}")).await.map(|_| ())
}
fn base(&self) -> &PrinterBase {
&self.base
}
/// OVERRIDE: a third, again-different body - Moonraker's real feed is a
/// WebSocket (see `adapters::moonraker`). This also shows the `Err`
/// path: an empty `host` fails immediately, same as any other vendor
/// would if it couldn't reach its printer.
/// Checks Moonraker answers, then starts the status poller. Klipper
/// itself being down is not a connect failure: Moonraker is up and
/// will report it.
async fn connect(&mut self) -> Result<(), PrinterError> {
if self.base.host.is_empty() {
if self.base.host.trim().is_empty() {
return Err(PrinterError(format!("{} has no host configured", self.base.name)));
}
println!("[{}] connecting over WebSocket (Moonraker)", self.base.name);
self.http
.get_json("/printer/info")
.await
.map_err(|err| PrinterError(format!("{} did not answer as Moonraker: {err}", self.base.name)))?;
let http = self.http.clone();
self.feed = Some(StatusFeed::start(POLL_EVERY, move || {
let http = http.clone();
async move { poll(&http).await }
}));
Ok(())
}
/// Nothing to publish to: Moonraker is request/response.
async fn publish(&mut self, _payload: String) -> Result<(), PrinterError> {
// TODO: add publishing via HTTP
Ok(())
Err(PrinterError("Klipper has no publish channel".into()))
}
/// OVERRIDE: Moonraker's own **hypothetical** native API, not gcode injection - no
/// percent-to-PWM conversion needed since it already takes a
/// percentage directly. Real version:
/// `POST /printer/gcode/script?script=SET_FAN_SPEED SPEED=0.NN`.
/// `M106` works on Klipper for the part-cooling fan, same as elsewhere.
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> {
println!("[{}] Moonraker: SET_FAN_SPEED SPEED={:.2}", self.base.name, percent as f32 / 100.0);
Ok(())
self.send_gcode_line(format!("M106 S{}", super::percent_to_pwm(percent))).await
}
/// OVERRIDE: real version is a multipart `POST /server/files/upload`
/// with `data` as the file part - Moonraker stores it on the printer's
/// virtual SD card, same upload-then-start model as Bambu/PrusaLink.
/// Uploads to the printer's `gcodes` root and starts it (`print=true`),
/// like Bambu's upload-then-`project_file`. Klipper runs plain G-code,
/// so a slicer project (`.3mf`) is refused here rather than uploaded
/// and left to fail on the machine.
async fn upload_project_file(&mut self, file_name: String, data: Vec<u8>) -> Result<(), PrinterError> {
println!("[{}] POST /server/files/upload {file_name} ({} bytes)", self.base.name, data.len());
let name = safe_file_name(&file_name)?;
let lower = name.to_lowercase();
if !(lower.ends_with(".gcode") || lower.ends_with(".gco") || lower.ends_with(".g")) {
return Err(PrinterError(format!(
"{name} is not G-code - Klipper needs a sliced .gcode file, not a project"
)));
}
if let Some(status) = self.status()
&& matches!(status.state.as_deref(), Some("RUNNING" | "PAUSE"))
{
return Err(PrinterError(format!("{} is already printing", self.base.name)));
}
let answer = self
.http
.post_file("/server/files/upload", &[("root", "gcodes"), ("print", "true")], "file", &name, &data)
.await?;
if answer.get("print_started").and_then(Value::as_bool) == Some(false) {
return Err(PrinterError(format!("{name} was uploaded but Klipper did not start it")));
}
Ok(())
}
/// OVERRIDE: Moonraker's gcode-script endpoint, one line per call - same
/// idea as `set_fan_speed`'s `SET_FAN_SPEED` call above, just with an
/// arbitrary line instead of a hardcoded one.
async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError> {
println!("[{}] POST /printer/gcode/script?script={gcode_line}", self.base.name);
Ok(())
self.http.post_json("/printer/gcode/script", &json!({ "script": gcode_line })).await.map(|_| ())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::printer::mock_http::MockServer;
const PRINTING: &str = r#"{"result":{"status":{
"print_stats":{"state":"printing","filename":"cube.gcode","print_duration":600.0,"info":{"current_layer":12,"total_layer":60}},
"virtual_sdcard":{"progress":0.25},
"extruder":{"temperature":214.8,"target":215.0},
"heater_bed":{"temperature":59.9,"target":60.0}}}}"#;
#[test]
fn a_printing_machine_maps_to_the_shared_vocabulary() {
let status = parse_status(&serde_json::from_str(PRINTING).unwrap()).unwrap();
assert_eq!(status.state.as_deref(), Some("RUNNING"));
assert_eq!(status.percent, Some(25));
assert_eq!((status.layer, status.total_layers), (Some(12), Some(60)));
// 600 s for a quarter: 1800 s more.
assert_eq!(status.remaining_minutes, Some(30));
assert_eq!(status.job_name.as_deref(), Some("cube.gcode"));
assert_eq!(status.nozzle_temp, Some(214.8));
assert_eq!(status.bed_target, Some(60.0));
assert_eq!(status.print_error, None);
}
#[test]
fn complete_is_100_percent_and_error_carries_a_code() {
let done = r#"{"result":{"status":{"print_stats":{"state":"complete","filename":"a.gcode"},"virtual_sdcard":{"progress":1.0}}}}"#;
let status = parse_status(&serde_json::from_str(done).unwrap()).unwrap();
assert_eq!((status.state.as_deref(), status.percent), (Some("FINISH"), Some(100)));
let failed = r#"{"result":{"status":{"print_stats":{"state":"error"}}}}"#;
let status = parse_status(&serde_json::from_str(failed).unwrap()).unwrap();
assert_eq!((status.state.as_deref(), status.print_error), (Some("FAILED"), Some(1)));
}
#[tokio::test]
async fn control_gcode_and_fan_go_to_the_right_endpoints() {
let mock = MockServer::start().await;
mock.answer("GET /printer/info", 200, r#"{"result":{"state":"ready"}}"#);
mock.answer("POST /printer/print/cancel", 200, r#"{"result":"ok"}"#);
mock.answer("POST /printer/gcode/script", 200, r#"{"result":"ok"}"#);
let mut printer = KlipperPrinter::new("k1", "Voron", &mock.host());
printer.connect().await.unwrap();
printer.control_print(PrintControl::Stop).await.unwrap();
assert_eq!(mock.sent("POST", "/printer/print/cancel").len(), 1);
printer.set_fan_speed(50).await.unwrap();
let sent = mock.sent("POST", "/printer/gcode/script");
assert_eq!(sent[0].text(), r#"{"script":"M106 S127"}"#);
}
#[tokio::test]
async fn connect_fails_when_nothing_answers_as_moonraker() {
let mock = MockServer::start().await; // every route is a 404
let mut printer = KlipperPrinter::new("k1", "Voron", &mock.host());
assert!(printer.connect().await.is_err());
}
#[tokio::test]
async fn the_poller_feeds_status_and_clears_it_when_the_printer_goes() {
let mock = MockServer::start().await;
mock.answer("GET /printer/info", 200, r#"{"result":{"state":"ready"}}"#);
mock.answer("GET /printer/objects/query", 200, PRINTING);
let mut printer = KlipperPrinter::new("k1", "Voron", &mock.host());
printer.connect().await.unwrap();
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(printer.status().unwrap().percent, Some(25));
// Klipper shuts down: queries 503, /printer/info says why.
mock.answer("GET /printer/objects/query", 503, "{}");
mock.answer("GET /printer/info", 200, r#"{"result":{"state":"shutdown"}}"#);
tokio::time::sleep(Duration::from_millis(2500)).await;
assert_eq!(printer.status().unwrap().state.as_deref(), Some("FAILED"));
}
#[tokio::test]
async fn upload_sends_the_file_with_print_true_and_refuses_a_project() {
let mock = MockServer::start().await;
mock.answer("GET /printer/info", 200, r#"{"result":{"state":"ready"}}"#);
mock.answer("POST /server/files/upload", 201, r#"{"print_started":true}"#);
let mut printer = KlipperPrinter::new("k1", "Voron", &mock.host());
printer.connect().await.unwrap();
printer.upload_project_file("jobs/cube.gcode".into(), b"G28\nG1 X1\n".to_vec()).await.unwrap();
let sent = mock.sent("POST", "/server/files/upload");
assert_eq!(sent.len(), 1);
let body = sent[0].text();
assert!(body.contains("name=\"print\"\r\n\r\ntrue"));
assert!(body.contains("filename=\"cube.gcode\""));
assert!(body.contains("G28\nG1 X1\n"));
assert!(sent[0].header("content-type").unwrap().starts_with("multipart/form-data; boundary="));
let err = printer.upload_project_file("x.3mf".into(), vec![1]).await.unwrap_err();
assert!(err.0.contains("not G-code"), "{err}");
assert_eq!(mock.sent("POST", "/server/files/upload").len(), 1);
}
#[tokio::test]
async fn an_upload_is_refused_while_a_print_runs() {
let mock = MockServer::start().await;
mock.answer("GET /printer/info", 200, r#"{"result":{"state":"ready"}}"#);
mock.answer("GET /printer/objects/query", 200, PRINTING);
let mut printer = KlipperPrinter::new("k1", "Voron", &mock.host());
printer.connect().await.unwrap();
tokio::time::sleep(Duration::from_millis(300)).await;
let err = printer.upload_project_file("b.gcode".into(), vec![1]).await.unwrap_err();
assert!(err.0.contains("already printing"), "{err}");
assert!(mock.sent("POST", "/server/files/upload").is_empty());
}
}
+118
View File
@@ -0,0 +1,118 @@
//! A tiny HTTP server for the REST drivers' tests: canned answers per
//! `METHOD /path` (query string ignored), and a record of what was sent.
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
#[derive(Debug, Clone)]
pub struct Recorded {
pub method: String,
pub path: String,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
}
impl Recorded {
pub fn header(&self, name: &str) -> Option<&str> {
self.headers.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str())
}
pub fn text(&self) -> String {
String::from_utf8_lossy(&self.body).into_owned()
}
}
#[derive(Clone)]
pub struct MockServer {
pub addr: SocketAddr,
routes: Arc<Mutex<HashMap<String, (u16, String)>>>,
requests: Arc<Mutex<Vec<Recorded>>>,
}
impl MockServer {
pub async fn start() -> Self {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let server = Self {
addr: listener.local_addr().unwrap(),
routes: Arc::default(),
requests: Arc::default(),
};
let shared = server.clone();
tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else { return };
let shared = shared.clone();
tokio::spawn(async move { shared.handle(stream).await });
}
});
server
}
pub fn host(&self) -> String {
self.addr.to_string()
}
/// `route` is e.g. `"GET /printer/info"`.
pub fn answer(&self, route: &str, status: u16, body: &str) {
self.routes.lock().unwrap().insert(route.to_string(), (status, body.to_string()));
}
pub fn requests(&self) -> Vec<Recorded> {
self.requests.lock().unwrap().clone()
}
pub fn sent(&self, method: &str, path: &str) -> Vec<Recorded> {
self.requests().into_iter().filter(|r| r.method == method && r.path == path).collect()
}
async fn handle(&self, mut stream: tokio::net::TcpStream) {
let mut buf = Vec::new();
let mut chunk = [0u8; 8192];
let head_end = loop {
if let Some(i) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
break i + 4;
}
match stream.read(&mut chunk).await {
Ok(0) | Err(_) => return,
Ok(n) => buf.extend_from_slice(&chunk[..n]),
}
};
let head = String::from_utf8_lossy(&buf[..head_end]).into_owned();
let mut lines = head.split("\r\n");
let mut first = lines.next().unwrap_or("").split(' ');
let method = first.next().unwrap_or("").to_string();
let target = first.next().unwrap_or("").to_string();
let headers: Vec<(String, String)> = lines
.filter_map(|l| l.split_once(':').map(|(k, v)| (k.trim().to_string(), v.trim().to_string())))
.collect();
let length: usize = headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("content-length"))
.and_then(|(_, v)| v.parse().ok())
.unwrap_or(0);
while buf.len() < head_end + length {
match stream.read(&mut chunk).await {
Ok(0) | Err(_) => break,
Ok(n) => buf.extend_from_slice(&chunk[..n]),
}
}
let path = target.split('?').next().unwrap_or("").to_string();
let body = buf[head_end..].to_vec();
let (status, answer) = self
.routes
.lock()
.unwrap()
.get(&format!("{method} {path}"))
.cloned()
.unwrap_or((404, "{}".to_string()));
self.requests.lock().unwrap().push(Recorded { method, path, headers, body });
let response = format!(
"HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{answer}",
answer.len()
);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.shutdown().await;
}
}
+81 -5
View File
@@ -24,26 +24,32 @@
//! Real MQTT is built now - `bambu.rs`/`bambu_mqtt.rs` for Bambu's
//! protocol, `bambu_ftps.rs` for getting files onto a Bambu printer,
//! `marlin_serial.rs` for a real Marlin serial connection - see each
//! module's own doc comment. PrusaLink/Klipper's HTTP clients are still
//! stubs; that's the next piece to build past this module.
//! module's own doc comment. Klipper (`klipper.rs`) and OctoPrint/PrusaLink
//! (`octoprint.rs`) are real HTTP clients, tested against mock servers only.
mod bambu;
mod bambu_camera;
mod bambu_commands;
mod bambu_ftps;
mod bambu_mqtt;
mod http;
mod klipper;
mod marlin_serial;
#[cfg(test)]
pub(crate) mod mock_http;
mod octoprint;
mod polled;
mod prusa;
// Reading a `.3mf` well enough to know which filament a plate wants. Kept
// free of printer types so it is testable without a printer.
mod project_file;
pub use project_file::{
map_to_trays, normalise_color, plate_filaments, AvailableTray, ProjectFilament,
map_to_slot, map_to_trays, normalise_color, plate_filaments, AvailableTray, ProjectFilament,
ProjectFileError,
};
pub use bambu::{
pub use bambu::{SnapshotJob,
BambuPrinter, BambuProtocolPreference, BambuProtocolVersion, BambuTls, FilamentSource,
LightState, CHAMBER_LIGHT,
};
@@ -53,7 +59,54 @@ pub use bambu_commands::{
XcamSettings,
};
pub use klipper::KlipperPrinter;
pub use prusa::{OctoPrintFlavour, OctoPrintPrinter, PrusaSerialPrinter};
pub use octoprint::{OctoPrintFlavour, OctoPrintPrinter};
pub use polled::StatusFeed;
pub use prusa::PrusaSerialPrinter;
/// What the cloud is told about a printer's job, whatever the vendor.
///
/// `state` uses the strings Bambu reports (`IDLE`, `RUNNING`, `PAUSE`,
/// `FINISH`, `FAILED`) because the cloud's `mapStatus` and the completion
/// watcher already speak them; the other vendors translate into them.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct PrinterStatus {
pub state: Option<String>,
pub percent: Option<i32>,
pub stage: Option<i32>,
pub layer: Option<i32>,
pub total_layers: Option<i32>,
pub remaining_minutes: Option<i32>,
pub job_name: Option<String>,
pub nozzle_temp: Option<f32>,
pub nozzle_target: Option<f32>,
pub bed_temp: Option<f32>,
pub bed_target: Option<f32>,
pub print_error: Option<i32>,
pub active_alerts: Option<usize>,
}
impl From<&PrintReport> for PrinterStatus {
fn from(report: &PrintReport) -> Self {
Self {
state: report.gcode_state.clone(),
percent: report.mc_percent,
stage: report.stg_cur,
layer: report.layer_num,
total_layers: report.total_layer_num,
remaining_minutes: report.mc_remaining_time,
// An idle printer reports `""` rather than omitting the field;
// empty-string-as-absent is worth normalising here so the cloud
// doesn't have to know that.
job_name: report.subtask_name.clone().filter(|s| !s.is_empty()),
nozzle_temp: report.nozzle_temper,
nozzle_target: report.nozzle_target_temper,
bed_temp: report.bed_temper,
bed_target: report.bed_target_temper,
print_error: report.print_error,
active_alerts: report.hms.as_ref().map(|h| h.len()),
}
}
}
/// A tiny error type: just a message. `String` would work too - this exists
/// mainly so `?` has something concrete to convert into everywhere.
@@ -194,6 +247,29 @@ pub enum PrinterHandle {
}
impl PrinterHandle {
/// A pending camera-frame read, for printers whose camera is a JPEG
/// stream rather than RTSP (Bambu P1/A1).
pub fn snapshot_job(&self) -> Result<bambu::SnapshotJob, PrinterError> {
match self {
PrinterHandle::Bambu(p) => p.snapshot_job(),
_ => Err(PrinterError("this printer has no snapshot camera".into())),
}
}
/// The latest vendor-neutral status, if the printer has one. Bambu's
/// comes from its MQTT cache, the HTTP vendors' from their pollers; the
/// serial printer has none.
pub async fn status(&self) -> Option<PrinterStatus> {
match self {
PrinterHandle::Bambu(p) => p.print_status().await.as_ref().map(PrinterStatus::from),
PrinterHandle::OctoPrint(p) => p.status(),
PrinterHandle::Klipper(p) => p.status(),
PrinterHandle::PrusaSerial(_) => None,
}
}
/// Bambu's full report, for what is Bambu-specific (camera, AMS).
///
/// The latest status this process has for the printer, if it has one.
///
/// An inherent method rather than a `GenericPrinter` trait method,
+417
View File
@@ -0,0 +1,417 @@
//! OctoPrint and PrusaLink over HTTP (`X-Api-Key`).
//!
//! Written from the two projects' published REST APIs and exercised against
//! a mock server in the tests below - no real OctoPrint or PrusaLink machine
//! has been on the bench, so treat the field names as unconfirmed until one
//! has.
use std::time::Duration;
use serde_json::{json, Value};
use super::http::{encode_segment, safe_file_name, Http};
use super::polled::StatusFeed;
use super::{GenericPrinter, PrintControl, PrinterBase, PrinterError, PrinterStatus};
const POLL_EVERY: Duration = Duration::from_secs(3);
/// Which implementation of the OctoPrint REST API is on the other end.
///
/// PrusaLink deliberately implements OctoPrint's API, so one client serves
/// both - but not identically. PrusaLink adds its own `/api/v1/*` (`info`,
/// `status`, `job`) which is where the useful detail lives, while OctoPrint
/// proper has the older `/api/printer` and `/api/job` and a plugin surface
/// PrusaLink does not have.
///
/// This is recorded from config now, while the client is still a stub,
/// because **no probe can settle it**: both answer `/api/version`, and every
/// PrusaLink identity endpoint needs a credential, so from outside the two
/// are indistinguishable (see `discovery::net::classify_octoprint_api`). It
/// is the operator's answer to a question only they can answer, so it needs
/// somewhere to live the moment they are asked - otherwise the real client
/// would have to ask them a second time.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OctoPrintFlavour {
/// Prusa's own firmware-hosted implementation, on an MK4/XL/MINI.
PrusaLink,
/// OctoPrint itself, usually on a Raspberry Pi in front of any printer.
Generic,
}
impl OctoPrintFlavour {
pub fn as_str(self) -> &'static str {
match self {
OctoPrintFlavour::PrusaLink => "PrusaLink",
OctoPrintFlavour::Generic => "OctoPrint",
}
}
}
pub struct OctoPrintPrinter {
base: PrinterBase,
pub flavour: OctoPrintFlavour,
http: Http,
feed: Option<StatusFeed>,
}
impl OctoPrintPrinter {
pub fn new(id: &str, name: &str, host: &str, api_key: &str, flavour: OctoPrintFlavour) -> Self {
Self {
base: PrinterBase { id: id.into(), name: name.into(), host: host.into() },
flavour,
// Port 80: OctoPrint's usual front (haproxy) and PrusaLink both
// answer there. An unusual port goes in `host`.
http: Http::new(host, 80, Some(api_key)),
feed: None,
}
}
pub fn status(&self) -> Option<PrinterStatus> {
self.feed.as_ref().and_then(StatusFeed::latest)
}
}
fn number(v: Option<&Value>) -> Option<f32> {
v.and_then(Value::as_f64).map(|n| n as f32)
}
/// OctoPrint's `GET /api/job`, plus `GET /api/printer` for temperatures
/// (which is a 409 whenever the printer is not operational, hence `Option`).
fn octoprint_status(job: &Value, printer: Option<&Value>) -> Option<PrinterStatus> {
let state = job.get("state")?.as_str()?;
let completion = number(job.get("progress").and_then(|p| p.get("completion")));
let left = number(job.get("progress").and_then(|p| p.get("printTimeLeft")));
let (mapped, percent) = if state.starts_with("Printing") || state == "Cancelling" {
("RUNNING", completion.unwrap_or(0.0))
} else if state == "Pausing" || state == "Paused" {
("PAUSE", completion.unwrap_or(0.0))
} else if state == "Operational" {
// OctoPrint has no "finished" state: the job simply stays at 100%.
match completion {
Some(c) if c >= 100.0 => ("FINISH", 100.0),
_ => ("IDLE", 0.0),
}
} else if state.contains("rror") && !state.starts_with("Offline") {
("FAILED", 0.0)
} else {
// Offline, still opening the serial port, ...: not a state worth
// reporting as the printer's.
return None;
};
let tool = printer.and_then(|p| p.get("temperature")).and_then(|t| t.get("tool0"));
let bed = printer.and_then(|p| p.get("temperature")).and_then(|t| t.get("bed"));
Some(PrinterStatus {
state: Some(mapped.into()),
percent: Some(percent.round() as i32),
remaining_minutes: (mapped == "RUNNING" || mapped == "PAUSE").then_some(left).flatten().map(|s| (s / 60.0).round() as i32),
job_name: job
.get("job")
.and_then(|j| j.get("file"))
.and_then(|f| f.get("display").or_else(|| f.get("name")))
.and_then(Value::as_str)
.map(str::to_string),
nozzle_temp: number(tool.and_then(|t| t.get("actual"))),
nozzle_target: number(tool.and_then(|t| t.get("target"))),
bed_temp: number(bed.and_then(|t| t.get("actual"))),
bed_target: number(bed.and_then(|t| t.get("target"))),
print_error: (mapped == "FAILED").then_some(1),
..PrinterStatus::default()
})
}
/// PrusaLink's `GET /api/v1/status`, with `GET /api/v1/job` for the file name
/// when a job exists.
fn prusalink_status(status: &Value, job: Option<&Value>) -> Option<PrinterStatus> {
let printer = status.get("printer")?;
let state = printer.get("state")?.as_str()?;
let mapped = match state {
"IDLE" | "STOPPED" => "IDLE",
"BUSY" => "PREPARE",
"PRINTING" => "RUNNING",
"PAUSED" | "ATTENTION" => "PAUSE",
"FINISHED" => "FINISH",
"ERROR" => "FAILED",
_ => return None,
};
let running = matches!(mapped, "RUNNING" | "PAUSE");
let progress = number(status.get("job").and_then(|j| j.get("progress")));
let left = number(status.get("job").and_then(|j| j.get("time_remaining")));
Some(PrinterStatus {
state: Some(mapped.into()),
percent: Some(match mapped {
"FINISH" => 100,
_ if running => progress.unwrap_or(0.0).round() as i32,
_ => 0,
}),
remaining_minutes: running.then_some(left).flatten().map(|s| (s / 60.0).round() as i32),
job_name: job
.and_then(|j| j.get("file"))
.and_then(|f| f.get("display_name").or_else(|| f.get("name")))
.and_then(Value::as_str)
.map(str::to_string),
nozzle_temp: number(printer.get("temp_nozzle")),
nozzle_target: number(printer.get("target_nozzle")),
bed_temp: number(printer.get("temp_bed")),
bed_target: number(printer.get("target_bed")),
print_error: (mapped == "FAILED").then_some(1),
..PrinterStatus::default()
})
}
async fn poll(http: &Http, flavour: OctoPrintFlavour) -> Option<PrinterStatus> {
match flavour {
OctoPrintFlavour::Generic => {
let job = http.get_json("/api/job").await.ok()?;
let printer = http.get_json("/api/printer").await.ok();
octoprint_status(&job, printer.as_ref())
}
OctoPrintFlavour::PrusaLink => {
let status = http.get_json("/api/v1/status").await.ok()?;
let has_job = status.get("job").is_some_and(|j| !j.is_null());
let job = if has_job { http.get_json("/api/v1/job").await.ok() } else { None };
prusalink_status(&status, job.as_ref())
}
}
}
impl GenericPrinter for OctoPrintPrinter {
/// OctoPrint: `POST /api/job` with a command. PrusaLink addresses the
/// running job by id, so the current job is fetched first.
async fn control_print(&mut self, action: PrintControl) -> Result<(), PrinterError> {
match self.flavour {
OctoPrintFlavour::Generic => {
let body = match action {
PrintControl::Pause => json!({ "command": "pause", "action": "pause" }),
PrintControl::Resume => json!({ "command": "pause", "action": "resume" }),
PrintControl::Stop => json!({ "command": "cancel" }),
};
self.http.post_json("/api/job", &body).await.map(|_| ())
}
OctoPrintFlavour::PrusaLink => {
let status = self.http.get_json("/api/v1/status").await?;
let id = status
.get("job")
.and_then(|j| j.get("id"))
.and_then(Value::as_i64)
.ok_or_else(|| PrinterError(format!("{} has no job to {}", self.base.name, action.as_str())))?;
match action {
PrintControl::Pause => self.http.put_empty(&format!("/api/v1/job/{id}/pause")).await,
PrintControl::Resume => self.http.put_empty(&format!("/api/v1/job/{id}/resume")).await,
PrintControl::Stop => self.http.delete(&format!("/api/v1/job/{id}")).await,
}
.map(|_| ())
}
}
}
fn base(&self) -> &PrinterBase {
&self.base
}
/// Checks the printer answers with this key, then starts the poller.
async fn connect(&mut self) -> Result<(), PrinterError> {
if self.base.host.trim().is_empty() {
return Err(PrinterError(format!("{} has no host configured", self.base.name)));
}
// `/api/version` needs no permissions on either, so it would pass
// with a wrong key; these two are the first that check it.
let probe = match self.flavour {
OctoPrintFlavour::Generic => "/api/job",
OctoPrintFlavour::PrusaLink => "/api/v1/status",
};
self.http
.get_json(probe)
.await
.map_err(|err| PrinterError(format!("{} did not answer as {}: {err}", self.base.name, self.flavour.as_str())))?;
let (http, flavour) = (self.http.clone(), self.flavour);
self.feed = Some(StatusFeed::start(POLL_EVERY, move || {
let http = http.clone();
async move { poll(&http, flavour).await }
}));
Ok(())
}
async fn publish(&mut self, _payload: String) -> Result<(), PrinterError> {
Err(PrinterError(format!("{} has no publish channel", self.flavour.as_str())))
}
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> {
self.send_gcode_line(format!("M106 S{}", super::percent_to_pwm(percent))).await
}
/// Uploads and starts the print. OctoPrint takes a multipart POST to
/// `/api/files/local`; PrusaLink a raw `PUT /api/v1/files/local/{name}`
/// with `Print-After-Upload`. Both run sliced G-code, not a project.
async fn upload_project_file(&mut self, file_name: String, data: Vec<u8>) -> Result<(), PrinterError> {
let name = safe_file_name(&file_name)?;
let lower = name.to_lowercase();
let printable = [".gcode", ".gco", ".g", ".bgcode"].iter().any(|ext| lower.ends_with(ext));
if !printable {
return Err(PrinterError(format!(
"{name} is not G-code - {} needs a sliced file, not a project",
self.flavour.as_str()
)));
}
if let Some(status) = self.status()
&& matches!(status.state.as_deref(), Some("RUNNING" | "PAUSE" | "PREPARE"))
{
return Err(PrinterError(format!("{} is already printing", self.base.name)));
}
match self.flavour {
OctoPrintFlavour::Generic => self
.http
.post_file("/api/files/local", &[("select", "true"), ("print", "true")], "file", &name, &data)
.await
.map(|_| ()),
OctoPrintFlavour::PrusaLink => self
.http
.put_bytes(
&format!("/api/v1/files/local/{}", encode_segment(&name)),
&[("Print-After-Upload", "?1"), ("Overwrite", "?1")],
data,
)
.await
.map(|_| ()),
}
}
async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError> {
match self.flavour {
OctoPrintFlavour::Generic => self
.http
.post_json("/api/printer/command", &json!({ "commands": [gcode_line] }))
.await
.map(|_| ()),
// PrusaLink's API has no G-code endpoint.
OctoPrintFlavour::PrusaLink => {
Err(PrinterError("PrusaLink cannot be sent G-code lines".into()))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::printer::mock_http::MockServer;
const JOB: &str = r#"{"job":{"file":{"name":"cube.gcode","display":"cube.gcode"}},
"progress":{"completion":40.0,"printTime":600,"printTimeLeft":900},"state":"Printing"}"#;
const TEMPS: &str =
r#"{"temperature":{"tool0":{"actual":209.5,"target":210.0},"bed":{"actual":60.0,"target":60.0}}}"#;
#[test]
fn octoprint_states_map_to_the_shared_vocabulary() {
let status = octoprint_status(&serde_json::from_str(JOB).unwrap(), serde_json::from_str(TEMPS).ok().as_ref()).unwrap();
assert_eq!(status.state.as_deref(), Some("RUNNING"));
assert_eq!(status.percent, Some(40));
assert_eq!(status.remaining_minutes, Some(15));
assert_eq!(status.job_name.as_deref(), Some("cube.gcode"));
assert_eq!(status.nozzle_target, Some(210.0));
// Finished: still "Operational", job stays at 100%. No temperatures
// (the printer endpoint was a 409) is fine.
let done = r#"{"state":"Operational","progress":{"completion":100.0}}"#;
let status = octoprint_status(&serde_json::from_str(done).unwrap(), None).unwrap();
assert_eq!((status.state.as_deref(), status.percent, status.nozzle_temp), (Some("FINISH"), Some(100), None));
let idle = r#"{"state":"Operational","progress":{"completion":null}}"#;
assert_eq!(octoprint_status(&serde_json::from_str(idle).unwrap(), None).unwrap().state.as_deref(), Some("IDLE"));
let offline = r#"{"state":"Offline","progress":{}}"#;
assert!(octoprint_status(&serde_json::from_str(offline).unwrap(), None).is_none());
let broken = r#"{"state":"Error","progress":{}}"#;
assert_eq!(octoprint_status(&serde_json::from_str(broken).unwrap(), None).unwrap().print_error, Some(1));
}
#[test]
fn prusalink_states_map_to_the_shared_vocabulary() {
let status = r#"{"printer":{"state":"PRINTING","temp_nozzle":215.1,"target_nozzle":215.0,"temp_bed":60.2,"target_bed":60.0},
"job":{"id":7,"progress":33.0,"time_remaining":1200,"time_printing":300}}"#;
let job = r#"{"id":7,"file":{"name":"CUBE~1.GCO","display_name":"cube.gcode"}}"#;
let parsed = prusalink_status(&serde_json::from_str(status).unwrap(), serde_json::from_str(job).ok().as_ref()).unwrap();
assert_eq!(parsed.state.as_deref(), Some("RUNNING"));
assert_eq!((parsed.percent, parsed.remaining_minutes), (Some(33), Some(20)));
assert_eq!(parsed.job_name.as_deref(), Some("cube.gcode"));
assert_eq!(parsed.bed_temp, Some(60.2));
let idle = r#"{"printer":{"state":"IDLE","temp_nozzle":25.0,"temp_bed":24.0}}"#;
let parsed = prusalink_status(&serde_json::from_str(idle).unwrap(), None).unwrap();
assert_eq!((parsed.state.as_deref(), parsed.percent), (Some("IDLE"), Some(0)));
}
#[tokio::test]
async fn octoprint_controls_upload_and_send_the_key() {
let mock = MockServer::start().await;
mock.answer("GET /api/job", 200, r#"{"state":"Operational","progress":{}}"#);
mock.answer("POST /api/job", 204, "");
mock.answer("POST /api/printer/command", 204, "");
mock.answer("POST /api/files/local", 201, r#"{"done":true}"#);
let mut printer = OctoPrintPrinter::new("o1", "Ender", &mock.host(), "KEY123", OctoPrintFlavour::Generic);
printer.connect().await.unwrap();
printer.control_print(PrintControl::Resume).await.unwrap();
let sent = mock.sent("POST", "/api/job");
assert_eq!(sent[0].text(), r#"{"action":"resume","command":"pause"}"#);
assert_eq!(sent[0].header("x-api-key"), Some("KEY123"));
printer.control_print(PrintControl::Stop).await.unwrap();
assert_eq!(mock.sent("POST", "/api/job")[1].text(), r#"{"command":"cancel"}"#);
printer.set_fan_speed(100).await.unwrap();
assert_eq!(mock.sent("POST", "/api/printer/command")[0].text(), r#"{"commands":["M106 S255"]}"#);
printer.upload_project_file("a.gcode".into(), b"G28".to_vec()).await.unwrap();
let body = mock.sent("POST", "/api/files/local")[0].text();
assert!(body.contains("name=\"print\"\r\n\r\ntrue") && body.contains("filename=\"a.gcode\""));
assert!(printer.upload_project_file("a.3mf".into(), vec![1]).await.is_err());
}
#[tokio::test]
async fn a_wrong_key_fails_connect() {
let mock = MockServer::start().await;
mock.answer("GET /api/job", 403, r#"{"error":"forbidden"}"#);
let mut printer = OctoPrintPrinter::new("o1", "Ender", &mock.host(), "bad", OctoPrintFlavour::Generic);
assert!(printer.connect().await.unwrap_err().0.contains("403"));
}
#[tokio::test]
async fn prusalink_addresses_the_running_job_by_id() {
let mock = MockServer::start().await;
mock.answer("GET /api/v1/status", 200, r#"{"printer":{"state":"PRINTING"},"job":{"id":7,"progress":10.0}}"#);
mock.answer("GET /api/v1/job", 200, r#"{"id":7,"file":{"display_name":"c.gcode"}}"#);
mock.answer("PUT /api/v1/job/7/pause", 204, "");
mock.answer("DELETE /api/v1/job/7", 204, "");
let mut printer = OctoPrintPrinter::new("p1", "MK4", &mock.host(), "K", OctoPrintFlavour::PrusaLink);
printer.connect().await.unwrap();
printer.control_print(PrintControl::Pause).await.unwrap();
printer.control_print(PrintControl::Stop).await.unwrap();
assert_eq!(mock.sent("PUT", "/api/v1/job/7/pause").len(), 1);
assert_eq!(mock.sent("DELETE", "/api/v1/job/7").len(), 1);
assert!(printer.send_gcode_line("M106".into()).await.is_err());
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(printer.status().unwrap().job_name.as_deref(), Some("c.gcode"));
// A job is running, so an upload is refused before anything is sent.
let err = printer.upload_project_file("n.bgcode".into(), vec![1]).await.unwrap_err();
assert!(err.0.contains("already printing"), "{err}");
assert!(mock.sent("PUT", "/api/v1/files/local/n.bgcode").is_empty());
}
#[tokio::test]
async fn prusalink_upload_is_a_put_with_print_after_upload() {
let mock = MockServer::start().await;
mock.answer("GET /api/v1/status", 200, r#"{"printer":{"state":"IDLE"}}"#);
mock.answer("PUT /api/v1/files/local/my%20part.gcode", 201, "");
let mut printer = OctoPrintPrinter::new("p1", "MK4", &mock.host(), "K", OctoPrintFlavour::PrusaLink);
printer.connect().await.unwrap();
tokio::time::sleep(Duration::from_millis(300)).await;
printer.upload_project_file("my part.gcode".into(), b"G28".to_vec()).await.unwrap();
let sent = mock.sent("PUT", "/api/v1/files/local/my%20part.gcode");
assert_eq!(sent[0].header("print-after-upload"), Some("?1"));
assert_eq!(sent[0].body, b"G28");
}
}
+47
View File
@@ -0,0 +1,47 @@
//! Status for vendors that have no push feed: a background task asks the
//! printer on an interval and keeps the last answer for `status()` to read.
//! (Bambu pushes over MQTT and keeps its own cache.)
use std::future::Future;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::task::JoinHandle;
use super::PrinterStatus;
pub struct StatusFeed {
latest: Arc<Mutex<Option<PrinterStatus>>>,
task: JoinHandle<()>,
}
impl StatusFeed {
/// `fetch` returning `None` means "no usable answer" (printer
/// unreachable, or not ready), and clears the cache: a stale status
/// shown as current is worse than none.
pub fn start<F, Fut>(interval: Duration, fetch: F) -> Self
where
F: Fn() -> Fut + Send + 'static,
Fut: Future<Output = Option<PrinterStatus>> + Send,
{
let latest = Arc::new(Mutex::new(None));
let shared = latest.clone();
let task = tokio::spawn(async move {
loop {
let status = fetch().await;
*shared.lock().unwrap() = status;
tokio::time::sleep(interval).await;
}
});
Self { latest, task }
}
pub fn latest(&self) -> Option<PrinterStatus> {
self.latest.lock().unwrap().clone()
}
}
impl Drop for StatusFeed {
fn drop(&mut self) {
self.task.abort();
}
}
+44
View File
@@ -71,6 +71,8 @@ pub enum ProjectFileError {
/// material or none.
#[error("no loaded tray matches {wanted} (filament index {index}); loaded: {loaded}")]
NoMatchingTray { index: usize, wanted: String, loaded: String },
#[error("cannot use AMS slot {0}: {1}")]
BadSlot(i32, String),
}
/// Normalises a colour to six uppercase hex digits.
@@ -271,6 +273,36 @@ pub fn map_to_trays(
}
/// Match quality, lower is better; `None` means no match.
/// An explicit choice of slot instead of the colour-based pick: the operator
/// said which spool to use. Deliberately narrow - a single-filament project
/// only (which filament goes where is ambiguous otherwise), onto a slot that
/// is loaded and holds the same *material type*, because the type decides
/// the temperatures. A different colour is allowed: that is the point.
pub fn map_to_slot(
filaments: &[ProjectFilament],
trays: &[AvailableTray],
slot: i32,
) -> Result<Vec<i32>, ProjectFileError> {
let [filament] = filaments else {
return Err(ProjectFileError::BadSlot(
slot,
format!("the project uses {} filaments; a chosen slot is only for single-filament projects", filaments.len()),
));
};
let tray = trays.iter().find(|t| t.slot == slot).ok_or_else(|| {
ProjectFileError::BadSlot(slot, "no filament is loaded in that slot".into())
})?;
if !tray.filament_type.eq_ignore_ascii_case(&filament.filament_type) {
return Err(ProjectFileError::BadSlot(
slot,
format!("it holds {} but the project was sliced for {}", tray.filament_type, filament.filament_type),
));
}
let mut mapping = vec![-1; filament.index + 1];
mapping[filament.index] = slot;
Ok(mapping)
}
fn tier(filament: &ProjectFilament, tray: &AvailableTray) -> Option<u8> {
let same_product = filament.tray_info_idx.eq_ignore_ascii_case(&tray.tray_info_idx);
let same_color = filament.color == tray.color;
@@ -288,6 +320,18 @@ mod tests {
use super::*;
use std::io::Write;
#[test]
fn a_chosen_slot_overrides_the_colour_pick_but_not_the_material() {
let f = [filament(0, "GFG99", "PETG", "161616")];
let t = [tray(2, "GFG99", "PETG", "161616"), tray(3, "GFG99", "PETG", "2850E0"), tray(1, "GFL99", "PLA", "D3C5A3")];
assert_eq!(map_to_trays(&f, &t).unwrap(), vec![2]);
assert_eq!(map_to_slot(&f, &t, 3).unwrap(), vec![3]);
assert!(map_to_slot(&f, &t, 1).is_err(), "PLA slot for a PETG project");
assert!(map_to_slot(&f, &t, 4).is_err(), "empty slot");
let two = [filament(0, "GFG99", "PETG", "161616"), filament(1, "GFG99", "PETG", "2850E0")];
assert!(map_to_slot(&two, &t, 3).is_err(), "multi-filament project");
}
const REAL: &str = r##"<?xml version="1.0" encoding="UTF-8"?>
<config>
<header>
-106
View File
@@ -1,110 +1,4 @@
use super::{GenericPrinter, PrinterBase, PrinterError};
/****************** OctoPrint API family ******************/
/// Which implementation of the OctoPrint REST API is on the other end.
///
/// PrusaLink deliberately implements OctoPrint's API, so one client serves
/// both - but not identically. PrusaLink adds its own `/api/v1/*` (`info`,
/// `status`, `job`) which is where the useful detail lives, while OctoPrint
/// proper has the older `/api/printer` and `/api/job` and a plugin surface
/// PrusaLink does not have.
///
/// This is recorded from config now, while the client is still a stub,
/// because **no probe can settle it**: both answer `/api/version`, and every
/// PrusaLink identity endpoint needs a credential, so from outside the two
/// are indistinguishable (see `discovery::net::classify_octoprint_api`). It
/// is the operator's answer to a question only they can answer, so it needs
/// somewhere to live the moment they are asked - otherwise the real client
/// would have to ask them a second time.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OctoPrintFlavour {
/// Prusa's own firmware-hosted implementation, on an MK4/XL/MINI.
PrusaLink,
/// OctoPrint itself, usually on a Raspberry Pi in front of any printer.
Generic,
}
impl OctoPrintFlavour {
pub fn as_str(self) -> &'static str {
match self {
OctoPrintFlavour::PrusaLink => "PrusaLink",
OctoPrintFlavour::Generic => "OctoPrint",
}
}
}
pub struct OctoPrintPrinter {
base: PrinterBase,
pub api_key: String,
pub flavour: OctoPrintFlavour,
}
impl OctoPrintPrinter {
pub fn new(id: &str, name: &str, host: &str, api_key: &str, flavour: OctoPrintFlavour) -> Self {
Self {
base: PrinterBase { id: id.into(), name: name.into(), host: host.into() },
api_key: api_key.into(),
flavour,
}
}
}
impl GenericPrinter for OctoPrintPrinter {
/// OVERRIDE: real version is `PUT /api/v1/job/{id}/pause`, `/resume`, or
/// `DELETE /api/v1/job/{id}` to cancel - PrusaLink addresses the running
/// job by id rather than the printer, so a real implementation has to
/// fetch the current job first.
async fn control_print(&mut self, action: super::PrintControl) -> Result<(), PrinterError> {
println!("[{}] {} job control: {}", self.base.name, self.flavour.as_str(), action.as_str());
Ok(())
}
fn base(&self) -> &PrinterBase {
&self.base
}
/// OVERRIDE: a completely different body from Bambu's - PrusaLink is a
/// plain REST API (real version: `GET /api/v1/status` with the API
/// key, see `adapters::prusalink`), no persistent session to hold.
async fn connect(&mut self) -> Result<(), PrinterError> {
println!("[{}] connecting over HTTP ({})", self.base.name, self.flavour.as_str());
Ok(())
}
async fn publish(&mut self, _payload: String) -> Result<(), PrinterError> {
// TODO: add publishing via HTTP
Ok(())
}
/// OVERRIDE: gcode via PrusaLink's command-injection endpoint - same
/// underlying M106 as Bambu, reusing the shared percent->PWM
/// conversion, but sent over HTTP instead of MQTT.
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> {
let pwm = super::percent_to_pwm(percent);
println!("[{}] POST /api/printer/command {{\"commands\":[\"M106 S{pwm}\"]}}", self.base.name);
Ok(())
}
/// OVERRIDE: real version is `PUT /api/v1/files/local/{file_name}` with
/// `data` as the request body - PrusaLink stores it on the printer's own
/// SD card, same "upload to storage the firmware owns" model as Bambu.
async fn upload_project_file(&mut self, file_name: String, data: Vec<u8>) -> Result<(), PrinterError> {
println!("[{}] PUT /api/v1/files/local/{file_name} ({} bytes)", self.base.name, data.len());
Ok(())
}
/// OVERRIDE: PrusaLink's command-injection endpoint, one line per call -
/// same endpoint `set_fan_speed` above already uses for M106, just with
/// an arbitrary line instead of a hardcoded one.
async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError> {
println!("[{}] POST /api/printer/command {{\"commands\":[\"{gcode_line}\"]}}", self.base.name);
Ok(())
}
}
/****************** PrusaSerial ******************/
pub struct PrusaSerialPrinter {
+247
View File
@@ -0,0 +1,247 @@
//! Adding and removing printers on a running gateway, on the console's say-so.
//!
//! The access code arrives in a `provision_printer` message, is checked by
//! actually connecting, and is then written to the local store (src/store.rs).
//! It is never logged and never sent anywhere. The cloud does not keep it -
//! it relayed it - so this is the only place it exists.
//!
//! Connect first, store second: a printer that cannot be reached with the
//! code the operator typed is a typo to report now, not a row to discover
//! later in a fleet that quietly never comes up.
use std::time::Duration;
use tracing::info;
use crate::fleet;
use crate::printer::GenericPrinter;
use crate::store::Store;
use crate::uplink::SharedFleet;
use crate::uplink::protocol::ProvisionPrinter;
/// Bambu's protocol auto-detection can spend several seconds on a first
/// attempt, and an operator is watching a spinner.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
/// Ids end up in MQTT topics, file names (pinned certificates) and log
/// lines, so they are held to a small safe alphabet.
fn valid_id(id: &str) -> bool {
!id.is_empty() && id.len() <= 64 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
pub async fn add(fleet: &SharedFleet, store: &Store, request: ProvisionPrinter) -> Result<(), String> {
if !valid_id(&request.id) {
return Err("printer id must be 1-64 letters, digits, '-' or '_'".into());
}
if fleet.lock().await.iter().any(|p| p.base().id == request.id) {
return Err(format!("this gateway already has a printer with id '{}'", request.id));
}
let entry = fleet::PrinterEntry {
id: request.id,
name: request.name,
vendor: request.vendor,
host: request.host,
access_code: request.access_code,
sn: request.sn,
api_key: request.api_key,
port: None,
baud_rate: 115_200,
ca_cert_path: None,
require_valid_cert: true,
};
// Built (which checks the required fields are present and non-blank) and
// connected before the fleet lock is taken: connecting can take seconds,
// and the lock is what telemetry waits on.
let mut printer = fleet::build(entry.clone()).map_err(|err| err.to_string())?;
match tokio::time::timeout(CONNECT_TIMEOUT, printer.connect()).await {
Ok(Ok(())) => {}
Ok(Err(err)) => return Err(format!("could not connect to the printer: {err}")),
Err(_) => return Err("the printer did not answer in time - check the address and that it is on".into()),
}
store.save_printer(&entry).map_err(|err| format!("connected, but could not save it locally: {err}"))?;
info!(printer = %entry.id, "printer added from the console");
let mut fleet = fleet.lock().await;
// Re-checked under the lock: two requests for one id can both pass the
// check above while the first is still connecting.
if fleet.iter().any(|p| p.base().id == entry.id) {
return Err(format!("this gateway already has a printer with id '{}'", entry.id));
}
fleet.push(printer);
Ok(())
}
pub async fn remove(fleet: &SharedFleet, store: &Store, printer_id: &str) -> Result<(), String> {
let stored = store.remove_printer(printer_id).map_err(|err| err.to_string())?;
let mut fleet = fleet.lock().await;
let before = fleet.len();
fleet.retain(|p| p.base().id != printer_id);
if !stored && fleet.len() == before {
return Err(format!("no printer with id '{printer_id}' on this gateway"));
}
info!(printer = %printer_id, "printer removed from the console");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ids_are_a_small_safe_alphabet() {
assert!(valid_id("p1p"));
assert!(valid_id("Printer_2-b"));
assert!(!valid_id(""));
assert!(!valid_id("../etc/passwd"));
assert!(!valid_id("has space"));
assert!(!valid_id(&"a".repeat(65)));
}
}
#[cfg(test)]
mod behaviour {
use std::sync::Arc;
use super::*;
fn request(id: &str) -> ProvisionPrinter {
ProvisionPrinter {
id: id.into(),
name: "Bench P1".into(),
vendor: "bambu".into(),
// Nothing listens here, so a connect attempt is refused at once.
host: Some("127.0.0.1".into()),
access_code: Some("12345678".into()),
sn: Some("01P00A000000001".into()),
api_key: None,
}
}
fn store() -> (Store, std::path::PathBuf) {
let path = std::env::temp_dir().join(format!("continuum-provision-test-{}-{}.db", std::process::id(), rand_suffix()));
(Store::open(&path).unwrap(), path)
}
fn rand_suffix() -> u128 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
}
fn empty_fleet() -> SharedFleet {
Arc::new(tokio::sync::Mutex::new(Vec::new()))
}
#[tokio::test]
async fn a_bad_id_is_refused_before_anything_happens() {
let (store, path) = store();
let err = add(&empty_fleet(), &store, request("../x")).await.unwrap_err();
assert!(err.contains("printer id"), "{err}");
assert!(store.printers().unwrap().is_empty());
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn a_blank_credential_is_named_and_nothing_is_saved() {
let (store, path) = store();
let mut req = request("p1");
req.access_code = Some(" ".into());
let err = add(&empty_fleet(), &store, req).await.unwrap_err();
assert!(err.contains("access_code"), "{err}");
assert!(store.printers().unwrap().is_empty());
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn a_printer_that_cannot_be_reached_is_not_saved() {
let (store, path) = store();
let fleet = empty_fleet();
let err = add(&fleet, &store, request("p1")).await.unwrap_err();
assert!(err.contains("could not connect") || err.contains("did not answer"), "{err}");
assert!(store.printers().unwrap().is_empty(), "a failed add must not leave a stored printer");
assert!(fleet.lock().await.is_empty());
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn the_error_never_contains_the_access_code() {
let (store, path) = store();
let err = add(&empty_fleet(), &store, request("p1")).await.unwrap_err();
assert!(!err.contains("12345678"), "{err}");
let _ = std::fs::remove_file(path);
}
#[tokio::test]
async fn removing_forgets_a_printer_in_both_places() {
let (store, path) = store();
let entry = crate::fleet::PrinterEntry {
id: "p1".into(),
name: "Bench".into(),
vendor: "bambu".into(),
host: Some("127.0.0.1".into()),
access_code: Some("12345678".into()),
sn: Some("01P00A000000001".into()),
api_key: None,
port: None,
baud_rate: 115_200,
ca_cert_path: None,
require_valid_cert: true,
};
store.save_printer(&entry).unwrap();
let fleet = empty_fleet();
fleet.lock().await.push(crate::fleet::build(entry).unwrap());
remove(&fleet, &store, "p1").await.unwrap();
assert!(fleet.lock().await.is_empty());
assert!(store.printers().unwrap().is_empty());
let err = remove(&fleet, &store, "p1").await.unwrap_err();
assert!(err.contains("no printer"), "{err}");
let _ = std::fs::remove_file(path);
}
/// The success path, using the OctoPrint client against a mock server,
/// because no real machine is needed to connect to one.
/// What this proves is the plumbing, not a vendor: a connected printer is
/// saved, joins the running fleet, and is still there after a "restart"
/// that reopens the store and rebuilds the fleet from it.
#[tokio::test]
async fn an_added_printer_joins_the_fleet_and_survives_a_restart() {
let (store, path) = store();
let fleet = empty_fleet();
let mock = crate::printer::mock_http::MockServer::start().await;
mock.answer("GET /api/job", 200, r#"{"state":"Operational","progress":{}}"#);
let mut req = request("octo1");
req.host = Some(mock.host());
req.vendor = "octoprint".into();
req.sn = None;
req.access_code = None;
req.api_key = Some("key-123".into());
add(&fleet, &store, req.clone()).await.unwrap();
assert!(fleet.lock().await.iter().any(|p| p.base().id == "octo1"));
// Same id again: refused, and the first is untouched.
let again = add(&fleet, &store, req).await.unwrap_err();
assert!(again.contains("already has"), "{again}");
assert_eq!(fleet.lock().await.len(), 1);
// "Restart": reopen the store, rebuild what main would.
drop(store);
let reopened = Store::open(&path).unwrap();
let rebuilt: Vec<_> = reopened.printers().unwrap().into_iter().map(|e| crate::fleet::build(e).unwrap()).collect();
assert_eq!(rebuilt.len(), 1);
assert_eq!(rebuilt[0].base().id, "octo1");
let _ = std::fs::remove_file(path);
}
#[test]
fn the_wire_message_parses_and_its_debug_hides_the_credential() {
let json = r#"{"type":"provision_printer","request_id":"r1","printer":{"id":"p1","name":"Bench","vendor":"bambu","host":"10.0.0.5","access_code":"98765432","sn":"SN1"}}"#;
let message: crate::uplink::ServerMessage = serde_json::from_str(json).unwrap();
let shown = format!("{message:?}");
assert!(shown.contains("p1") && shown.contains("<redacted>"), "{shown}");
assert!(!shown.contains("98765432"), "the access code reached a Debug string: {shown}");
}
}
+338
View File
@@ -0,0 +1,338 @@
//! The gateway's own database: printers an operator added from the console.
//!
//! Why the gateway keeps these itself and not the cloud: a printer's access
//! code is a credential for a machine on someone's LAN, and the platform's
//! rule is that those never rest in the cloud. The console sends the code
//! once, over the uplink, and this is where it lands. It also means the
//! fleet survives the API being unreachable - the gateway starts, connects
//! its printers and keeps reporting locally from this alone.
//!
//! SQLite, one file. The file holds credentials, so on Unix it is created
//! owner-only (0600) before SQLite touches it; SQLite gives its journal files
//! the same mode as the main one.
//!
//! `printers.toml` still works and is read alongside this; see `main.rs`.
use std::path::Path;
use std::sync::Mutex;
use anyhow::Context;
use rusqlite::{Connection, params};
use crate::fleet::PrinterEntry;
pub struct Store {
// rusqlite's `Connection` is not `Sync`, so it cannot be shared between
// tasks directly. Wrapping it in a `Mutex` is the plain fix: one caller
// at a time, which is plenty for a few writes a day.
conn: Mutex<Connection>,
}
impl Store {
pub fn open(path: &Path) -> anyhow::Result<Self> {
if let Some(dir) = path.parent().filter(|dir| !dir.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).with_context(|| format!("creating {dir:?} for the local store"))?;
}
create_private(path)?;
let conn = Connection::open(path).with_context(|| format!("opening the local store at {path:?}"))?;
// Each printer is a JSON document keyed by its id. The id is the
// gateway's own printer id - the one telemetry is reported under -
// so "which printer" has a single answer everywhere.
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS printers (
id TEXT PRIMARY KEY,
json TEXT NOT NULL
);
-- What happened to jobs the cloud handed this gateway, until the
-- cloud has acknowledged it (see `jobs`). Survives a restart.
CREATE TABLE IF NOT EXISTS outbox (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id TEXT NOT NULL,
event TEXT NOT NULL,
detail TEXT,
at_ms INTEGER NOT NULL
);
-- The job each printer is working on, as far as this gateway knows.
CREATE TABLE IF NOT EXISTS active_jobs (
printer_id TEXT PRIMARY KEY,
job_id TEXT NOT NULL,
since_ms INTEGER NOT NULL,
started INTEGER NOT NULL DEFAULT 0
);",
)?;
Ok(Self { conn: Mutex::new(conn) })
}
/// Adds a printer, or replaces the one with the same id.
pub fn save_printer(&self, entry: &PrinterEntry) -> anyhow::Result<()> {
let json = serde_json::to_string(entry)?;
let conn = self.conn.lock().expect("store lock poisoned");
conn.execute(
"INSERT INTO printers (id, json) VALUES (?1, ?2)
ON CONFLICT(id) DO UPDATE SET json = excluded.json",
params![entry.id, json],
)?;
Ok(())
}
/// Forgets a printer. True if there was one.
pub fn remove_printer(&self, id: &str) -> anyhow::Result<bool> {
let conn = self.conn.lock().expect("store lock poisoned");
Ok(conn.execute("DELETE FROM printers WHERE id = ?1", params![id])? > 0)
}
/// Every stored printer, oldest first.
pub fn printers(&self) -> anyhow::Result<Vec<PrinterEntry>> {
let conn = self.conn.lock().expect("store lock poisoned");
let mut statement = conn.prepare("SELECT json FROM printers ORDER BY rowid")?;
let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
let mut out = Vec::new();
for json in rows {
out.push(serde_json::from_str(&json?).context("a stored printer no longer parses")?);
}
Ok(out)
}
}
/// A job outcome waiting for the cloud's acknowledgement.
#[derive(Debug, Clone, PartialEq)]
pub struct OutboxEvent {
pub id: i64,
pub job_id: String,
pub event: String,
pub detail: Option<String>,
pub at_ms: i64,
}
/// A job a printer was handed and has not yet finished with.
#[derive(Debug, Clone, PartialEq)]
pub struct ActiveJob {
pub printer_id: String,
pub job_id: String,
pub since_ms: i64,
pub started: bool,
}
impl Store {
/// Queues an outcome to tell the cloud. Returns its id.
pub fn outbox_push(&self, job_id: &str, event: &str, detail: Option<&str>, at_ms: i64) -> anyhow::Result<i64> {
let conn = self.conn.lock().expect("store lock poisoned");
conn.execute(
"INSERT INTO outbox (job_id, event, detail, at_ms) VALUES (?1, ?2, ?3, ?4)",
params![job_id, event, detail, at_ms],
)?;
Ok(conn.last_insert_rowid())
}
/// Everything the cloud has not acknowledged, oldest first.
pub fn outbox_pending(&self) -> anyhow::Result<Vec<OutboxEvent>> {
let conn = self.conn.lock().expect("store lock poisoned");
let mut statement =
conn.prepare("SELECT id, job_id, event, detail, at_ms FROM outbox ORDER BY id LIMIT 500")?;
let rows = statement.query_map([], |row| {
Ok(OutboxEvent {
id: row.get(0)?,
job_id: row.get(1)?,
event: row.get(2)?,
detail: row.get(3)?,
at_ms: row.get(4)?,
})
})?;
Ok(rows.collect::<Result<_, _>>()?)
}
pub fn outbox_ack(&self, id: i64) -> anyhow::Result<()> {
let conn = self.conn.lock().expect("store lock poisoned");
conn.execute("DELETE FROM outbox WHERE id = ?1", params![id])?;
Ok(())
}
/// Records that `printer_id` was handed `job_id`, replacing any earlier job.
pub fn active_job_set(&self, printer_id: &str, job_id: &str, now_ms: i64) -> anyhow::Result<()> {
let conn = self.conn.lock().expect("store lock poisoned");
conn.execute(
"INSERT INTO active_jobs (printer_id, job_id, since_ms, started) VALUES (?1, ?2, ?3, 0)
ON CONFLICT(printer_id) DO UPDATE SET job_id = excluded.job_id, since_ms = excluded.since_ms, started = 0",
params![printer_id, job_id, now_ms],
)?;
Ok(())
}
pub fn active_job_started(&self, printer_id: &str) -> anyhow::Result<()> {
let conn = self.conn.lock().expect("store lock poisoned");
conn.execute("UPDATE active_jobs SET started = 1 WHERE printer_id = ?1", params![printer_id])?;
Ok(())
}
pub fn active_job_clear(&self, printer_id: &str) -> anyhow::Result<()> {
let conn = self.conn.lock().expect("store lock poisoned");
conn.execute("DELETE FROM active_jobs WHERE printer_id = ?1", params![printer_id])?;
Ok(())
}
pub fn active_jobs(&self) -> anyhow::Result<Vec<ActiveJob>> {
let conn = self.conn.lock().expect("store lock poisoned");
let mut statement = conn.prepare("SELECT printer_id, job_id, since_ms, started FROM active_jobs")?;
let rows = statement.query_map([], |row| {
Ok(ActiveJob {
printer_id: row.get(0)?,
job_id: row.get(1)?,
since_ms: row.get(2)?,
started: row.get::<_, i64>(3)? != 0,
})
})?;
Ok(rows.collect::<Result<_, _>>()?)
}
}
/// Creates the file empty and owner-only if it does not exist yet.
#[cfg(unix)]
fn create_private(path: &Path) -> anyhow::Result<()> {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.open(path)
.with_context(|| format!("creating the local store {path:?}"))?;
Ok(())
}
/// Windows: the file inherits its folder's permissions, which for the
/// per-user data directory is already that user's. Nothing to set here.
#[cfg(not(unix))]
fn create_private(_path: &Path) -> anyhow::Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(id: &str, access_code: &str) -> PrinterEntry {
PrinterEntry {
id: id.into(),
name: format!("Printer {id}"),
vendor: "bambu".into(),
host: Some("192.168.0.50".into()),
access_code: Some(access_code.into()),
sn: Some("01P00A000000000".into()),
api_key: None,
port: None,
baud_rate: 115_200,
ca_cert_path: None,
require_valid_cert: true,
}
}
/// A throwaway directory, removed when dropped. Hand-rolled rather than
/// pulling in a crate for four tests.
struct TempDir(std::path::PathBuf);
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn temp_db() -> (TempDir, std::path::PathBuf) {
use std::sync::atomic::{AtomicU32, Ordering};
static NEXT: AtomicU32 = AtomicU32::new(0);
let dir = std::env::temp_dir().join(format!(
"continuum-store-test-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
let path = dir.join("nested").join("continuum.db");
(TempDir(dir), path)
}
#[test]
fn what_was_saved_comes_back_after_reopening() {
let (_dir, path) = temp_db();
Store::open(&path).unwrap().save_printer(&entry("a", "11111111")).unwrap();
let reopened = Store::open(&path).unwrap();
let printers = reopened.printers().unwrap();
assert_eq!(printers.len(), 1);
assert_eq!(printers[0].id, "a");
assert_eq!(printers[0].access_code.as_deref(), Some("11111111"));
assert_eq!(printers[0].sn.as_deref(), Some("01P00A000000000"));
}
#[test]
fn saving_the_same_id_replaces_it_and_order_is_oldest_first() {
let (_dir, path) = temp_db();
let store = Store::open(&path).unwrap();
store.save_printer(&entry("a", "old")).unwrap();
store.save_printer(&entry("b", "bbb")).unwrap();
store.save_printer(&entry("a", "new")).unwrap();
let printers = store.printers().unwrap();
let ids: Vec<_> = printers.iter().map(|p| p.id.as_str()).collect();
assert_eq!(ids, ["a", "b"]);
assert_eq!(printers[0].access_code.as_deref(), Some("new"));
}
#[test]
fn removing_reports_whether_anything_was_there() {
let (_dir, path) = temp_db();
let store = Store::open(&path).unwrap();
store.save_printer(&entry("a", "x")).unwrap();
assert!(store.remove_printer("a").unwrap());
assert!(!store.remove_printer("a").unwrap());
assert!(store.printers().unwrap().is_empty());
}
#[cfg(unix)]
#[test]
fn the_file_holding_credentials_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let (_dir, path) = temp_db();
let store = Store::open(&path).unwrap();
store.save_printer(&entry("a", "secret")).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
#[test]
fn a_corrupt_file_is_an_error_not_an_empty_fleet() {
let (_dir, path) = temp_db();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, b"this is not a sqlite database at all, just text").unwrap();
assert!(Store::open(&path).is_err());
}
#[test]
fn an_unacknowledged_event_survives_a_restart_and_an_acknowledged_one_does_not() {
let (_dir, path) = temp_db();
let store = Store::open(&path).unwrap();
let first = store.outbox_push("job-1", "finished", None, 10).unwrap();
let second = store.outbox_push("job-2", "failed", Some("download failed"), 20).unwrap();
store.outbox_ack(first).unwrap();
drop(store);
let reopened = Store::open(&path).unwrap();
let pending = reopened.outbox_pending().unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].id, second);
assert_eq!(pending[0].detail.as_deref(), Some("download failed"));
}
#[test]
fn a_printer_has_one_active_job_and_a_new_one_replaces_it() {
let (_dir, path) = temp_db();
let store = Store::open(&path).unwrap();
store.active_job_set("p1", "job-1", 100).unwrap();
store.active_job_started("p1").unwrap();
assert!(store.active_jobs().unwrap()[0].started);
store.active_job_set("p1", "job-2", 200).unwrap();
let jobs = store.active_jobs().unwrap();
assert_eq!(jobs, vec![ActiveJob { printer_id: "p1".into(), job_id: "job-2".into(), since_ms: 200, started: false }]);
store.active_job_clear("p1").unwrap();
assert!(store.active_jobs().unwrap().is_empty());
}
}
+107
View File
@@ -0,0 +1,107 @@
//! Getting from the gateway's long-lived credential to the short-lived one
//! the socket wants.
//!
//! The cloud hands a farm's owner an *edge proxy token*, which is what goes in
//! this gateway's settings as `gateway_token`. It does not open the socket
//! directly: the upgrade wants an *edge JWT*, which the cloud signs for an
//! hour in exchange for the proxy token (`POST /auth/edge-token`). So every
//! connection attempt starts by trading, which is also why a gateway that
//! has been up for days is not locked out when the first JWT expires - each
//! reconnect gets a fresh one.
use std::time::Duration;
use anyhow::Context;
use serde::Deserialize;
use crate::config::Config;
const EXCHANGE_TIMEOUT: Duration = Duration::from_secs(15);
const SOCKET_PATH: &str = "/ws/edge/v1";
/// Where to trade the token, derived from the socket URL so there is one
/// address to configure, not two that can disagree.
pub fn exchange_url(uplink_url: &str) -> anyhow::Result<String> {
let (scheme, rest) = if let Some(rest) = uplink_url.strip_prefix("wss://") {
("https://", rest)
} else if let Some(rest) = uplink_url.strip_prefix("ws://") {
("http://", rest)
} else {
anyhow::bail!("the uplink URL must start with ws:// or wss://: {uplink_url}");
};
let base = rest
.strip_suffix(SOCKET_PATH)
.with_context(|| format!("the uplink URL must end with {SOCKET_PATH}: {uplink_url}"))?;
Ok(format!("{scheme}{base}/auth/edge-token"))
}
#[derive(Deserialize)]
struct Exchanged {
token: String,
}
/// Trades `gateway_token` for an edge JWT.
///
/// A 401 is reported as what it means - the cloud does not accept this
/// farm/token pair - because retrying will not fix it, and the usual cause is
/// a token that was rotated in the console while this gateway kept the old
/// one.
pub async fn fetch_edge_token(config: &Config) -> anyhow::Result<String> {
let url = exchange_url(&config.uplink_url)?;
let response = reqwest::Client::builder()
.timeout(EXCHANGE_TIMEOUT)
.build()?
.post(&url)
// Built by hand: the `json` feature of reqwest is off on purpose (see
// Cargo.toml), and this is one small body.
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"farmId": config.farm_id,
"edgeProxyToken": config.gateway_token,
})
.to_string(),
)
.send()
.await
.with_context(|| format!("could not reach {url} to get an edge token"))?;
match response.status() {
status if status.is_success() => {
let body = response.bytes().await?;
Ok(serde_json::from_slice::<Exchanged>(&body).context("the cloud's edge token reply was not understood")?.token)
}
reqwest::StatusCode::UNAUTHORIZED => {
anyhow::bail!("the cloud does not accept this farm_id / gateway_token - was the token rotated in the console?")
}
status => anyhow::bail!("the cloud answered {status} when asked for an edge token"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_exchange_address_follows_the_socket_address() {
assert_eq!(
exchange_url("wss://api.continuum.local/ws/edge/v1").unwrap(),
"https://api.continuum.local/auth/edge-token"
);
assert_eq!(
exchange_url("ws://localhost:3040/ws/edge/v1").unwrap(),
"http://localhost:3040/auth/edge-token"
);
// A prefix in front of the API survives.
assert_eq!(
exchange_url("wss://example.com/api/ws/edge/v1").unwrap(),
"https://example.com/api/auth/edge-token"
);
}
#[test]
fn an_address_it_cannot_follow_is_an_error_not_a_guess() {
assert!(exchange_url("https://api/ws/edge/v1").is_err());
assert!(exchange_url("wss://api/somewhere/else").is_err());
}
}
+21 -1
View File
@@ -26,6 +26,26 @@ pub const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(180);
/// up the whole budget above.
pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
/// Dev-only escape hatch for a local object store (MinIO) that speaks plain
/// HTTP: needs `CONTINUUM_ALLOW_INSECURE_LOCAL_DOWNLOADS=true` AND a loopback
/// or private-network (RFC 1918) address literal, so a presigned URL can never
/// cross a public network in the clear.
fn insecure_local_allowed(url: &reqwest::Url) -> bool {
use std::net::IpAddr;
if url.scheme() != "http"
|| !std::env::var("CONTINUUM_ALLOW_INSECURE_LOCAL_DOWNLOADS").is_ok_and(|v| v == "true")
{
return false;
}
let Some(ip) = url.host_str().and_then(|h| h.trim_matches(['[', ']']).parse::<IpAddr>().ok()) else {
return false;
};
match ip {
IpAddr::V4(v4) => v4.is_loopback() || v4.is_private(),
IpAddr::V6(v6) => v6.is_loopback(),
}
}
/// Downloads `url` fully into memory, or says why not.
///
/// The error is a `String` naming the failure; the caller prefixes the stage.
@@ -39,7 +59,7 @@ pub async fn fetch_job_file_capped(url: &str, max_bytes: u64) -> Result<Vec<u8>,
let parsed = reqwest::Url::parse(url).map_err(|err| format!("not a valid URL: {err}"))?;
// A presigned URL carries its authority in the query string; sending it
// over plain HTTP would expose it to anyone on the path.
if parsed.scheme() != "https" {
if parsed.scheme() != "https" && !insecure_local_allowed(&parsed) {
return Err(format!("refusing non-HTTPS URL (scheme '{}')", parsed.scheme()));
}
+238 -32
View File
@@ -26,12 +26,14 @@
//! readings for no consumer. See PLAN.md for what would actually be worth
//! buffering.
mod auth;
mod download;
mod webrtc;
mod protocol;
pub mod protocol;
pub use protocol::{ClientMessage, PrinterCommand, PrinterTelemetry, ServerMessage};
pub use protocol::{ClientMessage, DiscoveredPrinter, PrinterCommand, PrinterTelemetry, ServerMessage};
use std::collections::HashSet;
use std::sync::Arc;
use std::time::Duration;
@@ -42,6 +44,9 @@ use tokio_tungstenite::tungstenite::handshake::client::{Request, generate_key};
use tracing::{info, warn};
use crate::config::Config;
use crate::provision;
use crate::store::Store;
use protocol::ProvisionPrinter;
use crate::printer::{GenericPrinter, PrinterHandle};
/// The websocket, once connected. Named because `connect` has to hand it to
@@ -78,6 +83,8 @@ const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(15);
/// a lot of traffic to describe a machine whose state changes on the scale of
/// a layer, so this samples the cached report instead of chasing every push.
const TELEMETRY_INTERVAL: Duration = Duration::from_secs(5);
/// How often the job-event outbox is looked at for anything new to send.
const OUTBOX_INTERVAL: Duration = Duration::from_secs(3);
/// The printer fleet, shared between `main` (which owns it and keeps every
/// connection alive) and this module (which reads status and dispatches
@@ -119,13 +126,30 @@ fn host_header(url: &str) -> anyhow::Result<String> {
Ok(authority.to_string())
}
/// The queue a task elsewhere puts a message on to have the uplink send it.
///
/// Created by the caller rather than inside [`run`] so that a task which is
/// not the uplink - printer discovery, today - can hold the sender without
/// the uplink having to know what those tasks are. The property that matters
/// is unchanged either way: it outlives any one connection, so a message
/// produced while the socket is down waits for the next socket instead of
/// being dropped, and the sender living for the whole program is what stops
/// `recv()` from ever returning `None`.
pub fn channel() -> (
tokio::sync::mpsc::Sender<ClientMessage>,
tokio::sync::mpsc::Receiver<ClientMessage>,
) {
tokio::sync::mpsc::channel(OUTBOUND_CAPACITY)
}
/// Runs forever: connect, serve the connection until it drops, wait, retry.
pub async fn run(config: Config, fleet: SharedFleet) {
// Created out here, not per connection, so a message produced while the
// socket is down waits for the next one instead of being dropped. The
// sender is kept for the whole program's life, which is also what stops
// `recv()` below from ever returning `None`.
let (outbound, mut inbox) = tokio::sync::mpsc::channel::<ClientMessage>(OUTBOUND_CAPACITY);
pub async fn run(
config: Config,
fleet: SharedFleet,
store: Option<Arc<Store>>,
outbound: tokio::sync::mpsc::Sender<ClientMessage>,
mut inbox: tokio::sync::mpsc::Receiver<ClientMessage>,
) {
let mut delay = RETRY_DELAY;
loop {
@@ -140,7 +164,7 @@ pub async fn run(config: Config, fleet: SharedFleet) {
// would not distinguish a connection that lived for an hour
// from one that failed to open.
delay = RETRY_DELAY;
if let Err(err) = serve(ws_stream, &config, &fleet, &outbound, &mut inbox).await {
if let Err(err) = serve(ws_stream, &config, &fleet, store.as_ref(), &outbound, &mut inbox).await {
warn!(?err, "uplink connection dropped, reconnecting in {delay:?}");
}
}
@@ -156,6 +180,10 @@ pub async fn run(config: Config, fleet: SharedFleet) {
/// handshake from a connection that opened and later dropped; they want
/// different retry behaviour.
async fn connect(config: &Config) -> anyhow::Result<WsStream> {
// A fresh edge JWT per attempt: it lives an hour, so one fetched at
// startup would lock a long-running gateway out on its first reconnect.
let edge_token = auth::fetch_edge_token(config).await?;
// The edge token goes on the upgrade request, not in the first message.
// The backend verifies it before the socket exists, so a bad token is an
// HTTP 401 here rather than a connection that opens and is then closed -
@@ -164,7 +192,7 @@ async fn connect(config: &Config) -> anyhow::Result<WsStream> {
// those tools already know to redact.
let request = Request::builder()
.uri(&config.uplink_url)
.header("Authorization", format!("Bearer {}", config.gateway_token))
.header("Authorization", format!("Bearer {edge_token}"))
// tokio-tungstenite fills these in for a plain URL, but not once we
// hand it a request we built ourselves - and a handshake missing any
// of them is rejected by the server rather than ignored.
@@ -184,6 +212,7 @@ async fn serve(
ws_stream: WsStream,
config: &Config,
fleet: &SharedFleet,
store: Option<&Arc<Store>>,
outbound: &tokio::sync::mpsc::Sender<ClientMessage>,
inbox: &mut tokio::sync::mpsc::Receiver<ClientMessage>,
) -> anyhow::Result<()> {
@@ -199,9 +228,34 @@ async fn serve(
let mut heartbeat = tokio::time::interval(HEARTBEAT_INTERVAL);
let mut telemetry = tokio::time::interval(TELEMETRY_INTERVAL);
let mut seq: u64 = 0;
// Outbox events already written to THIS socket. Per connection on
// purpose: after a reconnect everything the cloud has not acknowledged
// goes out again, which is the point of keeping them.
let mut outbox_tick = tokio::time::interval(OUTBOX_INTERVAL);
let mut sent: HashSet<i64> = HashSet::new();
loop {
tokio::select! {
_ = outbox_tick.tick() => {
let Some(store) = store else { continue };
let pending = match store.outbox_pending() {
Ok(pending) => pending,
Err(err) => { warn!(%err, "could not read the job-event outbox"); continue }
};
let unsent: Vec<_> = pending.into_iter().filter(|e| !sent.contains(&e.id)).collect();
for event in unsent {
let msg = ClientMessage::JobEvent {
event_id: event.id,
job_id: event.job_id,
event: event.event,
detail: event.detail,
at_ms: event.at_ms,
};
write.send(Message::Text(serde_json::to_string(&msg)?)).await?;
sent.insert(event.id);
}
}
_ = heartbeat.tick() => {
seq += 1;
let msg = ClientMessage::Heartbeat { seq };
@@ -249,6 +303,7 @@ async fn serve(
command,
command_id.clone(),
outbound.clone(),
store.cloned(),
);
ClientMessage::CommandResult {
command_id, ok: true, error: None,
@@ -290,13 +345,39 @@ async fn serve(
outbound.clone(),
);
}
// Spawned: a connect, a TLS handshake and a frame
// from the printer take seconds, and awaiting that
// here would stall heartbeats and telemetry.
Ok(ServerMessage::CameraSnapshotRequest { request_id, printer_id }) => {
spawn_snapshot(fleet.clone(), request_id, printer_id, outbound.clone());
}
// Spawned: connecting to the new printer can take
// many seconds, and awaiting it here would stall
// heartbeats and telemetry for the whole farm.
Ok(ServerMessage::ProvisionPrinter { request_id, printer }) => {
spawn_provision(fleet.clone(), store.cloned(), request_id, Some(printer), outbound.clone());
}
Ok(ServerMessage::JobEventAck { event_id }) => {
sent.remove(&event_id);
if let Some(store) = store
&& let Err(err) = store.outbox_ack(event_id)
{
warn!(%err, event_id, "could not clear an acknowledged job event");
}
}
Ok(ServerMessage::RemovePrinter { request_id, printer_id }) => {
spawn_remove(fleet.clone(), store.cloned(), request_id, printer_id, outbound.clone());
}
Ok(other) => info!(?other, "received from cloud"),
// Loudly. This used to be an `if let Ok(..)` with
// no else, which is the same silent-drop bug that
// hid a real `print.push_status` failure for
// weeks - and here it would mean commands
// vanishing with the cloud never being told.
Err(err) => warn!(%err, payload = %text, "unparseable message from cloud, ignoring"),
// The payload is NOT logged. A `provision_printer` message
// carries an access code, and one that failed to parse is
// exactly the message that would end up here.
Err(err) => warn!(%err, bytes = text.len(), "unparseable message from cloud, ignoring"),
}
}
Some(Ok(Message::Close(_))) | None => return Ok(()),
@@ -330,29 +411,28 @@ async fn collect_telemetry(fleet: &SharedFleet) -> Vec<PrinterTelemetry> {
for printer in fleet.iter() {
let base = printer.base();
let Some(report) = printer.print_status().await else {
let Some(status) = printer.status().await else {
continue;
};
// Only Bambu has a camera report to derive these from.
let report = printer.print_status().await;
out.push(PrinterTelemetry {
printer_id: base.id.clone(),
name: base.name.clone(),
state: report.gcode_state.clone(),
percent: report.mc_percent,
stage: report.stg_cur,
layer: report.layer_num,
total_layers: report.total_layer_num,
remaining_minutes: report.mc_remaining_time,
// An idle printer reports `""` rather than omitting the field;
// empty-string-as-absent is worth normalising here so the cloud
// doesn't have to know that.
job_name: report.subtask_name.clone().filter(|s| !s.is_empty()),
nozzle_temp: report.nozzle_temper,
nozzle_target: report.nozzle_target_temper,
bed_temp: report.bed_temper,
bed_target: report.bed_target_temper,
print_error: report.print_error,
active_alerts: report.hms.as_ref().map(|h| h.len()),
state: status.state,
percent: status.percent,
stage: status.stage,
layer: status.layer,
total_layers: status.total_layers,
remaining_minutes: status.remaining_minutes,
job_name: status.job_name,
nozzle_temp: status.nozzle_temp,
nozzle_target: status.nozzle_target,
bed_temp: status.bed_temp,
bed_target: status.bed_target,
print_error: status.print_error,
active_alerts: status.active_alerts,
// Derived from the same reported URL `camera::sources` builds
// the go2rtc config from, so a printer is advertised as
// streamable here only if it is also actually being served
@@ -360,9 +440,15 @@ async fn collect_telemetry(fleet: &SharedFleet) -> Vec<PrinterTelemetry> {
// the rule means the two cannot drift apart.
camera_stream: crate::camera::source_for(
printer,
report.ipcam.as_ref().and_then(|c| c.rtsp_url.as_deref()),
report.as_ref().and_then(|r| r.ipcam.as_ref()).and_then(|c| c.rtsp_url.as_deref()),
)
.map(|source| source.stream_name),
camera_snapshot: crate::camera::wants_snapshot(
printer,
report.as_ref().is_some_and(|r| r.ipcam.is_some()),
report.as_ref().and_then(|r| r.ipcam.as_ref()).and_then(|c| c.rtsp_url.as_deref()),
)
.then_some(true),
});
}
@@ -411,10 +497,31 @@ fn spawn_long_command(
command: PrinterCommand,
command_id: String,
outbound: tokio::sync::mpsc::Sender<ClientMessage>,
store: Option<Arc<Store>>,
) {
// The job is followed from here on, so its outcome is reported even if
// the cloud is unreachable when it happens (see `jobs`).
let job_id = match (&command, &store) {
(PrinterCommand::StartPrint { job_id, .. }, Some(store)) => {
crate::jobs::accepted(store, &printer_id, job_id);
Some(job_id.clone())
}
_ => None,
};
tokio::spawn(async move {
match dispatch(&fleet, &printer_id, command).await {
Ok(()) => info!(%printer_id, "long-running command finished"),
Err(err) if job_id.is_some() => {
// Still logged locally; the cloud hears of it from the
// outbox, which survives it being down right now.
tracing::error!(
%printer_id, %err,
"long-running command failed after the cloud was already told it was accepted"
);
if let (Some(store), Some(job_id)) = (&store, &job_id) {
crate::jobs::start_failed(store, &printer_id, job_id, &err);
}
}
Err(err) => {
// Still logged locally, and still at error level: the queue
// only reaches a cloud that is up, and this is the copy that
@@ -440,6 +547,54 @@ fn spawn_long_command(
});
}
/// Adds a printer off the connection's task and always reports how it went:
/// the console is holding a form open waiting for exactly this.
fn spawn_provision(
fleet: SharedFleet,
store: Option<Arc<Store>>,
request_id: String,
request: Option<ProvisionPrinter>,
outbound: tokio::sync::mpsc::Sender<ClientMessage>,
) {
tokio::spawn(async move {
let result = match (store, request) {
(Some(store), Some(request)) => provision::add(&fleet, &store, request).await,
(None, _) => Err("this gateway has no local store, so it cannot add printers".to_string()),
(_, None) => Err("no printer in the request".to_string()),
};
report(request_id, result, &outbound).await;
});
}
fn spawn_remove(
fleet: SharedFleet,
store: Option<Arc<Store>>,
request_id: String,
printer_id: String,
outbound: tokio::sync::mpsc::Sender<ClientMessage>,
) {
tokio::spawn(async move {
let result = match store {
Some(store) => provision::remove(&fleet, &store, &printer_id).await,
None => Err("this gateway has no local store".to_string()),
};
report(request_id, result, &outbound).await;
});
}
async fn report(request_id: String, result: Result<(), String>, outbound: &tokio::sync::mpsc::Sender<ClientMessage>) {
let msg = match result {
Ok(()) => ClientMessage::CommandResult { command_id: request_id, ok: true, error: None },
Err(error) => {
warn!(%error, "printer request failed");
ClientMessage::CommandResult { command_id: request_id, ok: false, error: Some(error) }
}
};
if outbound.send(msg).await.is_err() {
warn!("uplink is shutting down; the result was not reported");
}
}
/// Answers one WebRTC offer, off this connection's task.
///
/// Always answers, success or failure: the cloud is holding a browser's
@@ -469,6 +624,48 @@ fn spawn_webrtc_negotiation(
});
}
/// Reads one camera frame off this connection's task and reports it.
///
/// The fleet lock is held only to copy out what the read needs (the access
/// code never leaves this process in the message), never across the network.
fn spawn_snapshot(
fleet: SharedFleet,
request_id: String,
printer_id: String,
outbound: tokio::sync::mpsc::Sender<ClientMessage>,
) {
tokio::spawn(async move {
let job = {
let fleet = fleet.lock().await;
match fleet.iter().find(|p| p.base().id == printer_id) {
Some(printer) => printer.snapshot_job().map_err(|err| err.to_string()),
None => Err(format!("no printer '{printer_id}' in this gateway's fleet")),
}
};
let result = match job {
Ok(job) => job.run().await.map_err(|err| err.to_string()),
Err(err) => Err(err),
};
let reply = match result {
Ok(jpeg) => {
use base64::Engine;
ClientMessage::CameraSnapshot {
request_id,
jpeg_base64: Some(base64::engine::general_purpose::STANDARD.encode(jpeg)),
error: None,
}
}
Err(err) => {
warn!(%printer_id, %err, "could not read a camera frame");
ClientMessage::CameraSnapshot { request_id, jpeg_base64: None, error: Some(err) }
}
};
if outbound.send(reply).await.is_err() {
warn!(%printer_id, "uplink is shutting down; the viewer gets no picture");
}
});
}
/// Finds the printer by our own id and runs the command. The error is a
/// `String` rather than a typed error because its only destination is a
/// `CommandResult` going back to the cloud as text.
@@ -512,7 +709,7 @@ async fn dispatch(fleet: &SharedFleet, printer_id: &str, command: PrinterCommand
PrinterCommand::ControlPrint { control } => {
printer.control_print(control).await.map_err(|err| err.to_string())
}
PrinterCommand::StartPrint { job_id, file_name, .. } => {
PrinterCommand::StartPrint { job_id, file_name, ams_slot, .. } => {
let bytes = downloaded.ok_or_else(|| "internal error: start_print reached dispatch with no downloaded file".to_string())?;
let byte_count = bytes.len();
info!(%printer_id, %job_id, %file_name, byte_count, "job file downloaded, sending to printer");
@@ -521,10 +718,19 @@ async fn dispatch(fleet: &SharedFleet, printer_id: &str, command: PrinterCommand
// all as one error - so the stage here is "printer", not finer.
// The printer's own message says which part (mapping, upload of
// the file, or start) it was.
printer
if let PrinterHandle::Bambu(bambu) = &mut *printer {
bambu.set_ams_slot_override(ams_slot);
} else if ams_slot.is_some() {
return Err("this printer cannot be told which AMS slot to use".into());
}
let result = printer
.upload_project_file(file_name, bytes)
.await
.map_err(|err| format!("printer upload/start failed (download of {byte_count} bytes succeeded): {err}"))
.map_err(|err| format!("printer upload/start failed (download of {byte_count} bytes succeeded): {err}"));
if let PrinterHandle::Bambu(bambu) = &mut *printer {
bambu.set_ams_slot_override(None);
}
result
}
}
}
+130 -1
View File
@@ -32,6 +32,17 @@ pub enum ClientMessage {
stream_base_url: Option<String>,
},
Heartbeat { seq: u64 },
/// What happened to a job the cloud handed this gateway. Kept in the
/// store and re-sent on every connection until a `JobEventAck`.
JobEvent {
event_id: i64,
job_id: String,
/// `started`, `finished` or `failed`.
event: String,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<String>,
at_ms: i64,
},
/// One snapshot per printer, on a timer. Sent as a batch rather than a
/// message per printer: a farm has one uplink and many printers, and the
/// cloud wants a coherent picture of the farm at an instant more than it
@@ -56,6 +67,68 @@ pub enum ClientMessage {
/// waiting on this and a viewer otherwise watches a video element that
/// never loads with nothing anywhere saying why.
WebrtcError { session_id: String, message: String },
/// The answer to a `CameraSnapshotRequest`: one JPEG frame, base64, or
/// the reason there is none. Always sent, success or failure - the cloud
/// is holding a viewer's request open for it.
CameraSnapshot {
request_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
jpeg_base64: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
},
/// Printers found on this gateway's own network and USB ports that are
/// *not* in its fleet file, so the console can offer them instead of
/// making an operator walk to a machine and read its IP address off the
/// screen.
///
/// A whole scan result that replaces the last one, not a set of
/// additions: a printer that has gone is reported by its absence. An
/// empty list is therefore meaningful - it says the gateway looked and
/// found nothing, which is different from never having sent one of these
/// at all, and a gateway with discovery off (the default) never does.
DiscoveredPrinters {
printers: Vec<DiscoveredPrinter>,
scanned_at: String,
},
}
/// One candidate, carrying no credential - and it is worth being precise
/// about why not, because this is the field a reader looks for.
///
/// It is not omitted for tidiness. No probe can learn one: a Bambu access
/// code is displayed on the printer's own screen and a PrusaLink API key is
/// in its web UI, and every identity endpoint that would reveal either needs
/// the credential first. So discovery shortens the operator's job to typing
/// one secret, rather than a secret plus an address plus a serial number -
/// and the backend's schema refuses a candidate that carries a credential,
/// so this staying empty is enforced at the far end too.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct DiscoveredPrinter {
/// The `vendor` string this would need in a fleet file.
pub vendor: &'static str,
/// An IP or hostname, or a device path like `/dev/ttyACM0` for a serial
/// candidate. Named `address` rather than `host` because half of them
/// are not hosts.
pub address: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub serial: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
/// A vendor this could equally be; the operator settles it. Today only
/// `prusa_link` against `octoprint`, which nothing observable separates.
#[serde(skip_serializing_if = "Option::is_none")]
pub ambiguous_with: Option<&'static str>,
pub source: &'static str,
/// Whether this build can actually drive the vendor. False is still
/// worth reporting - it answers "what is on this network?" - but the
/// console has to say so, or an entry looks finished when adding it
/// would produce a printer that connects and does nothing.
pub driver_ready: bool,
}
/// A flattened snapshot of one printer, in the terms an operator console
@@ -139,6 +212,11 @@ pub struct PrinterTelemetry {
/// `crate::camera`.
#[serde(skip_serializing_if = "Option::is_none")]
pub camera_stream: Option<String>,
/// True when the camera can only be read as single JPEG frames (a P1/A1)
/// and this gateway will answer a `CameraSnapshotRequest` for it. See
/// `crate::camera::wants_snapshot`.
#[serde(skip_serializing_if = "Option::is_none")]
pub camera_snapshot: Option<bool>,
}
/// What `continuum-backend` sends back over the same connection.
@@ -146,6 +224,8 @@ pub struct PrinterTelemetry {
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerMessage {
HelloAck { session_id: String },
/// The cloud has dealt with a `JobEvent`; the gateway can forget it.
JobEventAck { event_id: i64 },
/// The handshake was rejected - a bad, expired or wrong-farm token. The
/// backend closes the connection right after sending this.
///
@@ -173,6 +253,48 @@ pub enum ServerMessage {
/// and checks its password before CORS, so the unauthenticated preflight
/// a browser always sends is refused - measured, not assumed.
WebrtcOffer { session_id: String, camera_stream: String, sdp: String },
/// Read one JPEG frame from a P1/A1-style camera and answer with a
/// `CameraSnapshot`. `printer_id` is our own id.
CameraSnapshotRequest { request_id: String, printer_id: String },
/// Add a printer to this gateway. Carries the printer's credential, sent
/// once by the console and kept only here - see `provision`. Answered
/// with a `CommandResult` carrying the same id.
ProvisionPrinter { request_id: String, printer: ProvisionPrinter },
/// Forget a printer. Answered like `ProvisionPrinter`.
RemovePrinter { request_id: String, printer_id: String },
}
/// What the console may tell a gateway about a printer to add.
///
/// Deliberately narrower than `fleet::PrinterEntry`: no `ca_cert_path` and no
/// serial `port`, because both name something on the gateway's own disk or
/// USB bus, and the cloud has no business choosing those.
///
/// `Debug` is by hand so the credential cannot reach a log line - the
/// `Ok(other) => info!(?other, ...)` arm in the uplink formats whole messages.
#[derive(Clone, Deserialize)]
pub struct ProvisionPrinter {
pub id: String,
pub name: String,
pub vendor: String,
pub host: Option<String>,
pub access_code: Option<String>,
pub sn: Option<String>,
pub api_key: Option<String>,
}
impl std::fmt::Debug for ProvisionPrinter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ProvisionPrinter")
.field("id", &self.id)
.field("name", &self.name)
.field("vendor", &self.vendor)
.field("host", &self.host)
.field("access_code", &self.access_code.as_ref().map(|_| "<redacted>"))
.field("sn", &self.sn)
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
.finish()
}
}
/// The closed set of things the cloud is allowed to ask a printer to do.
@@ -220,7 +342,14 @@ pub enum PrinterCommand {
/// hides it. `file_name` is what the file is called on the printer;
/// `.3mf` is treated as a sliced project, anything else as bare gcode.
/// `job_id` is only for lining gateway logs up against a cloud job.
StartPrint { job_id: String, file_name: String, url: String },
StartPrint {
job_id: String,
file_name: String,
url: String,
/// Use this AMS slot (0-based, all units) instead of the project's own pick.
#[serde(default)]
ams_slot: Option<u8>,
},
}
impl PrinterCommand {
+121
View File
@@ -134,3 +134,124 @@ fn an_entry_with_the_credential_left_blank_is_refused_by_name() {
);
}
}
/// The other half of the loop: a printer the operator has already dealt with
/// must stop being offered as a fresh find.
///
/// Same coupling-by-a-string risk as the rest of this file, in the other
/// direction. `fleet::configured` reads the fields discovery writes, so a
/// rename on either side silently turns the filter off - and a filter that
/// silently stops filtering looks exactly like a working one, except the
/// console offers every printer the farm already runs.
mod already_configured {
use super::*;
fn configured_from(toml: &str) -> fleet::Configured {
let mut file = tempfile();
file.write_all(toml.as_bytes()).expect("write the fleet file");
fleet::configured(&file.path).expect("parse the fleet file")
}
#[test]
fn a_rendered_candidate_is_recognised_in_the_file_it_was_pasted_into() {
for vendor in [Vendor::Bambu, Vendor::Klipper, Vendor::Octoprint, Vendor::PrusaLink, Vendor::PrusaSerial] {
let mut candidate = Candidate::new("192.168.0.77", vendor, Source::HttpApi);
if vendor == Vendor::PrusaSerial {
candidate.host = "/dev/ttyACM0".to_string();
}
if vendor == Vendor::Bambu {
candidate.serial = Some("00M09A382100000".to_string());
}
let toml = with_credentials(&candidate.to_toml_entry("p"));
let configured = configured_from(&toml);
assert!(
configured.mentions(Some(&candidate.host), candidate.serial.as_deref()),
"a pasted {vendor:?} entry was not recognised as configured:\n{toml}"
);
}
}
#[test]
fn an_entry_whose_credential_is_still_blank_counts_as_configured() {
// The exact moment this matters: the operator has pasted in what
// discovery suggested and has not yet typed the access code off the
// printer's screen. `fleet::load` refuses that entry - and must -
// but re-offering the printer as a new find while they are mid-way
// through adding it is noise at the worst possible time.
let mut candidate = Candidate::new("192.168.0.78", Vendor::Bambu, Source::Certificate);
candidate.serial = Some("00M09A382100001".to_string());
let toml = candidate.to_toml_entry("p");
assert!(load(&toml).is_err(), "a blank access_code should still fail to load");
assert!(configured_from(&toml).mentions(Some("192.168.0.78"), None));
}
#[test]
fn a_printer_at_a_new_address_is_recognised_by_its_serial() {
// DHCP moved it. The serial is the machine; the address is a lease.
let configured = configured_from(
"[[printer]]\nid = \"p\"\nname = \"P1P\"\nvendor = \"bambu\"\n\
host = \"192.168.0.20\"\nsn = \"00M09A382100000\"\naccess_code = \"12345678\"\n",
);
assert!(configured.mentions(Some("192.168.0.99"), Some("00M09A382100000")));
// Case is not a difference: a serial read out of a TLS certificate
// has no guaranteed case.
assert!(configured.mentions(None, Some("00m09a382100000")));
// And something genuinely new is still new.
assert!(!configured.mentions(Some("192.168.0.99"), Some("00M09A382100002")));
}
#[test]
fn a_missing_fleet_file_means_nothing_is_configured_rather_than_an_error() {
// A gateway with no fleet yet is the case discovery exists for, so
// this is the normal path on a new box and not an edge case.
let configured = fleet::configured(std::path::Path::new("/nonexistent/printers.toml"))
.expect("a missing fleet file is not an error");
assert!(!configured.mentions(Some("192.168.0.20"), Some("00M09A382100000")));
}
}
/// The wire form a gateway reports, against the vendor and source strings the
/// backend's `discovered_printer.schema.json` declares as enums.
///
/// Those are a contract, not labels: an unrecognised value does not degrade
/// gracefully at the far end, it makes the backend reject the frame.
#[test]
fn every_vendor_and_source_renders_a_value_the_backend_schema_accepts() {
const VENDORS: [&str; 5] = ["bambu", "klipper", "prusa_link", "octoprint", "prusa_serial"];
const SOURCES: [&str; 5] = ["ssdp", "certificate", "http_api", "usb_descriptor", "serial_handshake"];
for vendor in [Vendor::Bambu, Vendor::Klipper, Vendor::PrusaLink, Vendor::Octoprint, Vendor::PrusaSerial] {
for source in [Source::Ssdp, Source::Certificate, Source::HttpApi, Source::UsbDescriptor, Source::SerialHandshake] {
let mut candidate = Candidate::new("192.168.0.20", vendor, source);
candidate.ambiguous_with = Some(Vendor::PrusaLink);
let wire = candidate.to_wire();
assert!(VENDORS.contains(&wire.vendor), "vendor {:?} renders {:?}", vendor, wire.vendor);
assert!(SOURCES.contains(&wire.source), "source {:?} renders {:?}", source, wire.source);
assert_eq!(wire.ambiguous_with, Some("prusa_link"));
assert_eq!(wire.driver_ready, vendor.driver_is_implemented());
}
}
}
/// A candidate must not carry a credential up to the cloud, and the check is
/// on the serialised bytes rather than the struct's fields.
///
/// Reading the fields would only prove what the type already says. The risk
/// is someone later adding a field to `Candidate` - an access code recovered
/// from somewhere, a pinned certificate - and it reaching the wire because
/// `to_wire` was never the place anyone thought to look.
#[test]
fn no_credential_shaped_field_reaches_the_wire() {
let mut candidate = Candidate::new("192.168.0.20", Vendor::Bambu, Source::Certificate);
candidate.serial = Some("00M09A382100000".to_string());
candidate.name = Some("P1P".to_string());
candidate.bind_state = Some("free".to_string());
let json = serde_json::to_string(&candidate.to_wire()).expect("serialise");
for forbidden in ["access_code", "api_key", "password", "token", "secret", "bind_state"] {
assert!(!json.contains(forbidden), "{forbidden} reached the wire: {json}");
}
// And the fields that should be there, are - or this test would pass on
// an empty object.
assert!(json.contains("00M09A382100000") && json.contains("\"vendor\":\"bambu\""), "{json}");
}
+10
View File
@@ -144,3 +144,13 @@ fn merge_keeps_stage_and_spool_when_a_partial_update_omits_them() {
assert_eq!(base.stg, Some(vec![54, 14, 13]));
assert!(base.vt_tray.is_some());
}
/// A P1P mid-print pushes `ams` objects without the inner unit list. That
/// used to fail the whole report ("missing field `ams`"), silencing telemetry
/// for the rest of the print - found on the first real dispatch.
#[test]
fn a_partial_ams_object_parses_and_keeps_known_units() {
let partial = serde_json::json!({"command":"push_status","sequence_id":"1","msg":1,"gcode_state":"RUNNING","ams":{"tray_now":"2"}});
let report: PrintReport = serde_json::from_value(partial).expect("a partial ams object should parse");
assert!(report.ams.as_ref().is_some_and(|a| a.ams.is_empty()));
}
+52 -2
View File
@@ -18,10 +18,12 @@ use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use serde_json::{json, Value};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::Mutex;
use tokio_tungstenite::WebSocketStream;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::handshake::server::{Request, Response};
use continuum_proxy::config::Config;
use continuum_proxy::uplink;
@@ -44,6 +46,10 @@ fn config(port: u16) -> Config {
go2rtc_api_listen: "127.0.0.1:1984".into(),
stream_base_url: None,
printers_path: "/nonexistent/printers.toml".into(),
store_path: "/nonexistent/continuum.db".into(),
// Off, which is the default and also the only safe thing in a test:
// an enabled one would sweep whatever network the test host is on.
discovery: Default::default(),
}
}
@@ -66,16 +72,60 @@ where
}
}
/// Plays the cloud's `/auth/edge-token`: reads one HTTP request, checks that
/// the gateway sent its farm and long-lived token, and answers with a JWT.
/// Returns the request text so the caller can assert on it.
async fn answer_token_exchange(listener: &TcpListener) -> String {
let (mut stream, _) = listener.accept().await.unwrap();
let mut request = Vec::new();
let mut buf = [0u8; 4096];
loop {
let n = tokio::time::timeout(WAIT, stream.read(&mut buf)).await.unwrap().unwrap();
assert!(n > 0, "the gateway hung up before finishing its token request");
request.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&request).into_owned();
if let Some((head, body)) = text.split_once("\r\n\r\n") {
let length = head
.lines()
.find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap()))
.unwrap_or(0);
if body.len() >= length {
break;
}
}
}
let body = r#"{"token":"minted-edge-jwt","expiresInSeconds":3600}"#;
stream
.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes())
.await
.unwrap();
String::from_utf8_lossy(&request).into_owned()
}
#[tokio::test]
async fn a_long_command_that_fails_reports_its_reason_after_the_fact() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let fleet: uplink::SharedFleet = Arc::new(Mutex::new(Vec::new()));
tokio::spawn(uplink::run(config(port), fleet));
let (outbound, inbox) = uplink::channel();
tokio::spawn(uplink::run(config(port), fleet, None, outbound, inbox));
// The gateway trades its long-lived token for an edge JWT first, and the
// JWT - not the token it was configured with - is what opens the socket.
let exchange = answer_token_exchange(&listener).await;
assert!(exchange.starts_with("POST /auth/edge-token "), "{exchange}");
assert!(exchange.contains(r#""farmId":"test-farm""#) && exchange.contains(r#""edgeProxyToken":"test-token""#), "{exchange}");
let (stream, _) = listener.accept().await.unwrap();
let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
let mut upgrade_auth = String::new();
let mut ws = tokio_tungstenite::accept_hdr_async(stream, |request: &Request, response: Response| {
upgrade_auth = request.headers().get("authorization").unwrap().to_str().unwrap().to_string();
Ok(response)
})
.await
.unwrap();
assert_eq!(upgrade_auth, "Bearer minted-edge-jwt", "the socket must be opened with the minted JWT");
let hello = next_json(&mut ws).await;
assert_eq!(hello["type"], "hello", "a gateway introduces itself first");
+183
View File
@@ -0,0 +1,183 @@
//! Job outcomes survive the cloud being away: an event in the gateway's store
//! is sent on connect, sent again after a reconnect if nobody acknowledged it,
//! and gone once acknowledged. A start that fails after being accepted is
//! queued the same way instead of being sent once to whoever is listening.
//!
//! Real `uplink::run` against a real websocket server; no printer is touched
//! (the fleet is empty, so the start fails on its lookup before any download).
use std::sync::Arc;
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use serde_json::{json, Value};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::Mutex;
use tokio_tungstenite::WebSocketStream;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::handshake::server::{Request, Response};
use continuum_proxy::config::Config;
use continuum_proxy::store::Store;
use continuum_proxy::uplink;
/// Long enough for a loopback round trip, short enough that a regression
/// fails the test instead of hanging the suite.
const WAIT: Duration = Duration::from_secs(10);
fn config(port: u16) -> Config {
Config {
gateway_id: "test-gw".into(),
farm_id: "test-farm".into(),
gateway_token: "test-token".into(),
uplink_url: format!("ws://127.0.0.1:{port}/ws/edge/v1"),
// Nothing here starts go2rtc or reads the fleet file - `uplink::run`
// is handed its fleet rather than loading one - but Config has no
// Default, so these have to say something.
go2rtc_bin: "/nonexistent-go2rtc".into(),
go2rtc_config: "/nonexistent/go2rtc.yaml".into(),
go2rtc_api_listen: "127.0.0.1:1984".into(),
stream_base_url: None,
printers_path: "/nonexistent/printers.toml".into(),
store_path: "/nonexistent/continuum.db".into(),
// Off, which is the default and also the only safe thing in a test:
// an enabled one would sweep whatever network the test host is on.
discovery: Default::default(),
}
}
/// The next frame as JSON. Heartbeats and telemetry share this socket, so
/// callers filter; `interval` fires on its first tick, which means a
/// heartbeat arrives immediately after the hello.
async fn next_json<S>(ws: &mut WebSocketStream<S>) -> Value
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
loop {
let frame = tokio::time::timeout(WAIT, ws.next())
.await
.expect("timed out waiting for a frame from the gateway")
.expect("the gateway closed the connection")
.expect("websocket error");
if let Message::Text(text) = frame {
return serde_json::from_str(&text).expect("the gateway sent invalid JSON");
}
}
}
/// Plays the cloud's `/auth/edge-token`: reads one HTTP request, checks that
/// the gateway sent its farm and long-lived token, and answers with a JWT.
/// Returns the request text so the caller can assert on it.
async fn answer_token_exchange(listener: &TcpListener) -> String {
let (mut stream, _) = listener.accept().await.unwrap();
let mut request = Vec::new();
let mut buf = [0u8; 4096];
loop {
let n = tokio::time::timeout(WAIT, stream.read(&mut buf)).await.unwrap().unwrap();
assert!(n > 0, "the gateway hung up before finishing its token request");
request.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&request).into_owned();
if let Some((head, body)) = text.split_once("\r\n\r\n") {
let length = head
.lines()
.find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap()))
.unwrap_or(0);
if body.len() >= length {
break;
}
}
}
let body = r#"{"token":"minted-edge-jwt","expiresInSeconds":3600}"#;
stream
.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes())
.await
.unwrap();
String::from_utf8_lossy(&request).into_owned()
}
/// Answers the token exchange and accepts the websocket, returning it after
/// the gateway's hello.
async fn accept(listener: &TcpListener) -> WebSocketStream<tokio::net::TcpStream> {
answer_token_exchange(listener).await;
let (stream, _) = listener.accept().await.unwrap();
let mut ws = tokio_tungstenite::accept_hdr_async(stream, |_: &Request, response: Response| Ok(response)).await.unwrap();
let hello = next_json(&mut ws).await;
assert_eq!(hello["type"], "hello");
ws
}
/// Reads frames until `want` job events have arrived, returning them by job id.
async fn job_events(ws: &mut WebSocketStream<tokio::net::TcpStream>, want: usize) -> Vec<Value> {
let mut events = Vec::new();
while events.len() < want {
let msg = next_json(ws).await;
if msg["type"] == "job_event" {
events.push(msg);
}
}
events.sort_by_key(|e| e["event_id"].as_i64());
events
}
#[tokio::test]
async fn unacknowledged_job_events_are_resent_after_a_reconnect_and_stop_once_acknowledged() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let dir = std::env::temp_dir().join(format!("continuum-outbox-test-{}", std::process::id()));
let store = Arc::new(Store::open(&dir.join("s.db")).unwrap());
// An outcome reached while the cloud was away.
store.outbox_push("11111111-1111-4111-8111-111111111111", "finished", None, 1_000).unwrap();
let fleet: uplink::SharedFleet = Arc::new(Mutex::new(Vec::new()));
let (outbound, inbox) = uplink::channel();
tokio::spawn(uplink::run(config(port), fleet, Some(store.clone()), outbound, inbox));
let mut ws = accept(&listener).await;
let first = job_events(&mut ws, 1).await;
assert_eq!(first[0]["job_id"], "11111111-1111-4111-8111-111111111111");
assert_eq!(first[0]["event"], "finished");
assert_eq!(first[0]["at_ms"], 1_000);
// A start that fails after being accepted goes through the same outbox.
ws.send(Message::Text(
json!({
"type": "command", "command_id": "cmd-1", "printer_id": "not-in-this-fleet",
"command": { "action": "start_print", "job_id": "22222222-2222-4222-8222-222222222222",
"file_name": "cube.gcode", "url": "http://127.0.0.1:1/never-fetched" }
})
.to_string(),
))
.await
.unwrap();
// Only the new one: the first was already sent on this connection.
let mut both = first.clone();
both.extend(job_events(&mut ws, 1).await);
assert_eq!(both[1]["job_id"], "22222222-2222-4222-8222-222222222222");
assert_eq!(both[1]["event"], "failed");
assert!(both[1]["detail"].as_str().unwrap().contains("not-in-this-fleet"), "{}", both[1]);
// The cloud never answers; the connection drops.
drop(ws);
let mut ws = accept(&listener).await;
let again = job_events(&mut ws, 2).await;
assert_eq!(
again.iter().map(|e| e["event_id"].clone()).collect::<Vec<_>>(),
both.iter().map(|e| e["event_id"].clone()).collect::<Vec<_>>(),
"after a reconnect everything unacknowledged is sent again"
);
ws.send(Message::Text(json!({ "type": "job_event_ack", "event_id": again[0]["event_id"] }).to_string()))
.await
.unwrap();
let deadline = tokio::time::Instant::now() + WAIT;
while store.outbox_pending().unwrap().len() != 1 {
assert!(tokio::time::Instant::now() < deadline, "the acknowledged event was not cleared");
tokio::time::sleep(Duration::from_millis(50)).await;
}
assert_eq!(store.outbox_pending().unwrap()[0].job_id, "22222222-2222-4222-8222-222222222222");
let _ = std::fs::remove_dir_all(dir);
}
+28 -1
View File
@@ -106,7 +106,7 @@ fn a_vendor_specific_control_verb_is_rejected() {
fn a_start_print_command_arrives_with_its_fields_intact() {
let raw = r#"{"type":"command","command_id":"c","printer_id":"p1p","command":{"action":"start_print","job_id":"5b0e7c1a-0000-4000-8000-000000000000","file_name":"plate_1.3mf","url":"https://objects.example.com/jobs/abc.3mf?X-Amz-Signature=deadbeef&X-Amz-Expires=300"}}"#;
match serde_json::from_str::<ServerMessage>(raw).expect("should parse") {
ServerMessage::Command { command: PrinterCommand::StartPrint { job_id, file_name, url }, .. } => {
ServerMessage::Command { command: PrinterCommand::StartPrint { job_id, file_name, url, .. }, .. } => {
assert_eq!(job_id, "5b0e7c1a-0000-4000-8000-000000000000");
assert_eq!(file_name, "plate_1.3mf");
assert_eq!(
@@ -188,6 +188,7 @@ fn empty_telemetry_fields_are_omitted_not_null() {
// The common case, and the third field that must be omitted: a
// P1P reports that it has a camera without giving a URL for it.
camera_stream: None,
camera_snapshot: None,
}],
};
@@ -226,6 +227,7 @@ fn a_zero_stage_is_sent_rather_than_treated_as_absent() {
print_error: Some(0),
active_alerts: Some(0),
camera_stream: Some("p1p".into()),
camera_snapshot: None,
}],
};
@@ -299,3 +301,28 @@ fn a_hello_carries_no_credential() {
assert!(!json.contains("token"), "the hello must carry no token, got:\n {json}");
assert_eq!(json, r#"{"type":"hello","gateway_id":"gw-1","farm_id":"farm-1"}"#);
}
#[test]
fn a_snapshot_request_is_readable_and_its_answer_matches_the_schema() {
let request = r#"{"type":"camera_snapshot_request","request_id":"r1","printer_id":"p1p"}"#;
assert!(matches!(
serde_json::from_str::<ServerMessage>(request),
Ok(ServerMessage::CameraSnapshotRequest { .. })
));
let ok = serde_json::to_string(&ClientMessage::CameraSnapshot {
request_id: "r1".into(),
jpeg_base64: Some("/9j/".into()),
error: None,
})
.unwrap();
assert_eq!(ok, r#"{"type":"camera_snapshot","request_id":"r1","jpeg_base64":"/9j/"}"#);
let failed = serde_json::to_string(&ClientMessage::CameraSnapshot {
request_id: "r1".into(),
jpeg_base64: None,
error: Some("no frame".into()),
})
.unwrap();
assert_eq!(failed, r#"{"type":"camera_snapshot","request_id":"r1","error":"no frame"}"#);
}