Compare commits

...
Author SHA1 Message Date
octoturgeandClaude Sonnet 5.5 2bbe8af9a1 Build discover_printers for Windows on the Gitea runner
Windows discovery tool / windows (push) Failing after 7m1s
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.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 10:54:07 +00:00
octoturgeandClaude Sonnet 5.5 b9d36b9b87 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.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 10:52:50 +00:00
octoturgeandClaude Sonnet 5.5 c6e364e9df Ignore the pid file the go2rtc supervisor writes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 10:48:57 +00:00
octoturgeandClaude Sonnet 5.5 987a7b4708 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.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 10:47:26 +00:00
octoturgeandClaude Sonnet 5.5 88bf1d1c2f 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.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 10:30:09 +00:00
octoturgeandClaude Sonnet 5.5 e90c6f54e8 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.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 16:01:34 +00:00
octoturgeandClaude Opus 5 1967aae42c 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 08:21:30 +00:00
octoturgeandClaude Opus 5 062686b1a7 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 08:08:27 +00:00
octoturgeandClaude Opus 5 1cd05d9120 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 07:56:25 +00:00
octoturgeandClaude Opus 5 47c48ee02d 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 07:49:46 +00:00
octoturgeandClaude Opus 5 4f37384706 Report the OctoPrint API as what it is, and let the operator name it
Reporting a detected printer as `prusa_link` asserted a product nothing on
the wire mentions. What a probe observes is that something speaks the
OctoPrint REST API; PrusaLink is one implementation of it, OctoPrint itself
is another, and without a credential the two are indistinguishable - every
PrusaLink identity endpoint requires digest auth.

So discovery now reports `vendor = "octoprint"`, the protocol it actually
saw, and carries `ambiguous_with` so the rendered entry prints a comment
offering `prusa_link` directly above the `vendor` line. Changing it is a
one-word edit in the file the operator is already pasting into, which is the
right place for a question only they can answer. The exception is a body
that names itself: `/api/version`'s `text` field reads "PrusaLink 0.7.0" or
"OctoPrint 1.9.3", so on the rare unauthenticated 200 the answer is known
and no choice is offered.

For that to mean anything, `octoprint` had to become a vendor `fleet.rs`
accepts - otherwise discovery printed a block that would not load. It maps
to the same client as `prusa_link`, because they speak the same API, with an
`OctoPrintFlavour` recording which. The flavour is stored now, while the
client is still a stub, precisely because no probe can settle it: it is the
operator's answer, and without somewhere to keep it the real client would
have to ask them a second time. `PrusaLinkPrinter` is accordingly now
`OctoPrintPrinter` - one client for the API family - since a struct called
PrusaLink holding a flavour that says "Generic" is a contradiction someone
would later have to unpick.

**Found by the new test, and the reason it exists:** `fleet::load` did *not*
reject an entry whose credential was blank. `require_field` took an
`Option`, so `access_code = ""` arrived as `Some("")` and loaded happily -
which falsified the claim this feature was built on, that printing a block
with an empty credential is safe because pasting it in unfinished fails
loudly at startup. It does now. An empty credential is not a usable one; it
only moves the failure somewhere worse, where a Bambu printer is refused at
MQTT and a PrusaLink one 401s on every poll with nothing saying the field is
blank. This is the same trap as `??`-instead-of-`||` on an environment
variable that ships empty, which the backend's `auth/edge_secret.ts` and
`s3/index.ts` both document; it bites identically on a TOML key.

tests/discovery_to_fleet.rs is new and closes the loop: discovery and fleet
are coupled by nothing but a string, unit tests on either side cannot catch
a drift between them, and the symptom of a drift is an operator pasting in
exactly what they were told to and the gateway refusing to start. It renders
a candidate for every vendor, fills in the credential the way a human would,
loads it for real, and checks the handle matches the vendor asked for - not
merely that something loaded, since `octoprint` and `prusa_link` share a
client and a mapping that collapsed them would still load and still be
wrong.

Verification: the Moonraker probe now runs end to end over real HTTP against
a stand-in serving Moonraker's documented `/printer/info`, and correctly
reports a klipper entry named from the returned hostname. Port 80 cannot be
bound without root here, so the PrusaLink path is covered by a test that
takes a real socket answering a real digest challenge - the one link unit
tests on the classifier cannot reach, and the one PrusaLink is identified by.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 07:40:28 +00:00
octoturgeandClaude Opus 5 e6830d3846 Discover Klipper, PrusaLink and serial printers too
Discovery found only Bambu printers, so a mixed farm still meant typing
most of printers.toml by hand. It now covers the four vendors fleet.rs
accepts, over both transports.

Detection splits in two, and keeping the halves apart is what makes this
honest. "Something at this address speaks X" needs no credential from any
vendor, and is most of the value - it yields the address and the vendor,
which is two of the three or four fields an entry needs. "What is it called,
what firmware does it run" only has an answer where the vendor publishes one
unauthenticated: Bambu always, Moonraker usually, PrusaLink never.

So an HTTP 401 counts as a find rather than a failure. A password prompt
from /api/version is proof that something is serving /api/version, which is
the question being asked. It also makes the Moonraker probe correct without
having to know how a given host configured [authorization], which is not
knowable from outside.

Klipper is `GET :7125/printer/info`, Moonraker's own identity endpoint,
which returns hostname and software_version. PrusaLink is
`GET :80/api/version`; its OpenAPI specification puts digestAuth on every
operation including /api/v1/info, so there is no unauthenticated identity
endpoint at all and a 401 is the best available answer - which is enough,
since the only field still missing is the API key no probe could supply.
This cannot distinguish PrusaLink from OctoPrint, and that is not fixable
from outside: PrusaLink deliberately implements OctoPrint's API. A wrong
guess an operator can see and correct beats skipping a real printer.

Two guards against claiming things that are not printers, both now tested
against what a real LAN actually answers. Three devices on the test segment
answer /api/version with HTTP 200 and a body that is not JSON - a router, a
device with a logon page, and one serving gzip - so a 200 must carry
OctoPrint's guaranteed `api` field, and Moonraker's must carry the `result`
wrapper every Moonraker reply has. And a bare 401 is not enough either:
routers, NASes and cameras all password protect port 80, so a 401 must carry
a *Digest* challenge, which PrusaLink's spec guarantees and the Basic-auth
crowd does not send. A sweep of the real /24 still finds exactly the two
Bambu printers and nothing else.

Serial detection is passive by default, and that is a safety property rather
than a limitation. Opening a serial port toggles DTR, which reboots an
Arduino-based board - marlin_serial.rs documents the same thing from the
other direction - so a firmware handshake against a machine that happens to
be printing ruins the print, and discovery is exactly what someone runs
while a farm is busy. So list_ports opens nothing: it reads USB descriptors
out of sysfs, which is where udev reads them from, and gets the
manufacturer, product and USB serial without touching the device. That is
enough to render an entry, since a serial printer's block needs only a path
and a baud rate. probe_m115 is the opt-in extra that learns the firmware and
the exact machine type, and it refuses to run without
--yes-reset-the-printer, the same shape as bambu_print_probe's
--yes-start-a-real-print.

sysfs rather than tokio-serial's libudev feature: a gateway appliance should
not need libudev-dev at build time to list its own ports, and reading the
four files udev would read is thirty lines and no dependency. The walk
climbs to the USB device holding idVendor rather than counting parents,
because a ttyACM link points at an interface and a ttyUSB link at a
usb-serial port, each a different depth down.

Only Prusa's own USB vendor id counts as evidence of a printer. Everything
else in the table is a generic bridge chip - FTDI, CH340, CP210x - that a
printer is likely to be behind but that a GPS or an Arduino project could
equally be, so those are reported with "could be a printer or any other
serial device" rather than asserted.

Entries now carry exactly the fields each vendor's loader demands and no
others, with a test that fails if to_toml_entry and fleet.rs drift apart,
and a rendered entry for a vendor whose client is still a stub says so -
otherwise an operator pastes in a Klipper printer and gets a gateway that
connects and does nothing.

Found while testing the M115 parser: a value ran to end-of-input instead of
end-of-line, so the last field on a line swallowed what followed and UUID
read as "cede2a2f-...\nok".

Unverified, and untestable here: the Klipper and PrusaLink probes (no such
hardware) and all serial detection (no serial device attached, and
/sys/class/tty holds only virtual consoles). Every parser those depend on is
unit tested against recorded protocol text, including the real non-printer
responses above.

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

Two mechanisms, because they fail in different places:

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

The TLS certificate (`identify`) is the dependable one. Every Bambu printer
puts its serial in the subject CN of the certificate it presents on 8883,
readable with no credentials, no MQTT session and across subnets. Verified
against both real printers: a P1P gives CN = 01S09B441900143 (issuer
BBL CA, TLS 1.2) and an H2C CN = 31B8BP5A1800585 (BBL Device CA O1C2-V2,
TLS 1.3), each an exact match for what printers.toml already said. A blind
sweep of their /24 found exactly those two and nothing else, in 24s.

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

Three decisions that are not incidental:

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 00:51:09 +00:00
octoturgeandClaude Opus 5 5a594c43a4 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 00:37:27 +00:00
octoturgeandClaude Opus 5 ee737f2e47 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 00:32:57 +00:00
octoturgeandClaude Opus 5 a8f8dec955 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-18 22:01:21 +00:00
octoturgeandClaude Opus 5 005b8a3337 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-18 22:00:51 +00:00
octoturgeandClaude Opus 5 0f57717378 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-18 21:52:05 +00:00
octoturgeandClaude Opus 5 fc69e23ad1 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-18 20:03:49 +00:00
octoturgeandClaude Opus 5 ae3195f772 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-18 20:00:00 +00:00
octoturgeandClaude Opus 5 2ca81401fc 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-18 13:43:19 +00:00
octoturgeandClaude Opus 5 da6260fa9c 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-17 14:00:05 +00:00
octoturgeandClaude Opus 5 9d046abf19 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 23:10:57 +00:00
octoturgeandClaude Opus 5 bda5e95b66 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 23:03:02 +00:00
octoturgeandClaude Opus 5 48672b1f45 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 22:53:47 +00:00
octoturgeandClaude Opus 5 35d2d96f05 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 22:37:28 +00:00
octoturgeandClaude Opus 5 790c951cb4 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 22:32:31 +00:00
octoturgeandClaude Opus 5 3d907d0443 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 22:23:41 +00:00
octoturgeandClaude Opus 5 6766ebbe80 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 21:43:06 +00:00
octoturgeandClaude Opus 5 aa49cbba49 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 21:37:20 +00:00
octoturgeandClaude Opus 5 6ff0c9a649 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 21:27:24 +00:00
octoturgeandClaude Opus 5 7d68e8e069 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-11 22:35:51 +00:00
octoturgeandClaude Opus 5 79df3e7153 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-11 17:55:01 +00:00
octoturgeandClaude Opus 5 4d644937af 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).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-11 17:38:27 +00:00
octoturgeandClaude Opus 5 4ab95ff7ce 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 12:16:48 +00:00
octoturgeandClaude Opus 5 8f7a332956 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 10:37:24 +00:00
octoturgeandClaude Opus 5 d25614b313 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 10:22:22 +00:00
octoturgeandClaude Opus 5 1d95451c4c 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 10:12:05 +00:00
octoturgeandClaude Opus 5 704b65ac26 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 10:11:36 +00:00
octoturgeandClaude Opus 5 8b971e78e5 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.

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:17:15 +00:00
octoturgeandClaude Sonnet 5 11177db2ad 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).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 15:03:14 +00:00
octoturgeandClaude Sonnet 5 1fd6529376 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWQVtBGxHYLkVw2kBupd5g
2026-09-07 14:55:09 +00:00
octoturgeandClaude Sonnet 5 133db9e4aa 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWQVtBGxHYLkVw2kBupd5g
2026-09-07 14:39:55 +00:00
octoturgeandClaude Sonnet 5 ce6a4c3806 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWQVtBGxHYLkVw2kBupd5g
2026-09-05 21:45:32 +00:00
octoturgeandClaude Sonnet 5 db9b575b55 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWQVtBGxHYLkVw2kBupd5g
2026-09-05 21:39:26 +00:00
octoturgeandClaude Sonnet 5 5d6e2e9e03 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWQVtBGxHYLkVw2kBupd5g
2026-09-05 21:27:59 +00:00
octoturgeandClaude Sonnet 5 773898b682 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.

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWQVtBGxHYLkVw2kBupd5g
2026-09-05 20:40:21 +00:00
57 changed files with 14709 additions and 479 deletions
+30 -7
View File
@@ -1,20 +1,43 @@
# 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
# Cloud uplink
CONTINUUM_UPLINK_URL=wss://api.continuum.local/ws/edge/v1
# go2rtc process management
# Camera restreaming. The config file is GENERATED by this daemon from what
# the printers themselves report - don't write it by hand, and keep it out of
# version control: it holds printer access codes.
CONTINUUM_GO2RTC_BIN=/usr/local/bin/go2rtc
CONTINUUM_GO2RTC_CONFIG=./go2rtc.yaml
CONTINUUM_GO2RTC_API_LISTEN=:1984
# Bambu MQTTS certificate trust. Most printers verify fine against the
# bundled shared CA (certs/bambu_ca2.pem) and need nothing here. For a
# printer that doesn't chain to it (P1P, at least), add "printer_id=path"
# entries, comma-separated: CONTINUUM_BAMBU_CA_CERT_OVERRIDES=p1=./certs/p1p.pem
CONTINUUM_BAMBU_CA_CERT_OVERRIDES=
CONTINUUM_BAMBU_REQUIRE_VALID_CERT=true
# The go2rtc API serves WebRTC, and the listen address above is every
# interface, so it is always password-protected: leave this unset and the
# daemon generates a random password each run and writes it into the config
# file above, which is already a secret. Set it only if you need a stable one
# (to open go2rtc's own web UI, say). The username is always "continuum".
#
# go2rtc exempts loopback from this, so a check from the gateway itself always
# succeeds - test from another machine if you want to confirm it is on.
# CONTINUUM_GO2RTC_PASSWORD=
# What a browser can reach the streams at, reported to the cloud. A bind
# address is not one, which is why this is separate from the listen address
# above; only you know this gateway's reachable name. Note this is a LAN
# address: a browser on this network can play the stream, one outside cannot.
# Leave it unset to advertise no streams at all, which is the safe default -
# a wrong URL here makes the UI offer a player that cannot connect.
# CONTINUUM_STREAM_BASE_URL=http://gateway.local:1984
# Fleet config - see printers.example.toml. Bambu MQTTS certificate trust
# (per-printer ca_cert_path/require_valid_cert, and automatic
# trust-on-first-connect pinning otherwise) is configured per printer in
# that file, not here.
CONTINUUM_PRINTERS_PATH=printers.toml
RUST_LOG=info,continuum_proxy=debug
+145
View File
@@ -0,0 +1,145 @@
# 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"
- ".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
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- 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"
- name: Upload the executable
uses: actions/upload-artifact@v3
with:
name: continuum-discover-printers-windows
path: 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" }
+7
View File
@@ -8,3 +8,10 @@ Cargo.lock
*.log
printers.toml
/certs/pinned/
# Generated by the daemon from what the printers report, and it holds their
# access codes.
go2rtc.yaml
go2rtc.yaml.tmp
continuum.toml
go2rtc.yaml.pid
+50 -12
View File
@@ -3,7 +3,10 @@ name = "continuum-proxy"
version = "0.1.0"
edition = "2024"
description = "Continuum edge gateway daemon - runs on-site, bridges printers to the cloud control plane"
license = "UNLICENSED"
# Proprietary: deliberately not an SPDX id, so cargo-deny treats it as
# first-party. See docs/LICENSES.md in continuum-common.
license = "LicenseRef-Proprietary"
publish = false
[lib]
name = "continuum_proxy"
@@ -21,10 +24,6 @@ path = "examples/printer_polymorphism.rs"
name = "test_bambu_certs"
path = "examples/test_bambu_certs.rs"
[[example]]
name = "fetch_bambu_cert"
path = "examples/fetch_bambu_cert.rs"
[[example]]
name = "auto_connect_bambu"
path = "examples/auto_connect_bambu.rs"
@@ -33,7 +32,27 @@ path = "examples/auto_connect_bambu.rs"
name = "bambu_commands_demo"
path = "examples/bambu_commands_demo.rs"
[[example]]
name = "live_reports"
path = "examples/live_reports.rs"
[[example]]
name = "bambu_idle_checks"
path = "examples/bambu_idle_checks.rs"
[dependencies]
# Real printer protocol clients (MQTT for Bambu, HTTP for PrusaLink/
# Moonraker, FTPS for gcode transfer) go here once you're ready to build
# past the stubs in src/printer/ - see that module's doc comment.
continuum-rust-types = { path = "../continuum-schemas/packages/rust-types" }
# FOR NON-LOCAL DEV
# continuum-rust-types = { git = "https://git.octoturge.com/Continuum/continuum-schemas.git" }
# Builds a JSON instance straight from the schema files in
# continuum-schemas/schemas/ - used for Bambu's raw MQTT JSON messages
# instead of hand-written serde structs (see bambu.rs).
continuum-schema-instance = { path = "../continuum-common/packages/rust-schema-instance" }
tokio = { version = "1.40", features = ["full"] }
tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
futures-util = "0.3"
@@ -47,10 +66,29 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
anyhow = "1"
thiserror = "1"
dotenvy = "0.15"
# Real printer protocol clients (MQTT for Bambu, HTTP for PrusaLink/
# Moonraker, FTPS for gcode transfer) go here once you're ready to build
# past the stubs in src/printer/ - see that module's doc comment.
continuum-rust-types = { path = "../continuum-schemas/packages/rust-types" }
# FOR NON-LOCAL DEV
# continuum-rust-types = { git = "https://git.octoturge.com/Continuum/continuum-schemas.git" }
tokio-serial = { version = "5.5.0", default-features = false }
rumqttc = { version = "0.25.1", default-features = false, features = ["use-native-tls"] }
# Blocking FTPS client for pushing project files to a printer's own
# storage (see src/printer/bambu_ftps.rs for why the blocking one, not
# the async one). `deprecated` is what gates `connect_secure_implicit`:
# Bambu speaks implicit FTPS on 990, which the RFCs deprecated in favour
# 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", "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
# to the build.
# Apache-2.0 - checked against docs/LICENSES.md before adding.
openssl = "0.10"
flate2 = "1.1.10"
# HTTPS GET of a presigned job-file URL for the `start_print` command (see
# 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"
+117 -39
View File
@@ -4,62 +4,140 @@ Edge gateway daemon for the Continuum print farm platform. Runs on a Linux
SBC on-site, talks to printers over the LAN, and keeps a connection to the
cloud control plane (`continuum-backend`).
## This is a learning-stage boilerplate
## What's real right now
This repo is deliberately minimal right now - real printer protocol clients
(Bambu MQTT+FTPS, PrusaLink REST, Klipper/Moonraker WebSocket) are **not**
implemented yet. Each vendor is a stub that just prints what it would do
(`src/printer/bambu.rs`, `prusa.rs`, `klipper.rs`). The idea is to learn
Rust's polymorphism pattern (trait + enum, since Rust has no class
inheritance) on something simple before adding real networking on top.
- **Bambu MQTT** - a real client for both protocol generations (MQTT
v3.1.1+BambuMQTT v1 on older firmware, MQTT v5+BambuMQTT v2 on current
firmware), auto-detected per printer on connect (a firmware update can
move the same physical printer between them, so this isn't something
config can hard-code once and forget - see `printer/bambu.rs`'s
`BambuProtocolPreference::Auto`). Request/reply correlation via
`sequence_id`, an in-memory `print.push_status` cache that merges v1's
partial updates and replaces on v2's full ones, and TLS trust that
auto-pins a printer's certificate via trust-on-first-connect the first
time the bundled CA doesn't verify (`printer/bambu.rs`'s
`ensure_trusted`) - see `printer/bambu_mqtt.rs`'s doc comment for the
full protocol write-up.
- **Marlin/RepRap serial** - a real held-open serial connection that paces
gcode lines by Marlin's own "ok" acknowledgment (`printer/marlin_serial.rs`).
- **Wire shapes from schemas, not hand duplication** - Bambu's raw MQTT
JSON is built straight from `continuum-schemas`' JSON Schema files via
`continuum-schema-instance`, instead of a second hand-written copy of
every request shape drifting out of sync with the schema.
- **Cloud uplink** - a WebSocket client to `continuum-backend` that
connects, sends a heartbeat on a timer, and reconnects on drop
(`src/uplink/`).
- **Camera restreaming** - generates go2rtc's configuration from the camera
URLs the printers report, and restarts go2rtc when it changes
(`src/camera.rs` + `src/go2rtc.rs`). Only the X1/H2 class publishes an
RTSP URL: confirmed against two real machines, an H2C reports
`rtsps://<ip>:322/streaming/live/1` and a P1P reports a camera with no URL
for it. A P1/A1 camera needs Bambu's proprietary port-6000 JPEG stream,
which this crate does not speak, so those printers advertise no stream
rather than one that will not play. Note the stream is reachable on the
gateway's LAN only - there is no relay.
- **Bambu file upload** - implicit FTPS (port 990) into the printer's own
`cache/` directory (`src/printer/bambu_ftps.rs`, via
`BambuPrinter::upload_file`). **Confirmed live against a P1P on
2026-10-03**: login, binary mode, and the transfer all work, byte count
matched exactly.
- **Print status** - `print.push_status` parsed into `PrintReport` with real
job progress, temperatures, fans and HMS entries. Confirmed live on both
generations (idle P1P on v1, H2C 20% into a job on v2); regression
fixtures from those captures are in `tests/fixtures/`.
## Examples worth knowing
- `cargo run --example live_reports [printer-id]` - **read-only**, safe
against a printer that is mid-print. Connects, reports firmware, and
watches the status stream. Redirect stderr to capture raw payloads.
- `cargo run --example bambu_idle_checks -- <printer-id>` - **writes**
(FTPS upload, fan speed). Requires an explicit printer id and refuses to
run unless that printer reports `gcode_state: "IDLE"`.
## What routes between the cloud and the printers
The uplink and the printer fleet are wired together, in both directions
(`uplink/mod.rs`):
- **Up:** every 5s, one `PrinterTelemetry` snapshot per printer that has
reported, batched into a single `Telemetry` message. Verified live against
a P1P and an H2C.
- **Down:** a `Command` from the cloud is looked up by our own printer id,
executed, and answered with a `CommandResult`. The set of commands is
deliberately small - a fan speed and a single gcode line - and deliberately
has no "start a print" variant; see `PrinterCommand`.
One quirk worth knowing: a Bambu on protocol v1 only sends a *complete*
status when asked, so each telemetry tick also fires a `pushing.pushall` for
the next one. A v1 printer's telemetry is therefore one tick behind, which
is why it shows up on the second frame rather than the first.
## What's still a stub
`start_uploaded_file` - the MQTT command that actually *starts* a print from
an uploaded file - is written but has deliberately never been fired, since
that commits a machine to hours of work. Its doc comment lists the three
details to confirm on the first real attempt. `upload_file` is proven, but
only with a small text file, not a multi-megabyte sliced `.3mf`.
PrusaLink (REST) and Klipper/Moonraker (WebSocket) - `printer/prusa.rs`
and `printer/klipper.rs` just `println!` what they'd send. Also not built
yet: local buffering for telemetry across connectivity gaps (so telemetry
produced while the uplink is down is simply lost), LAN printer discovery
(SSDP/mDNS), and the mechanical plate-changer interface.
## Getting started
```bash
cp .env.example .env
cargo run # the daemon: cloud uplink + go2rtc watchdog
cargo run --example printer_polymorphism # standalone demo, no network/env needed
cargo run # the daemon: connects every configured printer, then runs the cloud uplink + go2rtc watchdog
cp printers.example.toml printers.toml # fill in your real printers (gitignored)
cargo run --example test_bambu_certs # real TLS handshake test against each Bambu printer
cargo run --example auto_connect_bambu # checks/auto-pins each Bambu printer's certificate, no MQTT needed
cargo run --example test_bambu_certs # narrower: just the raw TLS handshake, no auto-pinning
cargo run --example printer_polymorphism # standalone trait+enum demo, fake data, no network/env needed
cargo run --example bambu_commands_demo # round-trips hand-written structs against real captured Bambu JSON
```
## Structure
```
src/
main.rs Runs the uplink and the go2rtc watchdog side by side
config.rs Loads settings from environment variables
fleet.rs Loads printers.toml into ready-to-use PrinterHandles
uplink/ WebSocket client to continuum-backend: connect, heartbeat, reconnect on drop
printer/ GenericPrinter trait + PrinterBase + PrinterHandle enum + one stub per vendor
go2rtc.rs Restarts the go2rtc camera-restreaming process if it dies
main.rs Loads+connects the printer fleet, then runs the uplink, camera config, watchdog and completion watcher side by side
config.rs Loads settings from environment variables
fleet.rs Loads printers.toml into ready-to-use PrinterHandles
uplink/ WebSocket client to continuum-backend: connect, heartbeat, reconnect on drop
camera.rs Writes go2rtc's config from the camera URLs the printers report
go2rtc.rs Restarts the go2rtc camera-restreaming process if it dies, or when the config changes
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
bambu.rs The real Bambu printer type: cert trust, protocol version auto-detect, commands
bambu_mqtt.rs The real MQTT wiring underneath bambu.rs - see its doc comment for the protocol details
bambu_commands/ Hand-written serde structs mirroring continuum-schemas' bambulab/ shapes
marlin_serial.rs A real held-open Marlin/RepRap serial connection
prusa.rs, klipper.rs Still stubs - see "What's still a stub" above
examples/
printer_polymorphism.rs Runs all five printer stubs through one `connect()` call site
test_bambu_certs.rs Loads printers.toml, does a real TLS handshake to each Bambu printer
auto_connect_bambu.rs Checks/auto-pins every Bambu printer's certificate (no MQTT)
test_bambu_certs.rs Narrower: just a raw TLS handshake per Bambu printer
printer_polymorphism.rs Standalone trait+enum demo, five fake printers, no network needed
bambu_commands_demo.rs Round-trips bambu_commands' structs against real captured Bambu JSON
```
`src/printer/` is where the "inheritance" question lives - see that
module's doc comment for the trait+enum pattern this project uses instead
of class inheritance, and run `printer_polymorphism` to see it work.
`printers.toml` (gitignored - copy from `printers.example.toml`) holds real
per-printer connection details: host, access code, and - since not every
Bambu printer trusts the same certificate (see `certs/README.md`) - an
optional per-printer CA override. `src/fleet.rs` loads it; nothing in
`main.rs` uses it yet, but `test_bambu_certs` does, as a real (if narrow -
just the TLS handshake, no MQTT) way to check a printer's certificate
without needing the full MQTT client built yet.
per-printer connection details: host, access code/API key/serial port
depending on vendor, and - for Bambu - an optional per-printer CA
certificate override (see `certs/README.md`; most printers don't need one,
since `connect()` auto-pins automatically).
## What's not here yet (on purpose)
## Learning Rust through this repo
- Real MQTT/FTPS/HTTP/WebSocket printer clients - `src/printer/*.rs` has a
`println!` where each of these will go.
- Local SQLite buffering for telemetry across connectivity gaps.
- LAN printer discovery (SSDP/mDNS).
- The mechanical plate-changer interface (serial/GPIO).
Add these back in one at a time as you get comfortable with the Rust
underneath them - each is its own small lesson (async I/O, a new crate's
API, error handling for a real protocol) rather than something to absorb
all at once.
`src/printer/mod.rs`'s doc comment is where the "how do I do inheritance
without classes" question lives - trait (shared behavior) + struct
(shared fields) + enum (the closed set of concrete types), instead of
`GenericPrinter -> BambuPrinter/PrusaLinkPrinter/...` class inheritance.
Run `printer_polymorphism` to see it work end to end without needing any
real printer on the network.
+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
+13
View File
@@ -28,3 +28,16 @@ certificates at the same time.
If a printer's connection ever fails certificate verification against this
file, re-fetch from the source URL above (BambuStudio ships whatever the
current fleet needs) and update the fingerprint here.
## `pinned/`
Not checked in (gitignored) - where `BambuPrinter::ensure_trusted` saves a
certificate it auto-pinned via trust-on-first-connect, one file per
printer, named `<serial number>.pem`. This only happens for a printer
whose certificate doesn't verify against `bambu_ca2.pem` above (P1P, at
least) and that doesn't already have an explicit `ca_cert_path` set in
`printers.toml` - see that method's doc comment in `src/printer/bambu.rs`
for the full trust-on-first-connect flow, and the SSH-host-key analogy it's
built on. Delete a printer's file here to force it to re-pin (a real
error, not silent, if the certificate it captures the second time still
doesn't verify) the next time it connects.
+77
View File
@@ -0,0 +1,77 @@
# 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"
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
+43
View File
@@ -0,0 +1,43 @@
# Licence policy for a closed-source commercial product.
# See docs/LICENSES.md in continuum-common for the rationale and the
# non-cargo dependencies (external binaries, ML models) this file cannot see.
#
# Run: cargo deny check licenses
[licenses]
# Permissive only. Anything copyleft reaching a shipped binary would make
# the product undistributable without releasing our source.
allow = [
"MIT",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"Zlib",
"Unicode-3.0",
"Unlicense",
"MPL-2.0",
"CC0-1.0",
"BlueOak-1.0.0",
]
confidence-threshold = 0.9
# Our own crates declare `LicenseRef-Proprietary`, which is deliberately not
# an SPDX identifier and must never appear in the allow list above - a
# third-party crate claiming it would be a real finding. They are recognised
# as first-party by `publish = false` in their manifests.
private = { ignore = true }
[bans]
deny = []
[advisories]
yanked = "deny"
# NOT VISIBLE TO THIS FILE: src/go2rtc.rs spawns go2rtc as an external
# process found on PATH (go2rtc itself is MIT). go2rtc shells out to ffmpeg
# for transcoding - ffmpeg is LGPL-2.1+ only when built without
# `--enable-gpl`. If we ever ship either binary rather than requiring the
# operator to install it, the build flags become our problem. See
# docs/LICENSES.md.
+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
+10 -11
View File
@@ -1,12 +1,13 @@
//! Run with: cargo run --example auto_connect_bambu
//!
//! The "smooth, no manual steps" version of test_bambu_certs +
//! fetch_bambu_cert combined: for each Bambu printer in printers.toml,
//! makes sure it has a working certificate, auto-pinning one via
//! trust-on-first-connect if the bundled CA doesn't verify. Safe to run
//! repeatedly - a printer that's already pinned (certs/pinned/<serial
//! number>.pem) just gets re-verified against its pin, no network trust
//! decision is made again.
//! For each Bambu printer in printers.toml, makes sure it has a working
//! certificate, auto-pinning one via trust-on-first-connect if the
//! bundled CA doesn't verify - the exact same `ensure_trusted` step
//! `BambuPrinter::connect()` runs before opening MQTT, isolated here so
//! you can check/establish trust without needing a full MQTT handshake to
//! succeed too. Safe to run repeatedly - a printer that's already pinned
//! (certs/pinned/<serial number>.pem) just gets re-verified against its
//! pin, no network trust decision is made again.
use continuum_proxy::fleet;
use continuum_proxy::printer::PrinterHandle;
@@ -16,10 +17,8 @@ fn main() -> anyhow::Result<()> {
let cert_dir = std::path::Path::new("certs/pinned");
for printer in &mut printers {
match printer {
PrinterHandle::BambuV1(bambu) => report(&bambu.ensure_trusted(cert_dir)),
PrinterHandle::BambuV2(bambu) => report(&bambu.ensure_trusted(cert_dir)),
_ => {}
if let PrinterHandle::Bambu(bambu) = printer {
report(&bambu.ensure_trusted(cert_dir))
}
}
+105
View File
@@ -0,0 +1,105 @@
//! Run with: cargo run --example bambu_cache_list -- <printer-id> [dir]
//!
//! `dir` defaults to `cache`; pass `/` to see the SD card root, which is
//! how to find out what a file's real absolute path is.
//!
//! **Read-only.** Connects, reads one `print.push_status`, and lists the
//! printer's `cache/` directory over FTPS. It sends no commands that change
//! anything - the `pushall` it issues is a request for a status report, which
//! is the one write-shaped thing a read-only tool has to do on a v1 printer
//! to learn anything at all.
//!
//! The point of it: `start_uploaded_file` can only print a file that is
//! already in `cache/`, so this answers "is there anything there, and what is
//! it called?" before any print command is sent. It is also how to find a
//! real slicer-produced `.3mf` to test that command against, which is worth
//! a great deal more than a hand-built one - if a hand-built file fails there
//! is no way to tell whether the command was wrong or the file was.
use std::time::Duration;
use continuum_proxy::fleet;
use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
const STATUS_TIMEOUT: Duration = Duration::from_secs(15);
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let Some(target) = std::env::args().nth(1) else {
anyhow::bail!("usage: cargo run --example bambu_cache_list -- <printer-id> [dir]");
};
let dir = std::env::args().nth(2).unwrap_or_else(|| "cache".to_string());
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 - this example is Bambu-specific");
};
println!("=== {} ===", bambu.display_name());
match tokio::time::timeout(CONNECT_TIMEOUT, bambu.connect()).await {
Ok(Ok(())) => {}
Ok(Err(err)) => anyhow::bail!("connect failed: {err}"),
Err(_) => anyhow::bail!("connect timed out - printer off or unreachable?"),
}
bambu.pushall().await.ok();
let deadline = tokio::time::Instant::now() + STATUS_TIMEOUT;
loop {
if let Some(report) = bambu.print_status().await
&& let Some(state) = report.gcode_state.clone()
{
println!(" gcode_state: {state}");
println!(" subtask_name: {}", report.subtask_name.clone().unwrap_or_default());
println!(
" layer: {}/{}",
report.layer_num.unwrap_or_default(),
report.total_layer_num.unwrap_or_default()
);
println!(" percent: {}", report.mc_percent.unwrap_or_default());
// Temperatures and remaining time, because 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.
println!(
" nozzle: {:.1} -> {:.1} C",
report.nozzle_temper.unwrap_or_default(),
report.nozzle_target_temper.unwrap_or_default()
);
println!(
" bed: {:.1} -> {:.1} C",
report.bed_temper.unwrap_or_default(),
report.bed_target_temper.unwrap_or_default()
);
println!(" remaining: {} min", report.mc_remaining_time.unwrap_or_default());
break;
}
if tokio::time::Instant::now() >= deadline {
println!(" (no gcode_state within {STATUS_TIMEOUT:?} - listing cache/ anyway)");
break;
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
println!("\n {dir} over FTPS:");
match bambu.list_dir(&dir).await {
Ok(entries) if entries.is_empty() => println!(" (empty)"),
Ok(entries) => {
for entry in &entries {
println!(" {entry}");
}
println!("\n {} entr(ies)", entries.len());
}
Err(err) => println!(" FAILED: {err}"),
}
Ok(())
}
+7 -1
View File
@@ -36,8 +36,14 @@ fn main() {
// --- print report: arrives under "print", NOT "pushing" (pushall
// triggers this stream, it isn't a direct reply to it) - includes the
// full ams/xcam example, with one empty tray and one loaded tray, to
// exercise the untagged Empty/Loaded split.
// exercise the untagged Empty/Loaded split. sequence_id/command added
// by hand here (the original real capture this was built from was
// itself a truncated example that happened to omit them, along with
// dozens of other real fields the schema defines - not proof the real
// message lacks them, see this struct's own doc comment).
let real_print_report = r#"{
"sequence_id": "1",
"command": "push_status",
"ams": {"ams": [{"humidity":"4","id":"0","temp":"22.7","tray":[
{"id":"0"},
{"bed_temp":"0","bed_temp_type":"0","cols":["000000FF"],"drying_temp":"0","drying_time":"0","id":"1","nozzle_temp_max":"240","nozzle_temp_min":"190","remain":0,"tag_uid":"0000000000000000","tray_color":"000000FF","tray_diameter":"0.00","tray_id_name":"","tray_info_idx":"GFA00","tray_sub_brands":"","tray_type":"PLA","tray_uuid":"00000000000000000000000000000000","tray_weight":"0","xcam_info":"000000000000000000000000"}
+140
View File
@@ -0,0 +1,140 @@
//! Run with: cargo run --example bambu_idle_checks -- <printer-id>
//!
//! **This one writes.** Unlike `live_reports`, it sends commands that change
//! the printer: an FTPS file upload and a fan-speed change. So it does two
//! things before touching anything:
//!
//! 1. Requires an explicit printer id from the command line. No "do this to
//! every printer in the fleet" mode - that's not a thing you want to
//! discover you ran.
//! 2. Refuses to continue unless that printer reports `gcode_state: "IDLE"`.
//! It connects, waits for a real `print.push_status`, and bails out if the
//! printer turns out to be mid-job. Sending a fan command to a running
//! print would corrupt it.
//!
//! What it deliberately does **not** do, even on an idle printer:
//!
//! - **Start a print.** The upload here goes through `upload_file`, which is
//! FTPS only. `upload_project_file` (upload *then* print) and
//! `start_uploaded_file` are left alone - committing a machine to hours of
//! physical work needs a human deciding to.
//! - **Calibrate.** `get_calibration` runs a real multi-minute bed-levelling
//! / vibration-compensation sequence. Available, intentionally not wired
//! in here.
//! - **Heat anything.** No temperature commands.
//!
//! The fan is set and then put back to 0 before exiting.
use std::time::Duration;
use continuum_proxy::fleet;
use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
/// How long to wait for the first `push_status` before giving up on knowing
/// whether the printer is idle. v2 pushes within about a second; v1 needs the
/// `pushall` this example sends first.
const STATUS_TIMEOUT: Duration = Duration::from_secs(15);
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let Some(target) = std::env::args().nth(1) else {
anyhow::bail!(
"usage: cargo run --example bambu_idle_checks -- <printer-id>\n\
(the id from printers.toml - required on purpose, this example writes to the printer)"
);
};
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 - this example is Bambu-specific");
};
println!("=== {} ===", bambu.display_name());
match tokio::time::timeout(CONNECT_TIMEOUT, bambu.connect()).await {
Ok(Ok(())) => {}
Ok(Err(err)) => anyhow::bail!("connect failed: {err}"),
Err(_) => anyhow::bail!("connect timed out - printer off or unreachable?"),
}
// --- Safety gate: is it actually idle? ---
// v1 only sends a full object after a pushall; v2 sends one unprompted.
bambu.pushall().await.ok();
let deadline = tokio::time::Instant::now() + STATUS_TIMEOUT;
let state = loop {
if let Some(report) = bambu.print_status().await
&& let Some(state) = report.gcode_state.clone()
{
break state;
}
if tokio::time::Instant::now() >= deadline {
anyhow::bail!(
"no print.push_status with a gcode_state within {STATUS_TIMEOUT:?} - refusing to \
send anything, because there's no way to tell whether this printer is busy"
);
}
tokio::time::sleep(Duration::from_millis(500)).await;
};
println!(" gcode_state: {state}");
if state != "IDLE" {
anyhow::bail!(
"printer reports '{state}', not IDLE - refusing to send write commands to a printer \
that may be mid-job. Use the `live_reports` example instead, it's read-only."
);
}
println!(" idle - safe to continue\n");
// --- 1. FTPS upload (no print start) ---
// The real point of this example: the FTPS path in bambu_ftps.rs had
// never touched hardware. A tiny text file is enough to prove login,
// binary mode, cwd into cache/, and the transfer itself.
let file_name = "continuum-ftps-check.txt";
let body = format!(
"written by continuum-proxy bambu_idle_checks at {:?}\n",
std::time::SystemTime::now()
);
let expected = body.len() as u64;
println!(" uploading {file_name} ({expected} bytes) over FTPS to cache/ ...");
match bambu.upload_file(file_name, body.into_bytes()).await {
Ok(written) if written == expected => println!(" OK - {written} bytes, matches what we sent"),
Ok(written) => println!(" PARTIAL - printer accepted {written} of {expected} bytes"),
Err(err) => println!(" FAILED: {err}"),
}
// --- 2. Fan speed (an MQTT gcode_line publish under the hood) ---
// Audible and visible on the machine, and harmless on an idle printer.
println!("\n setting part-cooling fan to 50% ...");
match bambu.set_fan_speed(50).await {
Ok(()) => println!(" sent (M106 P1 S127) - listen for the fan"),
Err(err) => println!(" FAILED: {err}"),
}
tokio::time::sleep(Duration::from_secs(5)).await;
println!(" putting the fan back to 0% ...");
match bambu.set_fan_speed(0).await {
Ok(()) => println!(" sent"),
Err(err) => println!(" FAILED: {err}"),
}
// Give the printer a moment to report the change back, so the fan
// reading below reflects the command rather than racing it.
tokio::time::sleep(Duration::from_secs(3)).await;
if let Some(report) = bambu.print_status().await {
println!(
"\n cooling_fan_speed now reads: {:?}",
report.cooling_fan_speed.as_deref().unwrap_or("?")
);
}
println!("\ndone - nothing was printed, calibrated, or heated");
Ok(())
}
+554
View File
@@ -0,0 +1,554 @@
//! Run with: cargo run --example bambu_print_probe -- <printer-id> <step> [arg]
//!
//! Probes the print-start and print-control commands against a real printer,
//! one deliberate step at a time. **Some of these steps commit a physical
//! machine to work**, so nothing here happens implicitly: every step is named
//! on the command line, and the ones that can start a print additionally
//! require `--yes-start-a-real-print`.
//!
//! Steps, in the order worth running them:
//!
//! - `status` read-only. Connect, print state, list `cache/`.
//! - `probe-missing` **zero physical risk.** Sends `print.gcode_file`
//! pointing at a file that does not exist. The printer
//! cannot start anything, so this isolates one
//! question: does it accept and parse the command shape
//! at all? A clean rejection naming the file means the
//! shape is right and only the target was wrong. A
//! parse error, or silence, means the shape is wrong.
//! - `print-project <local> [name]`
//! **starts a real print.** The whole production path in
//! one call - derive the AMS mapping from the archive,
//! upload, start - via
//! `GenericPrinter::upload_project_file`. Gated on a
//! terminal state and `--yes-start-a-real-print`. Takes
//! no `--ams`: the point is that it reads the mapping
//! itself.
//! - `upload <local> [name]`
//! **inert.** FTPS-transfers a local file onto the card
//! and stops - nothing prints until told to. Reports
//! the byte count the printer accepted and the rate.
//! - `delete <path>` removes one named file from the card.
//! - `blink` runs `completion::celebrate`, the real ten-second
//! finished-print blink, and then checks the light
//! ended off. Twenty light commands in ten seconds is
//! a rate the printer may refuse, which is the thing
//! this answers.
//! - `light on|off|flash`
//! **no physical risk** beyond a light changing. `on`
//! and `off` go through `BambuPrinter::set_light`, the
//! same call `completion::run` blinks with, so this
//! confirms the shipped path rather than a hand-built
//! body. `flash` instead sends the schema's `flashing`
//! mode raw, to settle what `loop_times` and
//! `interval_time` actually do - the self-driven blink
//! exists because that was unconfirmed.
//! - `start <file>` sends `print.gcode_file`/`print.project_file` for a
//! file in `cache/`. Gated on `gcode_state: IDLE` and
//! on the explicit flag.
//! - `pause` / `resume` / `stop`
//! the control commands. `stop` cancels - it is the
//! undo for `start`, and the reason `start` is worth
//! testing at all: without it a cloud-initiated print
//! could only be ended at the machine.
//!
//! After whatever it sends, it watches the `print.push_status` stream and
//! reports every change to the fields that matter. Run with
//! `RUST_LOG=continuum_proxy::printer::bambu_mqtt=trace` to see the raw
//! payloads too - which is where a rejection that `PrintReport` does not
//! model will show up.
use std::time::Duration;
use continuum_proxy::fleet;
use continuum_proxy::printer::{
FilamentSource, GenericPrinter, LightState, PrintControl, PrinterHandle, CHAMBER_LIGHT,
};
const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
const STATUS_TIMEOUT: Duration = Duration::from_secs(15);
/// How long to watch the report stream after sending something.
const WATCH: Duration = Duration::from_secs(20);
/// How long to wait for a light change to show up in `lights_report`. A v1
/// printer only reports when asked, so this polls `pushall` rather than
/// waiting on an organic push that is not coming.
const LIGHT_READBACK: Duration = Duration::from_secs(12);
/// Only for the message - `completion.rs` owns the real value.
const BLINK_FOR_DISPLAY: &str = "10s";
/// A file name that cannot exist in `cache/`, for `probe-missing`.
const MISSING_FILE: &str = "continuum-probe-no-such-file.gcode";
/// Every tray a P1P with one AMS has: 4 AMS slots plus the external spool.
/// `ams_mapping` has to be padded to this length - see the schema.
const P1P_TRAY_COUNT: usize = 5;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Without this there is no subscriber, so the `trace!` of every raw
// report in bambu_mqtt.rs goes nowhere and RUST_LOG appears to do
// nothing - which is exactly how the first run of this probe came back
// with no evidence either way.
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let args: Vec<String> = std::env::args().collect();
let (Some(target), Some(step)) = (args.get(1).cloned(), args.get(2).cloned()) else {
anyhow::bail!(
"usage: cargo run --example bambu_print_probe -- <printer-id> <step> [arg]\n\
steps: status | probe-missing | light on|off|flash | start <file-in-cache> \
| pause | resume | stop"
);
};
let confirmed = args.iter().any(|a| a == "--yes-start-a-real-print");
// Handled before the fleet below is borrowed, because the real blink
// wants the whole fleet behind a lock rather than one printer.
if step == "blink" {
return blink_probe(&target).await;
}
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 - this example is Bambu-specific");
};
println!("=== {} / step: {step} ===", bambu.display_name());
match tokio::time::timeout(CONNECT_TIMEOUT, bambu.connect()).await {
Ok(Ok(())) => {}
Ok(Err(err)) => anyhow::bail!("connect failed: {err}"),
Err(_) => anyhow::bail!("connect timed out - printer off or unreachable?"),
}
// v1 answers only when asked; v2 pushes unprompted.
bambu.pushall().await.ok();
let state = wait_for_state(bambu).await?;
println!(" state before: {state}");
match step.as_str() {
"status" => {
match bambu.list_cache().await {
Ok(entries) => {
println!("\n cache/ ({} entries):", entries.len());
for entry in &entries {
println!(" {entry}");
}
}
Err(err) => println!(" cache list FAILED: {err}"),
}
return Ok(());
}
"probe-missing" => {
// Cannot start anything: the file is not there. This asks only
// whether the command shape is understood.
println!("\n sending print.gcode_file for a file that does not exist:");
println!(" param -> /cache/{MISSING_FILE}");
match bambu
.start_uploaded_file(MISSING_FILE, &FilamentSource::ExternalSpool)
.await
{
Ok(()) => println!(" the printer ACCEPTED it (result: success)"),
Err(err) => anyhow::bail!("the printer answered: {err}"),
}
}
"start" => {
let Some(file) = args.get(3).filter(|a| !a.starts_with("--")).cloned() else {
anyhow::bail!("`start` needs a file name that is already in cache/ - run `status` to list them");
};
// The guard's job is "don't start a print on a machine that may
// already be working", and IDLE is not the only state that
// satisfies it. FINISH and FAILED are both terminal - FAILED is
// what a Bambu reports after an operator cancellation, so
// demanding IDLE made the harness refuse every print following a
// cancelled one, which is the normal case when testing. PREPARE,
// RUNNING, PAUSE and SLICING all mean the machine is committed,
// and anything unrecognised is refused rather than assumed idle.
const READY_TO_START: [&str; 3] = ["IDLE", "FINISH", "FAILED"];
if !READY_TO_START.contains(&state.as_str()) {
anyhow::bail!(
"printer reports '{state}', which is not one of {READY_TO_START:?} - \
refusing to start a print on a machine that may already be working"
);
}
if !confirmed {
anyhow::bail!(
"refusing to start a real print without --yes-start-a-real-print.\n\
This commits the machine to physical work. Be at the printer."
);
}
// `--ams=3` or `--ams=3,-1,0` picks AMS slots per project
// filament index; without it, the external spool. There is no
// default that is safe for both, so the flag has to be explicit.
let filament = match args.iter().find_map(|a| a.strip_prefix("--ams=")) {
Some(list) => {
let slots = list
.split(',')
.map(|slot| slot.trim().parse::<i32>())
.collect::<Result<Vec<_>, _>>()
.map_err(|err| anyhow::anyhow!("--ams needs a comma-separated list of slot numbers: {err}"))?;
// 4 AMS slots + 1 external spool on a P1P with one AMS.
// See the padding rule in the ams_mapping schema.
FilamentSource::ams(slots, P1P_TRAY_COUNT)
.map_err(|err| anyhow::anyhow!("{err}"))?
}
None => FilamentSource::ExternalSpool,
};
match &filament {
FilamentSource::ExternalSpool => {
println!("\n starting print of '{file}' from the EXTERNAL SPOOL ...")
}
FilamentSource::Ams(slots) => {
println!("\n starting print of '{file}' with ams_mapping {slots:?} ...")
}
}
match bambu.start_uploaded_file(&file, &filament).await {
Ok(()) => println!(" accepted"),
Err(err) => anyhow::bail!("the printer answered: {err}"),
}
}
"pause" | "resume" | "stop" => {
let action = match step.as_str() {
"pause" => PrintControl::Pause,
"resume" => PrintControl::Resume,
_ => PrintControl::Stop,
};
println!("\n sending print.{} ...", action.as_str());
match bambu.control_print(action).await {
Ok(()) => println!(" accepted"),
Err(err) => anyhow::bail!("the printer answered: {err}"),
}
}
// `light on|off|flash`. The first two deliberately go through
// `set_light` rather than a raw body: that method had never once
// reached a printer, and `completion::run` blinks a finished print's
// chamber light with it, so the thing worth testing is it and not a
// body written here to match.
"light" => {
let mode = args.get(3).map(String::as_str).unwrap_or("on");
match mode {
"on" | "off" => {
let state = if mode == "on" { LightState::On } else { LightState::Off };
println!("\n set_light({CHAMBER_LIGHT}, {mode}):");
match bambu.set_light(CHAMBER_LIGHT, state).await {
Ok(()) => println!(" accepted"),
Err(err) => println!(" FAILED: {err}"),
}
// "Accepted" is not "it changed" - a ledctrl reply says
// success as soon as the command parses. `lights_report`
// is the only read-back there is, so poll until the
// printer says the light is what we asked for.
report_light(bambu, mode).await;
}
// Raw, because there is no typed call for it yet - that is
// the point of the probe. Deliberately short: 3 cycles of
// 500ms on / 500ms off, matching what `completion::run`
// drives by hand, so the two are comparable by eye.
"flash" => {
let body = serde_json::json!({
"command": "ledctrl",
"led_node": CHAMBER_LIGHT,
"led_mode": "flashing",
"led_on_time": 500,
"led_off_time": 500,
"loop_times": 3,
"interval_time": 1000,
});
println!("\n sending system.ledctrl flashing (3x 500/500, interval 1000):");
match bambu.request_raw("system", body).await {
Ok(reply) => println!(" reply: {reply}"),
Err(err) => println!(" no usable reply: {err}"),
}
println!(
" watch the printer: 3 blinks means loop_times is cycles; \n\
anything else means the field means something other than that"
);
// And read it back, which says whether `flashing` is a
// firmware *mode* the printer holds and reports, or a
// one-shot that leaves the reported mode untouched.
report_light(bambu, "flashing").await;
}
other => anyhow::bail!("`light` takes on, off or flash - not '{other}'"),
}
return Ok(());
}
// `shape <json>` sends one `print` command verbatim as a correlated
// request and prints the reply. For settling an unconfirmed command
// shape against the hardware - see `BambuPrinter::request_raw`.
"shape" => {
let Some(raw) = args.get(3) else {
anyhow::bail!("`shape` needs a JSON body for the `print` category");
};
let body: serde_json::Value = serde_json::from_str(raw)
.map_err(|err| anyhow::anyhow!("that is not valid JSON: {err}"))?;
println!("\n sending print {} ...", body["command"]);
match bambu.request_raw("print", body).await {
Ok(reply) => println!(" reply: {reply}"),
Err(err) => println!(" no usable reply: {err}"),
}
}
// `print-project <local> [name]` is the whole path in one call:
// derive the filament source from the archive, upload, start. Calls
// `GenericPrinter::upload_project_file`, so it tests the production
// entry point rather than the three pieces in sequence.
"print-project" => {
let Some(local) = args.get(3) else {
anyhow::bail!("`print-project` needs a local .3mf path");
};
const READY_TO_START: [&str; 3] = ["IDLE", "FINISH", "FAILED"];
if !READY_TO_START.contains(&state.as_str()) {
anyhow::bail!(
"printer reports '{state}', which is not one of {READY_TO_START:?} - \
refusing to start a print on a machine that may already be working"
);
}
if !confirmed {
anyhow::bail!(
"refusing to start a real print without --yes-start-a-real-print.\n\
This uploads a project and commits the machine to it. Be at the printer."
);
}
let data = std::fs::read(local)?;
let name = args
.get(4)
.filter(|a| !a.starts_with("--"))
.cloned()
.unwrap_or_else(|| format!("continuum-probe-{}", file_stem(local)));
println!("\n upload_project_file({name}, {} bytes):", data.len());
println!(" no --ams flag: the mapping comes out of the archive, not this harness");
let started = std::time::Instant::now();
match bambu.upload_project_file(name.clone(), data).await {
Ok(()) => println!(" accepted after {:?} - watching", started.elapsed()),
Err(err) => {
// The interesting failures are here: an unmappable
// project should fail *before* the upload, leaving
// nothing on the card.
println!(" FAILED after {:?}: {err}", started.elapsed());
println!(" if that was the mapping, the card should be untouched - check with `status`");
return Ok(());
}
}
watch(bambu, state).await;
println!("\n the file is still on the card as {name} - `delete` it when done");
return Ok(());
}
// `upload <local> [name]` puts a file on the card and stops there.
// Inert: a file on the SD card does nothing until something is told
// to print it. The only proven transfer before this was 102 bytes,
// and real projects on the card run to 62 MB - a size at which
// buffering, timeouts and the FTPS data connection all start to
// matter in ways a 102-byte write cannot show.
"upload" => {
let Some(local) = args.get(3) else {
anyhow::bail!("`upload` needs a local file path");
};
let data = std::fs::read(local)?;
let name = args
.get(4)
.cloned()
.unwrap_or_else(|| format!("continuum-probe-{}", file_stem(local)));
println!("\n uploading {} bytes as {name} ...", data.len());
let started = std::time::Instant::now();
match bambu.upload_file(&name, data.clone()).await {
Ok(written) => {
let elapsed = started.elapsed();
println!(" printer accepted {written} bytes in {elapsed:?}");
if written == data.len() as u64 {
let rate = data.len() as f64 / elapsed.as_secs_f64() / 1_048_576.0;
println!(" byte count matches exactly ({rate:.1} MiB/s)");
} else {
println!(" MISMATCH: sent {}, printer took {written}", data.len());
}
}
Err(err) => println!(" upload FAILED: {err}"),
}
return Ok(());
}
// `delete <path>` removes one file. Named in full on the command
// line every time - there is no pattern form on purpose.
"delete" => {
let Some(path) = args.get(3) else {
anyhow::bail!("`delete` needs the exact path of a file on the card");
};
println!("\n deleting {path} ...");
match bambu.delete_file(path).await {
Ok(()) => println!(" gone"),
Err(err) => println!(" delete FAILED: {err}"),
}
return Ok(());
}
// `fetch <path> <dest>` copies one file off the card. Read-only.
"fetch" => {
let (Some(path), Some(dest)) = (args.get(3), args.get(4)) else {
anyhow::bail!("`fetch` needs a path on the printer and a local destination");
};
match bambu.download_file(path).await {
Ok(bytes) => {
std::fs::write(dest, &bytes)?;
println!(" {} bytes -> {dest}", bytes.len());
}
Err(err) => anyhow::bail!("fetch failed: {err}"),
}
}
other => anyhow::bail!("unknown step '{other}'"),
}
watch(bambu, state).await;
Ok(())
}
/// The file name without its directory, for naming an upload after its
/// source. Not `Path::file_stem`, which would strip only the last extension
/// and turn `tray.stl.gcode.3mf` into something that is no longer a `.3mf`.
fn file_stem(path: &str) -> String {
path.rsplit('/').next().unwrap_or(path).to_string()
}
/// Runs `completion::celebrate` - the actual ten-second blink a finished print
/// triggers - against a real printer. The question it answers is a rate one:
/// `celebrate` sends twenty `ledctrl` commands half a second apart, and a
/// printer is entitled to refuse that. It stops at the first failure, so if
/// the rate is too high this prints one warning and ends dark.
async fn blink_probe(target: &str) -> anyhow::Result<()> {
use std::sync::Arc;
use tokio::sync::Mutex;
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");
};
println!("=== {} / step: blink ===", bambu.display_name());
match tokio::time::timeout(CONNECT_TIMEOUT, bambu.connect()).await {
Ok(Ok(())) => {}
Ok(Err(err)) => anyhow::bail!("connect failed: {err}"),
Err(_) => anyhow::bail!("connect timed out - printer off or unreachable?"),
}
}
println!("\n running completion::celebrate for {BLINK_FOR_DISPLAY} - watch the printer");
let fleet: continuum_proxy::uplink::SharedFleet = Arc::new(Mutex::new(printers));
let started = std::time::Instant::now();
continuum_proxy::completion::celebrate(fleet.clone(), target.to_string()).await;
println!(" returned after {:?}", started.elapsed());
// It is specified to end dark, which is the part worth checking: a blink
// that leaves the light on looks like a printer that is still busy.
let fleet = fleet.lock().await;
if let Some(PrinterHandle::Bambu(bambu)) = fleet.iter().find(|p| p.base().id == target) {
report_light(bambu, "off").await;
}
Ok(())
}
/// Polls `lights_report` until the chamber light reads `want`, printing every
/// change with the time it took. Gives up rather than hanging: a light that
/// never reports what was asked for is the result, not a reason to wait.
async fn report_light(bambu: &continuum_proxy::printer::BambuPrinter, want: &str) {
let started = std::time::Instant::now();
let mut last = String::new();
while started.elapsed() < LIGHT_READBACK {
let mode = bambu
.print_status()
.await
.and_then(|r| r.lights_report)
.and_then(|lights| {
lights
.into_iter()
.find(|l| l.node.as_deref() == Some(CHAMBER_LIGHT))
.and_then(|l| l.mode)
});
let mode = mode.unwrap_or_else(|| "<not reported>".into());
if mode != last {
println!(" lights_report after {:?}: {CHAMBER_LIGHT} = {mode}", started.elapsed());
last = mode.clone();
}
if mode == want {
println!(" confirmed: the printer reports '{want}', not just that it accepted it");
return;
}
bambu.pushall().await.ok();
tokio::time::sleep(Duration::from_millis(1500)).await;
}
println!(" gave up after {LIGHT_READBACK:?}: last reported '{last}', asked for '{want}'");
}
async fn wait_for_state(bambu: &continuum_proxy::printer::BambuPrinter) -> anyhow::Result<String> {
let deadline = tokio::time::Instant::now() + STATUS_TIMEOUT;
loop {
if let Some(report) = bambu.print_status().await
&& let Some(state) = report.gcode_state.clone()
{
return Ok(state);
}
if tokio::time::Instant::now() >= deadline {
anyhow::bail!(
"no print.push_status with a gcode_state within {STATUS_TIMEOUT:?} - refusing to \
go further, because there is no way to tell what this printer is doing"
);
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
/// Reports every change to the fields that would show a command landing.
async fn watch(bambu: &continuum_proxy::printer::BambuPrinter, before: String) {
println!("\n watching the report stream for {WATCH:?} ...");
let deadline = tokio::time::Instant::now() + WATCH;
let mut last = before;
let mut last_error: Option<i32> = None;
let mut last_task = String::new();
while tokio::time::Instant::now() < deadline {
// v1 needs asking each time for a complete object.
bambu.pushall().await.ok();
tokio::time::sleep(Duration::from_millis(1500)).await;
if let Some(report) = bambu.print_status().await {
if let Some(state) = report.gcode_state.clone()
&& state != last
{
println!(" gcode_state: {last} -> {state}");
last = state;
}
if report.print_error != last_error {
println!(" print_error: {:?} -> {:?}", last_error, report.print_error);
last_error = report.print_error;
}
let task = report.subtask_name.clone().unwrap_or_default();
if task != last_task {
println!(" subtask_name: '{last_task}' -> '{task}'");
last_task = task;
}
if let Some(hms) = &report.hms
&& !hms.is_empty()
{
println!(" hms (printer alerts): {}", serde_json::to_string(hms).unwrap_or_default());
}
}
}
println!(" final state: {last}");
}
+279
View File
@@ -0,0 +1,279 @@
//! Finds printers and prints `printers.toml` blocks for them.
//!
//! ```text
//! cargo run --example discover_printers -- all [a.b.c.0/24] # everything safe
//! cargo run --example discover_printers -- listen [seconds] # passive SSDP (Bambu)
//! cargo run --example discover_printers -- raw [seconds] # ... and dump every datagram
//! 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
//! ```
//!
//! Everything except `serial-probe` is **read-only and sends no credentials**.
//! `listen` only receives. `identify` and `sweep` open a connection and read
//! what the far end volunteers - a TLS certificate, an HTTP identity endpoint
//! - without a session or a key. `serial` reads sysfs and opens no device.
//!
//! **`serial-probe` resets the printer**, because opening a serial port
//! toggles DTR on an Arduino-based board. That ruins a print in progress, so
//! it refuses to run without `--yes-reset-the-printer`, the same shape as
//! `bambu_print_probe`'s `--yes-start-a-real-print`.
//!
//! Which network subcommand to use depends on where the gateway sits.
//! `listen` learns the most about a Bambu printer - the model and the
//! operator's own name come only from SSDP - but multicast does not cross a
//! router, so it finds nothing unless this host is on the printers' segment.
//! `sweep` works from anywhere that can route to them, and covers Bambu,
//! Klipper and PrusaLink. `all` does both and merges the result.
//!
//! It deliberately does not write `printers.toml`: a Bambu access code is on
//! the printer's screen and a PrusaLink API key is in its web UI, so every
//! block printed here is incomplete by construction. `fleet::load` rejects an
//! entry whose credential is empty, which means pasting one in and forgetting
//! to finish it fails loudly at the next start rather than quietly.
use std::collections::HashSet;
use std::time::Duration;
use continuum_proxy::discovery::{self, Candidate, net, serial};
/// Long enough to catch Bambu's announcement interval a few times over.
const LISTEN_SECONDS: u64 = 15;
/// Per-host probe budget. Generous for a LAN, but a sweep spends it on every
/// address that is not there, so the batching in `sweep_hosts` matters more.
const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
/// A serial handshake has to wait out a board reset before it can even ask.
const SERIAL_TIMEOUT: Duration = Duration::from_secs(10);
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
.init();
let args: Vec<String> = std::env::args().skip(1).collect();
let Some(command) = args.first().map(String::as_str) else {
anyhow::bail!(usage());
};
let found = match command {
"all" => {
let mut found = listen(LISTEN_SECONDS, false).await?;
match args.get(1) {
Some(subnet) => found.extend(sweep(subnet).await?),
// Without a subnet the sweep is skipped rather than guessed:
// 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()?);
found
}
"listen" | "raw" => {
let seconds = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(LISTEN_SECONDS);
listen(seconds, command == "raw").await?
}
"identify" => {
let hosts = &args[1..];
if hosts.is_empty() {
anyhow::bail!("identify needs at least one host");
}
let mut found = Vec::new();
for host in hosts {
let candidates = net::identify_any(host, PROBE_TIMEOUT).await;
if candidates.is_empty() {
// Named explicitly, unlike in a sweep: the operator asked
// about this address, so silence is the wrong answer.
eprintln!("{host}: nothing answered as a Bambu, Klipper or PrusaLink printer");
}
for candidate in &candidates {
println!("{host}: {} - {}", candidate.vendor.toml_value(), describe(candidate));
}
found.extend(candidates);
}
found
}
"sweep" => {
let subnet = args.get(1).ok_or_else(|| anyhow::anyhow!("sweep needs a subnet, e.g. 192.168.0.0/24"))?;
sweep(subnet).await?
}
"serial" => list_serial()?,
"serial-probe" => {
let port = args.get(1).ok_or_else(|| anyhow::anyhow!("serial-probe needs a port, e.g. /dev/ttyACM0"))?;
let baud = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(serial::DEFAULT_BAUD_RATE);
if !args.iter().any(|a| a == "--yes-reset-the-printer") {
anyhow::bail!(
"opening {port} will RESET the printer on it - on an Arduino-based board the \
port open toggles DTR, which reboots the machine and ruins any print in \
progress. Re-run with --yes-reset-the-printer if nothing is printing. \
`serial` (no probe) lists ports without opening any."
);
}
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()),
};
report(discovery::merge(found));
Ok(())
}
fn usage() -> String {
"usage: discover_printers all [subnet] | listen [seconds] | raw [seconds] | identify <host>... \
| sweep <a.b.c.0/24> | serial | wire [subnet] [--ssdp] \
| serial-probe <port> [baud] --yes-reset-the-printer"
.to_string()
}
async fn sweep(subnet: &str) -> anyhow::Result<Vec<Candidate>> {
// Expanded before anything is printed, so a refused subnet says so
// instead of first claiming to be probing it.
let hosts = net::hosts_in_subnet(subnet)?;
println!(
"probing {} addresses in {subnet} - Bambu on {}, Klipper on {}, PrusaLink on {} ...",
hosts.len(),
net::BAMBU_MQTT_PORT,
net::MOONRAKER_PORT,
net::PRUSALINK_PORT
);
Ok(net::sweep_hosts(&hosts, PROBE_TIMEOUT).await)
}
async fn listen(seconds: u64, raw: bool) -> anyhow::Result<Vec<Candidate>> {
let window = Duration::from_secs(seconds);
println!("listening {seconds}s on {:?} for {} ...", discovery::SSDP_PORTS, discovery::SSDP_GROUP);
// Both ports at once, because which one a printer announces on depends on
// its firmware and the operator has no way to know which.
let listeners = discovery::SSDP_PORTS.map(|port| discovery::listen_ssdp(port, window, raw));
let mut found: Vec<Candidate> = Vec::new();
for result in futures_util::future::join_all(listeners).await {
match result {
Ok(candidates) => found.extend(candidates),
// One port being unavailable - held by another process, or a
// kernel that will not join the group - must not lose the other.
Err(err) => eprintln!("warning: {err}"),
}
}
Ok(found)
}
fn list_serial() -> anyhow::Result<Vec<Candidate>> {
let ports = serial::list_ports()?;
if ports.is_empty() {
println!("no USB/serial ports found.");
return Ok(Vec::new());
}
println!("{} serial port(s):", ports.len());
for port in &ports {
let confidence = if port.is_probably_a_printer() { "printer" } else { "maybe" };
println!(" {} [{confidence}] {}", port.path, port.to_candidate().detail.unwrap_or_default());
}
// USB descriptors describe the bridge chip, so for anything but a Prusa
// this is as far as passive detection goes - and the operator should know
// that the next step has a cost.
if ports.iter().any(|port| !port.is_probably_a_printer()) {
println!(
"\nUSB alone cannot tell a printer from any other serial device. `serial-probe <port>`\n\
asks the board directly with M115, but opening the port RESETS it - don't while printing."
);
}
Ok(ports.iter().map(serial::SerialDevice::to_candidate).collect())
}
fn describe(candidate: &Candidate) -> String {
let mut parts = Vec::new();
if let Some(serial) = &candidate.serial {
parts.push(format!("serial {serial}"));
}
if let Some(name) = &candidate.name {
parts.push(name.clone());
}
if let Some(detail) = &candidate.detail {
parts.push(detail.clone());
}
if parts.is_empty() { "no details available".to_string() } else { parts.join(", ") }
}
fn report(found: Vec<Candidate>) {
if found.is_empty() {
println!(
"\nNo printers found.\n\
If that was `listen`: multicast does not cross a router, so a gateway on a\n\
different subnet from its printers will never hear them - use\n\
`sweep <their subnet>/24` instead. If it was `sweep`: check the subnet, and\n\
that the printers have LAN mode (Bambu) or their API enabled."
);
return;
}
println!("\n{} printer(s) found. Paste into printers.toml and add each credential:\n", found.len());
let mut used: HashSet<String> = HashSet::new();
for candidate in &found {
// Two of the same model suggest the same id, and a duplicate `id`
// would silently give the fleet two printers it cannot tell apart.
let base = candidate.suggested_id();
let mut id = base.clone();
let mut n = 2;
while !used.insert(id.clone()) {
id = format!("{base}-{n}");
n += 1;
}
println!("{}", candidate.to_toml_entry(&id));
}
if found.iter().any(|c| !c.vendor.driver_is_implemented()) {
println!(
"Note: some of these vendors' clients in printer/ are still stubs - those entries\n\
are marked, and a gateway will connect to them and do nothing useful yet."
);
}
}
-37
View File
@@ -1,37 +0,0 @@
//! Run with: cargo run --example fetch_bambu_cert -- <printer_id> <output_path>
//!
//! "Trust on first connect": connects to one printer from printers.toml
//! with certificate verification disabled, captures whatever certificate
//! it presents, and saves it as PEM. Meant for a printer like P1P that
//! doesn't chain to the bundled CA - after running this, point that
//! printer's `ca_cert_path` in printers.toml at the saved file and
//! `test_bambu_certs` should report it as verified from then on.
//!
//! This trusts whoever answers on the network *right now* - only run it on
//! a network you trust, ideally right after unboxing the printer.
use continuum_proxy::fleet;
use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
fn main() -> anyhow::Result<()> {
let mut args = std::env::args().skip(1);
let printer_id = args.next().ok_or_else(|| anyhow::anyhow!("usage: fetch_bambu_cert <printer_id> <output_path>"))?;
let output_path = args.next().ok_or_else(|| anyhow::anyhow!("usage: fetch_bambu_cert <printer_id> <output_path>"))?;
let printers = fleet::load(std::path::Path::new("printers.toml"))?;
let pem = printers
.iter()
.find_map(|printer| match printer {
PrinterHandle::BambuV1(bambu) if bambu.base().id == printer_id => Some(bambu.fetch_certificate()),
PrinterHandle::BambuV2(bambu) if bambu.base().id == printer_id => Some(bambu.fetch_certificate()),
_ => None,
})
.ok_or_else(|| anyhow::anyhow!("no Bambu printer with id '{printer_id}' in printers.toml"))??;
std::fs::write(&output_path, &pem)?;
println!("saved {printer_id}'s certificate to {output_path}");
println!("now set ca_cert_path = \"{output_path}\" for {printer_id} in printers.toml");
Ok(())
}
+178
View File
@@ -0,0 +1,178 @@
//! Run with: cargo run --example live_reports
//!
//! **Read-only. Safe to run against a printer that is mid-print.**
//!
//! Connects to every Bambu printer in printers.toml, asks each one what
//! firmware it's running, then watches its `print.push_status` stream for a
//! while. Everything it sends either reads state or asks the printer to
//! re-send what it already knows:
//!
//! - `get_version` - read-only.
//! - `pushall` - "re-send the full status object". A request for data,
//! not a state change. v1 only; a no-op on v2.
//! - `print_status` - reads this process's own in-memory cache, no traffic.
//!
//! It deliberately sends **no** `gcode_line`, **no** `set_fan_speed`, **no**
//! `calibration` (that one starts a physical calibration sequence and would
//! wreck a running print), no upload, and nothing that starts or stops a
//! job. Keep it that way - the whole point of this example is that it can
//! be pointed at a working printer without a second thought. Use
//! `bambu_idle_checks` for anything that writes.
//!
//! The raw payloads are the real output. `PrintReport` models only a small
//! fraction of what a real `push_status` carries (see its doc comment), so
//! this turns on the `trace`-level raw-payload log in `bambu_mqtt.rs` and
//! lets you capture the untouched JSON:
//!
//! ```sh
//! cargo run --example live_reports 2> capture.jsonl
//! ```
//!
//! Then grow `PrintReport` and `continuum-schemas`' `push_status.schema.json`
//! from what's actually in there, rather than from guesses.
use std::time::Duration;
use continuum_proxy::fleet;
use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
use tracing_subscriber::EnvFilter;
/// How long to sit and watch each printer's stream before moving on.
const WATCH_FOR: Duration = Duration::from_secs(25);
/// How often to print a one-line summary of the cached report.
const POLL_EVERY: Duration = Duration::from_secs(2);
/// A printer that's switched off shouldn't hold the whole run up - the TLS
/// handshake inside `connect` has its own 5s timeout, but the MQTT half
/// doesn't, so this bounds the lot.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Default to trace for the raw-payload target specifically, so a plain
// run captures payloads without needing RUST_LOG set by hand. An
// explicit RUST_LOG still wins.
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| {
EnvFilter::new("info,continuum_proxy::printer::bambu_mqtt=trace")
}))
.with_writer(std::io::stderr)
.init();
let mut printers = fleet::load(std::path::Path::new("printers.toml"))?;
// Optional: a printer id to look at just that one. No argument means the
// whole fleet. Read-only either way, so there's nothing to guard here.
let only: Option<String> = std::env::args().nth(1);
for printer in &mut printers {
if let Some(id) = &only
&& &printer.base().id != id
{
continue;
}
let PrinterHandle::Bambu(bambu) = printer else {
println!("skipping {} - not a Bambu printer", printer.display_name());
continue;
};
println!("\n=== {} ===", bambu.display_name());
match tokio::time::timeout(CONNECT_TIMEOUT, bambu.connect()).await {
Ok(Ok(())) => {}
Ok(Err(err)) => {
println!(" connect failed: {err}");
continue;
}
Err(_) => {
println!(" connect timed out after {CONNECT_TIMEOUT:?} - printer off or unreachable?");
continue;
}
}
match bambu.get_version().await {
Ok(version) => {
println!(" get_version OK: {} modules", version.module.len());
for module in &version.module {
println!(" {:<12} sw {:<14} hw {}", module.name, module.sw_ver, module.hw_ver);
}
}
Err(err) => println!(" get_version failed: {err}"),
}
// v1 needs this to get a full object at all; it's a no-op on v2 (see
// `pushall`'s doc comment). Harmless either way.
if let Err(err) = bambu.pushall().await {
println!(" pushall failed: {err}");
}
println!(" watching print.push_status for {WATCH_FOR:?}...");
let deadline = tokio::time::Instant::now() + WATCH_FOR;
// Keyed on the *contents*, not on `sequence_id`. v2 leaves
// `sequence_id` pinned at one value while the report genuinely
// changes underneath it (confirmed live - see `PrintStatusCache`),
// so keying on it reports one update for a whole print.
let mut last_seen: Option<String> = None;
let mut updates = 0u32;
while tokio::time::Instant::now() < deadline {
tokio::time::sleep(POLL_EVERY).await;
match bambu.print_status().await {
Some(report) => {
let seq = report.envelope.sequence_id.clone();
let fingerprint = format!(
"{:?}|{:?}|{:?}|{:?}|{:?}|{:?}",
report.gcode_state,
report.mc_percent,
report.layer_num,
report.mc_remaining_time,
report.nozzle_temper,
report.bed_temper
);
let changed = last_seen.as_ref() != Some(&fingerprint);
if changed {
updates += 1;
last_seen = Some(fingerprint);
}
println!(
" seq={seq:<6} {:<8} {:>3}% layer {}/{} {} min left nozzle {:.1}/{:.1}C bed {:.1}/{:.1}C{}",
report.gcode_state.as_deref().unwrap_or("?"),
report.mc_percent.unwrap_or(0),
report.layer_num.unwrap_or(0),
report.total_layer_num.unwrap_or(0),
report.mc_remaining_time.unwrap_or(0),
report.nozzle_temper.unwrap_or(0.0),
report.nozzle_target_temper.unwrap_or(0.0),
report.bed_temper.unwrap_or(0.0),
report.bed_target_temper.unwrap_or(0.0),
if changed { "" } else { " (unchanged)" }
);
if let Some(job) = report.subtask_name.as_deref().filter(|s| !s.is_empty()) {
println!(" job: {job}");
}
if let Some(hms) = report.hms.as_ref().filter(|h| !h.is_empty()) {
println!(" hms: {} active entr(y/ies)", hms.len());
}
println!(
" fans: cooling={} heatbreak={} big1={} big2={} wifi={}",
report.cooling_fan_speed.as_deref().unwrap_or("?"),
report.heatbreak_fan_speed.as_deref().unwrap_or("?"),
report.big_fan1_speed.as_deref().unwrap_or("?"),
report.big_fan2_speed.as_deref().unwrap_or("?"),
report.wifi_signal.as_deref().unwrap_or("?")
);
}
// Reaching here now means genuinely nothing arrived. It used
// to also mean "something arrived and failed to parse",
// which is why a mid-print H2C once looked completely
// silent - see `handle_incoming_report`.
None => println!(" no push_status received yet"),
}
}
println!(" {updates} distinct push_status update(s) in {WATCH_FOR:?}");
}
println!("\ndone - raw payloads went to stderr (redirect it to capture them)");
Ok(())
}
+27 -18
View File
@@ -4,36 +4,46 @@
//! - `connect()` is called the same way regardless of vendor (polymorphism),
//! but each variant's `impl GenericPrinter` runs completely different
//! code (override).
//! - `dispatch_gcode()` only exists on the two Bambu structs (extension) -
//! you have to `match` the enum back down to the concrete type to reach
//! it, which is the trade-off for not having implicit downcasting.
//! - `dispatch_gcode()` only exists on the Bambu struct (extension) - you
//! have to `match` the enum back down to the concrete type to reach it,
//! which is the trade-off for not having implicit downcasting.
use continuum_proxy::printer::{
BambuTls, BambuV1Printer, BambuV2Printer, GenericPrinter, KlipperPrinter, PrinterHandle, PrusaLinkPrinter,
PrusaSerialPrinter,
BambuPrinter, BambuProtocolPreference, BambuTls, GenericPrinter, KlipperPrinter, PrinterHandle,
OctoPrintFlavour, OctoPrintPrinter, PrusaSerialPrinter,
};
#[tokio::main]
async fn main() {
let mut fleet: Vec<PrinterHandle> = vec![
PrinterHandle::BambuV1(BambuV1Printer::new(
// Forced: a known-old printer.
PrinterHandle::Bambu(BambuPrinter::new(
"p1",
"P1S",
"192.168.1.49",
"87654321",
"01P00A000000000",
BambuTls::BundledCa,
BambuProtocolPreference::ForceV1,
)),
PrinterHandle::BambuV2(BambuV2Printer::new(
// Auto: the recommended default - see bambu.rs's doc comment.
PrinterHandle::Bambu(BambuPrinter::new(
"p2",
"X1 Carbon",
"192.168.1.50",
"12345678",
"00M00A000000000",
BambuTls::BundledCa,
BambuProtocolPreference::Auto,
)),
PrinterHandle::PrusaLink(PrusaLinkPrinter::new("p3", "MK4", "192.168.1.51", "prusa-api-key")),
PrinterHandle::PrusaSerial(PrusaSerialPrinter::new("p4", "MK3S+", 0)),
PrinterHandle::OctoPrint(OctoPrintPrinter::new(
"p3",
"MK4",
"192.168.1.51",
"prusa-api-key",
OctoPrintFlavour::PrusaLink,
)),
PrinterHandle::PrusaSerial(PrusaSerialPrinter::new("p4", "MK3S+", "/dev/ttyACM0", 115_200)),
PrinterHandle::Klipper(KlipperPrinter::new("p5", "Voron 2.4", "192.168.1.52")),
// No host configured - this one will hit the Err path.
PrinterHandle::Klipper(KlipperPrinter::new("p6", "Broken Voron", "")),
@@ -50,16 +60,15 @@ async fn main() {
}
// Vendor-only extension: reachable only after matching the concrete
// variant back out of the enum. BambuV1Printer and BambuV2Printer are
// separate types now (each *has* a BambuGenericPrinter rather than
// being the same struct), so - unlike before the split - this needs
// two arms instead of one `A(x) | B(x)` pattern: an or-pattern requires
// every alternative to bind the same type, and these no longer do.
// variant back out of the enum - one arm now that Bambu is a single
// type regardless of which protocol version `connect()` settled on.
// `print_status()` is real Bambu-only behavior with no equivalent on
// `GenericPrinter` (no other vendor here has an in-memory report
// cache) - none of these connected above (fake IPs), so it's always
// `None`, but the point is the downcast, not a live reading.
for printer in &fleet {
match printer {
PrinterHandle::BambuV1(bambu) => bambu.dispatch_gcode("part.gcode.3mf"),
PrinterHandle::BambuV2(bambu) => bambu.dispatch_gcode("part.gcode.3mf"),
_ => {}
if let PrinterHandle::Bambu(bambu) = printer {
println!(" {}: cached print_status = {:?}", bambu.sn, bambu.print_status().await)
}
}
+3 -8
View File
@@ -7,19 +7,14 @@
//! this trust question.
use continuum_proxy::fleet;
use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
use continuum_proxy::printer::PrinterHandle;
fn main() -> anyhow::Result<()> {
let printers = fleet::load(std::path::Path::new("printers.toml"))?;
// BambuV1Printer and BambuV2Printer are separate types (each *has* a
// BambuGenericPrinter rather than being the same struct), so this
// needs one arm per variant rather than a single `A(x) | B(x)` pattern.
for printer in &printers {
match printer {
PrinterHandle::BambuV1(bambu) => report(bambu.base().name.as_str(), bambu.test_tls_handshake()),
PrinterHandle::BambuV2(bambu) => report(bambu.base().name.as_str(), bambu.test_tls_handshake()),
_ => {}
if let PrinterHandle::Bambu(bambu) = printer {
report(bambu.base().name.as_str(), bambu.test_tls_handshake())
}
}
+42 -7
View File
@@ -1,24 +1,52 @@
# Copy this file to printers.toml (gitignored) and fill in your real
# printers. See src/fleet.rs for the loader, examples/test_bambu_certs.rs
# for a way to test a Bambu printer's certificate against this config.
#
# Rather than reading these fields off each printer's screen:
#
# cargo run --example discover_printers -- all 192.168.0.0/24
#
# finds the printers on your network and on your USB ports and prints blocks
# like the ones below, with the addresses and serials already filled in. It
# covers Bambu, Klipper/Moonraker, PrusaLink and Marlin-over-serial. Also
# `listen` (Bambu on this subnet only), `sweep <subnet>` (works through a
# router), `identify <host>`, and `serial`.
#
# It cannot find a credential - a Bambu access code is on the printer's
# screen and a PrusaLink API key is in its web UI, which is the point of both
# - so every block it prints still needs that one field before this file will
# load. Note that only `bambu` has a working client today; the others are
# found and reported, but their drivers in src/printer/ are still stubs.
#
# One thing discovery cannot answer for you: `octoprint` vs `prusa_link`.
# PrusaLink implements OctoPrint's API, so from the network the two are
# identical - discovery suggests `octoprint` (the protocol it actually saw)
# and leaves a comment offering the other. Change it if you know it is a
# Prusa serving its own API.
[[printer]]
id = "p1p"
name = "P1P"
vendor = "bambu_v1" # bambu_v1 | bambu_v2 | prusa_link | prusa_serial | klipper
# Forced instead of "bambu" here since this one's a known-old printer -
# see fleet.rs's PrinterEntry doc comment for the full vendor list.
vendor = "bambu_v1"
host = "192.168.1.50"
access_code = "REPLACE_WITH_ACCESS_CODE"
sn = "REPLACE_WITH_SERIAL_NUMBER" # printer screen > Settings > Device (used for MQTT topics later)
# P1P doesn't chain to the bundled CA - point this at a cert you've
# downloaded from the printer itself. Leave unset to use the bundled CA.
# `cargo run --example fetch_bambu_cert -- p1p ./certs/p1p.pem` can fetch it
# for you (trust-on-first-connect - see that example's doc comment).
ca_cert_path = "./certs/p1p.pem"
# No ca_cert_path needed even though P1P doesn't chain to the bundled CA -
# `connect()` auto-pins a certificate via trust-on-first-connect the first
# time it can't verify against the bundled one, and saves it under
# certs/pinned/<sn>.pem for every run after that (see BambuPrinter::
# ensure_trusted's doc comment). Only set ca_cert_path explicitly if you
# want to point at a certificate you've captured/vetted some other way.
[[printer]]
id = "h2c"
name = "H2C"
vendor = "bambu_v2"
# "bambu" auto-detects MQTT v5-vs-v3.1.1 (BambuMQTT v2-vs-v1) on connect -
# the recommended default, since a firmware update can change which one a
# given printer speaks without you knowing ahead of time.
vendor = "bambu"
host = "192.168.1.51"
access_code = "REPLACE_WITH_ACCESS_CODE"
sn = "REPLACE_WITH_SERIAL_NUMBER"
@@ -37,3 +65,10 @@ sn = "REPLACE_WITH_SERIAL_NUMBER"
# vendor = "prusa_link"
# host = "192.168.1.53"
# api_key = "REPLACE_WITH_API_KEY"
# [[printer]]
# id = "mk3s"
# name = "MK3S+"
# vendor = "prusa_serial"
# port = "/dev/ttyACM0"
# baud_rate = 115200 # optional, defaults to 115200
+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());
}
}
+586
View File
@@ -0,0 +1,586 @@
//! Turns the printers' own camera URLs into a go2rtc configuration.
//!
//! `go2rtc.rs` has supervised a go2rtc process since before this module
//! existed, and go2rtc has never had anything to serve: nothing wrote its
//! config file. `CONTINUUM_GO2RTC_CONFIG` pointed at `./go2rtc.yaml`, which
//! did not exist in the repository or anywhere else. This writes it.
//!
//! ## Where the URL comes from
//!
//! Not from `printers.toml`. A printer reports its own camera URL in
//! `print.push_status`'s `ipcam` block, so asking an operator to copy it into
//! a config file would be asking them to keep a second copy of something the
//! machine already tells us - and to update it by hand when the printer's
//! address changes.
//!
//! ## What does not work, which is most printers
//!
//! 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.
//!
//! 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
//! *refuses* 322 and listens on 6000. So a P1P has no RTSP endpoint to
//! restream, and no amount of guessing a URL would produce one.
//!
//! That is also why this module reads the *reported* URL and never builds one
//! from the printer's IP: the fact that a printer has a camera and the fact
//! that the camera has an RTSP endpoint are different facts, and only the
//! second one is useful.
//!
//! ## What is confirmed to work
//!
//! Measured end to end against a live H2C with go2rtc 1.9.14 (2026-10-18):
//! this module's generated config is enough for go2rtc to connect to
//! `rtsps://bblp:<access_code>@<ip>:322/streaming/live/1` and pull video -
//! 984,604 bytes over `rtsp+tcp`, no certificate complaint and no
//! authentication failure. The printer runs LIVE555 and sends **H.264 High
//! profile, level 4.1**, which a browser plays natively over WebRTC, so no
//! transcoding step (and no ffmpeg on the gateway) is needed. The
//! `rtspx://` prefix that go2rtc documents for skipping TLS verification is
//! not required here.
use std::collections::BTreeMap;
use std::time::Duration;
use tokio::sync::Notify;
use tracing::{info, warn};
use crate::printer::{GenericPrinter, PrinterHandle};
use crate::uplink::SharedFleet;
/// How often to rebuild the configuration from what the printers report.
///
/// 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
/// authenticate with.
const CAMERA_USER: &str = "bblp";
/// One printer's playable stream.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CameraSource {
/// Our own printer id, which doubles as the go2rtc stream name. Using it
/// for both means the cloud can name a stream without the gateway having
/// to report a mapping: `camera_stream` in telemetry is this id.
pub stream_name: String,
/// The full source URL, credentials included.
pub url: String,
}
/// Builds the source URL for one printer, or `None` if it has no stream.
///
/// The credentials go in the URL because that is the only place go2rtc takes
/// them for an RTSP source. The consequence is that the printer's access code
/// ends up in a file on disk - which is not a new exposure (`printers.toml`
/// already holds it, and that file is gitignored for this reason) but it does
/// mean the generated config deserves the same treatment, and must never be
/// logged.
pub fn source_for(printer: &PrinterHandle, report_url: Option<&str>) -> Option<CameraSource> {
let PrinterHandle::Bambu(bambu) = printer else {
// Nothing else has a camera path here yet. A Prusa's is a separate
// product, and a Klipper camera is whatever the user configured.
return None;
};
let url = report_url?;
if url.is_empty() {
return None;
}
// `rtsps://1.2.3.4:322/streaming/live/1` -> `rtsps://bblp:code@1.2.3.4:322/...`
let with_credentials = match url.split_once("://") {
Some((scheme, rest)) => {
// A URL that already carries credentials is left alone rather
// than having a second set spliced in.
if rest.contains('@') {
url.to_string()
} else {
format!("{scheme}://{CAMERA_USER}:{}@{rest}", bambu.access_code)
}
}
// Not a URL shape this understands. Refused rather than guessed at:
// a malformed source makes go2rtc fail to load the whole file, which
// would take every *working* stream down with it.
None => {
warn!(
printer = %bambu.display_name(),
"the printer reported a camera URL with no scheme; ignoring it"
);
return None;
}
};
Some(CameraSource {
stream_name: printer.base().id.clone(),
url: with_credentials,
})
}
/// Renders a go2rtc configuration.
///
/// Hand-written YAML rather than a serializer, for the same reason the
/// `.3mf` reader is hand-rolled: it is a fixed, tiny shape, and a dependency
/// for ten lines of output is a dependency to license and audit. The values
/// are quoted and the keys are our own printer ids, which `fleet.rs`
/// constrains.
///
/// `listen` is explicit rather than left to go2rtc's defaults, because the
/// cloud is told this address and a default that changes between go2rtc
/// versions would change where the stream lives without anything saying so.
/// Basic-auth credentials for go2rtc's API listener.
///
/// The API is what serves WebRTC, so without this anyone who can reach
/// `http://<gateway>:1984` can watch any printer with a camera - and the
/// listener defaults to `:1984`, every interface. Measured against go2rtc
/// 1.9.14, `username`/`password` under `api:` really does enforce it: from a
/// non-loopback address, no credentials and wrong credentials both get 401
/// and the correct ones get 200, on `/api/webrtc`, `/api/ws`,
/// `/api/frame.jpeg` and the web UI alike.
///
/// **Loopback is exempt**, which is a deliberate convenience of go2rtc's and
/// worth knowing twice over: it is why a probe from the gateway itself still
/// works with no credentials, and it is why a probe from the gateway itself
/// cannot be used to check that auth is on. Test from another host.
pub struct ApiAuth {
pub username: String,
pub password: String,
}
impl ApiAuth {
/// The operator's password if they set one, otherwise a fresh random one.
///
/// 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 - 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
/// documented as a secret, so that is where an operator reads it from -
/// one secret store rather than two.
pub fn new() -> anyhow::Result<Self> {
let password = match std::env::var("CONTINUUM_GO2RTC_PASSWORD") {
Ok(set) if !set.is_empty() => set,
// `||` semantics, not `??`: an empty value in a deployment
// template is "unset", not "the empty password".
_ => random_secret()?,
};
Ok(Self { username: "continuum".to_string(), password })
}
}
/// 32 bytes of kernel randomness, base64url so it is safe unquoted in YAML.
///
/// Read from `/dev/urandom` with the standard library rather than pulling in
/// a crate for it: the gateway is Linux, this is the one place that needs
/// randomness, and a new dependency would need a licence review (see
/// `continuum-common/docs/LICENSES.md`).
fn random_secret() -> anyhow::Result<String> {
use base64::Engine;
use std::io::Read;
let mut bytes = [0u8; 32];
std::fs::File::open("/dev/urandom")?.read_exact(&mut bytes)?;
Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes))
}
/// Escapes a value for a YAML double-quoted scalar.
///
/// Only needed for an operator-supplied password - a generated one is
/// base64url and has nothing to escape - but a password containing a quote
/// would otherwise produce a config go2rtc cannot parse, and the symptom
/// would be a camera that silently never appears.
fn yaml_quoted(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}
pub fn render_config(api_listen: &str, sources: &[CameraSource], auth: &ApiAuth) -> String {
let mut out = String::from(
"# Generated by continuum-proxy. Do not edit: it is rewritten from\n\
# what the printers themselves report, so an edit here is lost on\n\
# the next rebuild. Contains printer access codes - treat it as a\n\
# secret and keep it out of version control.\n",
);
// Sorted, so an unchanged fleet renders byte-identical output and the
// change detection below does not restart go2rtc on every rebuild.
let ordered: BTreeMap<&str, &str> = sources
.iter()
.map(|s| (s.stream_name.as_str(), s.url.as_str()))
.collect();
out.push_str("streams:\n");
if ordered.is_empty() {
// An empty mapping, written explicitly. `streams:` with nothing
// under it is a null in YAML, and go2rtc is entitled to treat that
// differently from an empty map.
out.push_str(" {}\n");
}
for (name, url) in &ordered {
out.push_str(&format!(" {name}: \"{url}\"\n"));
}
out.push_str(&format!("api:\n listen: \"{api_listen}\"\n"));
// Not optional, and there is no flag to turn it off. The alternative is
// an unauthenticated video feed of someone's workshop reachable by
// anything on their network, which is not a thing to leave behind a
// setting. Nothing in this crate calls go2rtc's API, so nothing here
// breaks; a browser cannot use these credentials directly either way -
// see the CORS note in the module docs.
out.push_str(&format!(
" username: \"{}\"\n password: \"{}\"\n",
yaml_quoted(&auth.username),
yaml_quoted(&auth.password),
));
// Closes go2rtc's own RTSP server, and this has to be explicit.
//
// Leaving the key out does NOT leave the server off - go2rtc defaults to
// :8554. Measured against go2rtc 1.9.14 with a config that omitted it:
// `rtsp://<gateway>:8554/<printer>` answered a DESCRIBE carrying a real
// video SDP, with no credentials sent at all. Every printer's camera was
// republished to anything that could reach the port.
//
// Nothing needs it: a browser is served over WebRTC through the API
// listener above. An empty `listen` is how go2rtc is told not to bind.
out.push_str("rtsp:\n listen: \"\"\n");
// `webrtc:` is deliberately left alone, and with TURN in the picture that
// is now a decision worth stating rather than an omission.
//
// go2rtc keeps its default media port and its default candidate
// gathering, which produces a host candidate and - measured against
// 1.9.14 - a server-reflexive one it discovers by STUN on its own. That
// is everything this end needs. No `ice_servers` here, and in particular
// **no TURN credentials on the gateway**: the viewer's browser is pinned
// to relay-only, so the browser allocates the relay and go2rtc simply
// sends to it from its own candidate. Only one side of a WebRTC session
// has to hold a relay for the media to traverse one.
//
// That asymmetry is what makes this tractable at all, because go2rtc
// reads its config exactly once at startup: TURN credentials are
// short-lived, so rotating them here would mean restarting go2rtc and
// dropping every viewer on a timer. Keeping them browser-side moves the
// rotation to a request that is already per-viewer.
//
// This is not a second open stream server either way: a WebRTC session
// exists only after an SDP offer through the API, and that API now
// requires a password.
out
}
/// Collects every printer that currently has a playable stream.
pub async fn sources(fleet: &SharedFleet) -> Vec<CameraSource> {
let fleet = fleet.lock().await;
let mut found = Vec::new();
for printer in fleet.iter() {
let report_url = printer
.print_status()
.await
.and_then(|report| report.ipcam)
.and_then(|ipcam| ipcam.rtsp_url);
if let Some(source) = source_for(printer, report_url.as_deref()) {
found.push(source);
}
}
found
}
/// Rewrites the config whenever what the printers report changes, and asks
/// the watchdog to restart go2rtc when it does.
///
/// Restart rather than reload: go2rtc can reload some of its configuration
/// over its own API, but a restart is the behaviour we can be sure of, and
/// the watchdog was already built to restart it. A stream interruption at the
/// moment a printer's camera URL changes is acceptable; a stream that is
/// silently serving the old address is not.
pub async fn run(
fleet: SharedFleet,
config_path: String,
api_listen: String,
auth: ApiAuth,
restart: std::sync::Arc<Notify>,
) {
let mut ticker = tokio::time::interval(REBUILD_INTERVAL);
let mut last_written: Option<String> = None;
loop {
ticker.tick().await;
// Collected once and kept, so the count logged below describes the
// config actually written rather than a second, later reading of the
// fleet that could disagree with it.
let found = sources(&fleet).await;
let desired = render_config(&api_listen, &found, &auth);
if last_written.as_deref() == Some(desired.as_str()) {
continue;
}
// Compare against the file too, not just our own memory of it: on
// startup `last_written` is `None`, and rewriting an identical file
// would restart go2rtc - and therefore drop every live viewer - for
// no reason, every time the daemon restarts.
if last_written.is_none()
&& let Ok(existing) = tokio::fs::read_to_string(&config_path).await
&& existing == desired
{
last_written = Some(desired);
continue;
}
match write_atomically(&config_path, &desired).await {
Ok(()) => {
// Counted from the sources, not from the rendered text. It
// used to be `matches("\n ").count() - 1`, which reported
// one stream for a config with none - the `{}` of an empty
// mapping indents exactly like a stream does. A log line
// that says a camera is being served when none is makes the
// one thing this task is for impossible to check.
let count = found.len();
// The content is never logged: it holds access codes.
info!(path = %config_path, streams = count, "wrote go2rtc config, restarting it");
last_written = Some(desired);
restart.notify_one();
}
Err(err) => warn!(path = %config_path, ?err, "could not write the go2rtc config"),
}
}
}
/// Writes through a temporary file and renames.
///
/// go2rtc may be reading this file at any moment - the watchdog restarts it
/// on a five-second loop - and a partially written config is a config that
/// fails to parse, which takes every working stream down. A rename is atomic
/// 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` 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
}
#[cfg(test)]
mod tests {
use super::*;
use crate::printer::{BambuPrinter, BambuProtocolPreference, BambuTls};
fn printer(id: &str) -> PrinterHandle {
PrinterHandle::Bambu(BambuPrinter::new(
id,
"Test Printer",
"192.168.0.21",
"12345678",
"SN123",
BambuTls::Insecure,
BambuProtocolPreference::Auto,
))
}
/// A fixed credential, so the byte-identical assertions below stay
/// meaningful. A real one is 32 random bytes and would make every
/// rendered config differ from every other.
fn test_auth() -> ApiAuth {
ApiAuth { username: "continuum".into(), password: "test-password".into() }
}
/// The URL shape confirmed from a real H2C's `ipcam` block.
#[test]
fn a_reported_rtsps_url_gets_the_printer_credentials_spliced_in() {
let source = source_for(&printer("h2c"), Some("rtsps://192.168.0.21:322/streaming/live/1"))
.expect("an rtsps URL is playable");
assert_eq!(source.stream_name, "h2c");
assert_eq!(source.url, "rtsps://bblp:12345678@192.168.0.21:322/streaming/live/1");
}
/// What a P1P reports: an `ipcam` block that says a camera exists without
/// giving anything to connect to. Advertising a stream here would make a
/// UI offer a player that can never load.
#[test]
fn a_printer_that_reports_no_url_has_no_stream() {
assert_eq!(source_for(&printer("p1p"), None), None);
assert_eq!(source_for(&printer("p1p"), Some("")), None);
}
/// Splicing a second set of credentials into a URL that already has some
/// would produce `bblp:code@user:pass@host`, which is not a host.
#[test]
fn a_url_that_already_carries_credentials_is_left_alone() {
let source = source_for(&printer("h2c"), Some("rtsps://someone:else@10.0.0.5:322/live"))
.expect("still playable");
assert_eq!(source.url, "rtsps://someone:else@10.0.0.5:322/live");
}
/// Refused rather than guessed at, because one bad entry makes go2rtc
/// fail to load the whole file and takes every working stream with it.
#[test]
fn a_url_with_no_scheme_is_refused_rather_than_repaired() {
assert_eq!(source_for(&printer("h2c"), Some("192.168.0.21:322/streaming/live/1")), None);
}
/// `streams:` with nothing under it is YAML for null, not for an empty
/// map, and a gateway with no streamable camera is the normal state of a
/// P1-only farm - so this is the output that must be valid, not a corner.
#[test]
fn an_empty_fleet_renders_an_explicit_empty_map() {
let config = render_config(":1984", &[], &test_auth());
assert!(config.contains("streams:\n {}\n"), "got:\n{config}");
assert!(config.contains("listen: \":1984\""), "got:\n{config}");
}
/// The change detection in `run` compares rendered output, so an
/// unchanged fleet in a different order must render identically or
/// go2rtc gets restarted - dropping every live viewer - every 30s.
#[test]
fn the_same_fleet_in_a_different_order_renders_identically() {
let a = CameraSource { stream_name: "aaa".into(), url: "rtsps://one".into() };
let b = CameraSource { stream_name: "zzz".into(), url: "rtsps://two".into() };
assert_eq!(
render_config(":1984", &[a.clone(), b.clone()], &test_auth()),
render_config(":1984", &[b, a], &test_auth()),
);
}
/// The config must *actively* close go2rtc's RTSP server.
///
/// This test used to assert the opposite thing - that the rendered
/// config contained no `rtsp:` key - and concluded from that that no
/// RTSP server was opened. It passed, and it was worthless: go2rtc
/// defaults to :8554 when the key is absent. Measured against go2rtc
/// 1.9.14, a config with no `rtsp:` block answered an unauthenticated
/// DESCRIBE on `rtsp://<gateway>:8554/h2c` with a real video SDP.
///
/// A test that asserts a key is missing cannot tell "off" from
/// "defaulted on". This one asserts the value that turns it off.
#[test]
fn the_generated_config_closes_the_rtsp_server() {
let source = CameraSource { stream_name: "h2c".into(), url: "rtsps://x".into() };
let config = render_config(":1984", &[source], &test_auth());
assert!(config.contains("rtsp:\n listen: \"\"\n"), "got:\n{config}");
}
/// An empty fleet must still close it - a gateway with no cameras is the
/// common case, and it is the config most likely to be left running.
#[test]
fn an_empty_config_closes_the_rtsp_server_too() {
assert!(render_config(":1984", &[], &test_auth()).contains("rtsp:\n listen: \"\"\n"));
}
/// The API listener is what serves WebRTC, and it defaults to every
/// interface, so a config without this is a video feed of someone's
/// workshop open to their whole network.
///
/// Asserts the values, for the same reason as the RTSP test above: a
/// `username:` key with the wrong content would pass an existence check
/// and authenticate nobody.
#[test]
fn the_generated_config_demands_a_password_on_the_api() {
let source = CameraSource { stream_name: "h2c".into(), url: "rtsps://x".into() };
let config = render_config(":1984", &[source], &test_auth());
assert!(
config.contains(" username: \"continuum\"\n password: \"test-password\"\n"),
"got:\n{config}"
);
}
/// Even with no cameras. A gateway that acquires one later keeps the
/// config it was already running, and an unauthenticated listener with
/// no streams is one printer away from an unauthenticated listener with
/// one.
#[test]
fn an_empty_config_demands_a_password_too() {
assert!(render_config(":1984", &[], &test_auth()).contains(" password: \"test-password\"\n"));
}
/// Byte-identical output for the same inputs, which is what stops the
/// rebuild loop restarting go2rtc.
///
/// The credential is the live hazard here: it is generated once per run
/// precisely so this holds. Were it re-derived per render, every 30s tick
/// would see a changed config, restart go2rtc and drop every viewer -
/// forever, and the only symptom would be video that dies every half
/// minute.
#[test]
fn the_same_fleet_and_credential_render_identically() {
let source = CameraSource { stream_name: "h2c".into(), url: "rtsps://x".into() };
assert_eq!(
render_config(":1984", std::slice::from_ref(&source), &test_auth()),
render_config(":1984", &[source], &test_auth()),
);
}
/// An operator-supplied password is not guaranteed to be YAML-safe, and
/// a config go2rtc cannot parse means a camera that silently never
/// appears - the hardest kind of failure to trace back to a password.
#[test]
fn an_awkward_operator_password_is_escaped() {
let auth = ApiAuth {
username: "continuum".into(),
password: r#"he said "hi"\ok"#.into(),
};
let config = render_config(":1984", &[], &auth);
assert!(
config.contains(r#" password: "he said \"hi\"\\ok""#),
"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();
}
}
+253
View File
@@ -0,0 +1,253 @@
//! Noticing that a print has finished, and saying so at the machine.
//!
//! Its own task, polling the fleet, rather than a branch inside the uplink's
//! telemetry loop. The uplink's loop only ticks while the cloud connection is
//! up, and a light that only comes on when the internet is working is not
//! much of a notification: the person this is for is standing next to the
//! printer, and the printer is on the same LAN as this process. Nothing about
//! telling them should route through a datacentre.
use std::collections::HashMap;
use std::time::Duration;
use tracing::{info, warn};
use crate::printer::{GenericPrinter, LightState, PrinterHandle, CHAMBER_LIGHT};
use crate::uplink::SharedFleet;
/// How long the lights blink for when a print finishes.
const BLINK_FOR: Duration = Duration::from_secs(10);
/// On for this long, then off for this long. 500ms reads as a deliberate
/// blink from across a room; much faster looks like a fault.
const BLINK_HALF_CYCLE: Duration = Duration::from_millis(500);
/// How often to look for a print that has just finished.
///
/// A notification a few seconds late is fine. What matters more is that this
/// is frequent enough not to miss a state: `FINISH` persists until the next
/// print starts, so there is no race to lose - but the first poll that sees
/// it is the one that fires.
const POLL_INTERVAL: Duration = Duration::from_secs(5);
/// Bambu's word for a print that ran to completion.
///
/// Deliberately only this one. `FAILED` is what a Bambu reports for a print
/// that was cancelled or went wrong - including every `print.stop` this
/// software sends - and celebrating a cancellation would be worse than
/// staying dark.
const FINISHED: &str = "FINISH";
/// Remembers each printer's last known state, so a finish can be told from a
/// printer that has simply been sitting in `FINISH` for an hour.
#[derive(Default)]
pub struct CompletionWatcher {
last_state: HashMap<String, String>,
}
impl CompletionWatcher {
pub fn new() -> Self {
Self::default()
}
/// The printers that have *just* finished a print.
///
/// Two rules, both of which exist to avoid a false celebration:
///
/// - It must be a transition. `FINISH` persists until the next print
/// starts, so without this every poll for the next hour would fire.
/// - A printer seen for the first time never fires, even if it is
/// already `FINISH`. Otherwise restarting the gateway blinks every
/// printer that happens to be sitting in `FINISH`, announcing prints
/// that finished yesterday.
pub fn newly_finished<I, S>(&mut self, states: I) -> Vec<String>
where
I: IntoIterator<Item = (String, S)>,
S: AsRef<str>,
{
let mut finished = Vec::new();
for (printer_id, state) in states {
let state = state.as_ref();
let previous = self.last_state.insert(printer_id.clone(), state.to_string());
if state == FINISHED && previous.is_some_and(|prev| prev != FINISHED) {
finished.push(printer_id);
}
}
finished
}
}
/// Watches the fleet and blinks a printer's lights when it finishes a print.
pub async fn run(fleet: SharedFleet) {
let mut watcher = CompletionWatcher::new();
let mut ticker = tokio::time::interval(POLL_INTERVAL);
loop {
ticker.tick().await;
let states = {
let fleet = fleet.lock().await;
// `print_status` is a cached read, but `refresh_status` below
// publishes, so this does hold the lock across one MQTT publish
// per printer. Same shape as the uplink's own `refresh_fleet`,
// and at a handful of printers it is not worth more machinery;
// the blink itself is what had to stay out of the lock, because
// that one takes ten seconds.
let mut states = Vec::new();
for printer in fleet.iter() {
// v1 printers only push a status when asked. Doing it here as
// well as in the uplink means this keeps working with the
// cloud connection down, which is the whole point of the
// separate task.
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) {
states.push((printer.base().id.clone(), state));
}
}
states
};
for printer_id in watcher.newly_finished(states) {
info!(printer = %printer_id, "print finished; blinking the lights");
// Spawned, not awaited: this takes ten seconds, and nothing else
// in the fleet should wait for it.
tokio::spawn(celebrate(fleet.clone(), printer_id));
}
}
}
/// Blinks one printer's chamber light for `BLINK_FOR`, then leaves it off.
///
/// The light is left off whatever happens, including when a blink fails
/// part-way: a printer abandoned mid-blink with its light on looks like a
/// machine still working.
/// Public so `examples/bambu_print_probe.rs` can run the real blink against a
/// real printer. Twenty light commands in ten seconds is a rate a printer can
/// refuse, and a blink reimplemented in the probe to match would not have
/// told us whether this one works.
pub async fn celebrate(fleet: SharedFleet, printer_id: String) {
let deadline = tokio::time::Instant::now() + BLINK_FOR;
let mut state = LightState::On;
while tokio::time::Instant::now() < deadline {
if let Err(err) = set_light(&fleet, &printer_id, state).await {
// Stop at the first failure rather than logging twenty times,
// but still fall through to the final Off below.
warn!(printer = %printer_id, %err, "stopped blinking after a failed light command");
break;
}
state = match state {
LightState::On => LightState::Off,
LightState::Off => LightState::On,
};
tokio::time::sleep(BLINK_HALF_CYCLE).await;
}
if let Err(err) = set_light(&fleet, &printer_id, LightState::Off).await {
warn!(printer = %printer_id, %err, "could not turn the light back off");
}
}
/// One light command, holding the fleet lock only for as long as it takes.
///
/// Locking per command rather than once around the whole blink: the fleet
/// mutex is also what telemetry and cloud command dispatch need, and holding
/// it for ten seconds would stall both for every printer on the farm.
async fn set_light(
fleet: &SharedFleet,
printer_id: &str,
state: LightState,
) -> Result<(), String> {
let fleet = fleet.lock().await;
let printer = fleet
.iter()
.find(|p| p.base().id == printer_id)
.ok_or_else(|| format!("no printer with id '{printer_id}' in this gateway's fleet"))?;
// Bambu-only for now. A Prusa has no addressable light and Klipper's
// depends entirely on the user's own config, so there is nothing
// vendor-neutral to put on `GenericPrinter` yet - and inventing a
// trait method that three of four implementations refuse is worse than
// matching here.
match printer {
PrinterHandle::Bambu(bambu) => {
bambu.set_light(CHAMBER_LIGHT, state).await.map_err(|err| err.to_string())
}
other => Err(format!(
"{} has no light this software knows how to control",
other.display_name()
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn states(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
pairs.iter().map(|(id, s)| (id.to_string(), s.to_string())).collect()
}
/// The regression that matters most: a gateway restarting while a
/// printer sits in FINISH must not announce a print that finished
/// yesterday.
#[test]
fn a_printer_first_seen_already_finished_does_not_fire() {
let mut watcher = CompletionWatcher::new();
assert!(watcher.newly_finished(states(&[("p1p", "FINISH")])).is_empty());
}
#[test]
fn a_transition_into_finish_fires_once_and_only_once() {
let mut watcher = CompletionWatcher::new();
watcher.newly_finished(states(&[("p1p", "RUNNING")]));
assert_eq!(watcher.newly_finished(states(&[("p1p", "FINISH")])), vec!["p1p"]);
// FINISH persists until the next print starts; without the
// transition check this would fire every poll for an hour.
assert!(watcher.newly_finished(states(&[("p1p", "FINISH")])).is_empty());
}
/// A Bambu reports an operator cancellation as FAILED - including every
/// `print.stop` this software sends. Celebrating that would be worse
/// than staying dark.
#[test]
fn a_cancelled_print_never_fires() {
let mut watcher = CompletionWatcher::new();
watcher.newly_finished(states(&[("p1p", "RUNNING")]));
assert!(watcher.newly_finished(states(&[("p1p", "FAILED")])).is_empty());
}
#[test]
fn a_second_print_fires_again() {
let mut watcher = CompletionWatcher::new();
watcher.newly_finished(states(&[("p1p", "RUNNING")]));
watcher.newly_finished(states(&[("p1p", "FINISH")]));
watcher.newly_finished(states(&[("p1p", "RUNNING")]));
assert_eq!(watcher.newly_finished(states(&[("p1p", "FINISH")])), vec!["p1p"]);
}
#[test]
fn printers_are_tracked_independently() {
let mut watcher = CompletionWatcher::new();
watcher.newly_finished(states(&[("p1p", "RUNNING"), ("h2c", "RUNNING")]));
let finished = watcher.newly_finished(states(&[("p1p", "FINISH"), ("h2c", "RUNNING")]));
assert_eq!(finished, vec!["p1p"]);
}
/// Ten seconds of 500ms half-cycles is 20 commands, ending on Off. Worth
/// pinning: the ask was "blink for ten seconds then turn the lights off",
/// and a half-cycle that does not divide the duration would end mid-blink.
#[test]
fn the_blink_divides_evenly_into_the_duration() {
assert_eq!(BLINK_FOR.as_millis() % BLINK_HALF_CYCLE.as_millis(), 0);
assert_eq!(BLINK_FOR.as_millis() / BLINK_HALF_CYCLE.as_millis(), 20);
}
}
+607 -35
View File
@@ -1,6 +1,49 @@
use std::collections::HashMap;
//! 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.
use std::env;
use std::path::PathBuf;
use std::fmt;
use std::net::IpAddr;
use std::time::Duration;
use serde::Deserialize;
#[derive(Debug, Clone)]
pub struct Config {
@@ -10,50 +53,579 @@ pub struct Config {
pub uplink_url: String,
pub go2rtc_bin: String,
pub go2rtc_config: String,
/// What go2rtc's API listens on, which is also what serves WebRTC to a
/// browser. Written into the generated config rather than left to
/// go2rtc's default, because the cloud is told this address and a
/// default that moves between go2rtc versions would move the stream
/// without anything saying so.
pub go2rtc_api_listen: String,
/// The base URL a browser can reach this gateway's streams at, reported
/// to the cloud in `hello`.
///
/// Not derived from `go2rtc_api_listen`: that is a bind address, often
/// `:1984` or `0.0.0.0:1984`, and neither is something a browser can
/// open. Only the operator knows the gateway's reachable name. Unset
/// means "do not advertise a stream", which is the right default - a
/// wrong URL here is worse than none, because a UI would offer a player
/// that cannot connect.
pub stream_base_url: Option<String>,
/// Path to the fleet config file - see `fleet.rs` and
/// `printers.example.toml` for the shape. Bambu certificate trust
/// (per-printer `ca_cert_path`/`require_valid_cert`, and automatic
/// trust-on-first-connect pinning otherwise - see
/// `BambuPrinter::ensure_trusted`) lives entirely in that file now,
/// not as a separate fleet-wide env-configured policy here.
pub printers_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,
}
/// Per-printer CA certificate overrides, keyed by printer ID. Most
/// Bambu printers chain to the bundled shared CA (`certs/bambu_ca2.pem`)
/// and don't need an entry here - but some models (P1P, at least) ship
/// their own certificate instead of one signed by that shared CA, with
/// no single file covering all of them. Look up a printer's own entry
/// (if any) when constructing it; there's no fleet-wide "the" override.
pub bambu_ca_cert_overrides: HashMap<String, PathBuf>,
/// When `false`, Bambu MQTTS connections skip certificate verification
/// entirely instead of checking against a CA. `true` (the default) is
/// the secure choice; only flip this fleet-wide policy if you have
/// printers you can't get to verify any other way.
pub bambu_require_valid_cert: bool,
/// 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>,
/// 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()
}
}
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> {
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"),
Self::layered(None, &|key| env::var(key).ok())
}
bambu_ca_cert_overrides: parse_cert_overrides(&env_or("CONTINUUM_BAMBU_CA_CERT_OVERRIDES", "")),
bambu_require_valid_cert: env_or("CONTINUUM_BAMBU_REQUIRE_VALID_CERT", "true") == "true",
/// 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", &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"),
discovery: discovery_settings(file.discovery.as_ref(), env)?,
})
}
}
/// Parses `"printer_id=path,printer_id2=path2"` into a lookup map. Empty
/// input (the common case - no overrides needed) yields an empty map.
fn parse_cert_overrides(raw: &str) -> HashMap<String, PathBuf> {
raw.split(',')
.filter_map(|entry| entry.split_once('='))
.map(|(id, path)| (id.trim().to_string(), PathBuf::from(path.trim())))
.collect()
/// 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 require(key: &str) -> anyhow::Result<String> {
env::var(key).map_err(|_| anyhow::anyhow!("missing required env var {key}"))
}
#[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);
}
fn env_or(key: &str, default: &str) -> String {
env::var(key).unwrap_or_else(|_| default.to_string())
}
+1830
View File
File diff suppressed because it is too large Load Diff
+878
View File
@@ -0,0 +1,878 @@
//! Finding printers, so setting up a farm does not mean reading five fields
//! off each machine's screen and retyping them into `printers.toml`.
//!
//! `printers.toml` stays the source of truth. Discovery deliberately does not
//! write it and does not add printers to a running fleet, because **it cannot
//! learn a credential** - a Bambu access code is on the printer's own screen
//! and a PrusaLink API key is in its web UI, which is the point of both. What
//! discovery produces is a *candidate*: everything except the secret,
//! rendered as a `[[printer]]` block for a human to complete. `fleet::load`
//! rejects an entry whose credential is empty, which is why this prints
//! rather than appends - writing a half-finished entry into the file would
//! stop the gateway starting.
//!
//! # What can be found, and how much
//!
//! Detection splits into two tiers, and keeping them apart is what makes this
//! honest rather than optimistic:
//!
//! - **"something at this address speaks X"** needs no credential for 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.
//! - **the details** - serial, model, firmware, the operator's own name for
//! the machine - only come from vendors that publish them unauthenticated.
//!
//! | Vendor | Probe | Tier 1 | Details |
//! |---|---|---|---|
//! | Bambu | TLS certificate on 8883 | yes | **serial number**, free |
//! | Bambu | SSDP on 2021/1990 | yes | model, name, bind state |
//! | Klipper | `GET :7125/printer/info` | yes | hostname + Klipper version, if its authorization allows |
//! | PrusaLink | `GET :80/api/version` | yes | none - every identity endpoint needs digest auth |
//! | Marlin over serial | USB descriptors in sysfs | yes | vendor/product strings, USB serial |
//! | Marlin over serial | `M115` handshake | yes | firmware and machine type - **but see the warning below** |
//!
//! # The three things worth knowing before extending this
//!
//! **Multicast does not cross a router.** SSDP finds the most about a Bambu
//! printer, but a gateway on a different VLAN from its printers hears
//! nothing, which is an ordinary way to run a workshop. Active probing is
//! what works from anywhere that can route to the machines, and that is why
//! the certificate read - not SSDP - is the dependable Bambu mechanism.
//!
//! **Opening a serial port resets the board.** On Arduino-based boards
//! toggling DTR, which opening the port does, reboots the printer -
//! `printer/marlin_serial.rs` documents this too. So a `M115` handshake on a
//! machine that happens to be printing *ruins the print*. Serial detection is
//! therefore passive by default: it reads USB descriptors out of sysfs and
//! opens nothing. The handshake is opt-in, per port, and says so.
//!
//! **Finding a printer is not being able to drive it.** The Klipper and
//! PrusaLink clients in `printer/` are stubs. Discovery still reports those
//! machines, because knowing what is on the network is useful before the
//! client exists, but a rendered entry for one carries a warning - otherwise
//! an operator pastes it in and gets a gateway that connects and does
//! nothing.
//!
//! # Verified, and not
//!
//! Verified live on 2026-10-19 against the only hardware available, two
//! Bambu printers: a P1P presented `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 `printers.toml`, and a blind sweep of
//! their `/24` found exactly those two in 24s.
//!
//! **Unverified, and untestable from here:** SSDP (this host is routed to the
//! printers rather than on their segment, so their multicast cannot arrive),
//! the Klipper and PrusaLink probes (no such hardware), and all serial
//! detection (no serial device is attached, and `/sys/class/tty` holds
//! nothing but virtual consoles). Every parser those paths depend on is unit
//! tested against recorded protocol text, and each probe is tolerant by
//! design - a missing field costs a field, not the printer. `discover_printers
//! raw` dumps whatever actually arrives, so the first run on real hardware
//! 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, 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.
pub const SSDP_GROUP: Ipv4Addr = Ipv4Addr::new(239, 255, 255, 250);
/// Both ports Bambu has been seen to announce on. 2021 is the current one;
/// 1990 is older firmware, and listening on both costs a socket.
pub const SSDP_PORTS: [u16; 2] = [2021, 1990];
/// Which kind of printer a candidate is, which decides what its entry needs.
///
/// These map one-to-one onto the `vendor` strings `fleet.rs` accepts, and the
/// mapping is the whole reason this enum exists rather than a string: a typo
/// in a rendered `vendor` produces a config file that fails to load, and the
/// compiler can prevent that.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Vendor {
Bambu,
Klipper,
/// Prusa's own implementation of the OctoPrint API, on an MK4/XL/MINI.
PrusaLink,
/// OctoPrint itself, or anything else serving its API.
///
/// This is the honest default for a printer found by probing, because
/// what a probe observes is "something here speaks the OctoPrint API" -
/// never which implementation. Reporting `prusa_link` would assert a
/// product nothing on the wire names.
Octoprint,
/// Marlin-family firmware over a raw serial port. Named for Prusa
/// because that is what `fleet.rs` calls it, but
/// `printer/marlin_serial.rs` drives any Marlin board the same way -
/// there is nothing Prusa-specific in the transport.
PrusaSerial,
}
impl Vendor {
/// The exact string `fleet.rs` expects in `vendor`.
pub fn toml_value(self) -> &'static str {
match self {
// Never a guessed `bambu_v1`/`bambu_v2`: this value auto-detects
// the MQTT dialect on connect, and since a firmware update can
// move a physical printer between them, a guess written into a
// file goes stale silently.
Vendor::Bambu => "bambu",
Vendor::Klipper => "klipper",
Vendor::PrusaLink => "prusa_link",
Vendor::Octoprint => "octoprint",
Vendor::PrusaSerial => "prusa_serial",
}
}
/// Whether `printer/` can actually drive this vendor yet.
///
/// Only Bambu is real. Reporting a printer the gateway cannot drive is
/// still worth doing - it answers "what is on this network?" - but it has
/// to be said out loud, or the entry looks finished when it is not.
pub fn driver_is_implemented(self) -> bool {
match self {
Vendor::Bambu => true,
// `printer/klipper.rs` and `printer/prusa.rs` are stubs whose
// methods return without doing anything.
Vendor::Klipper | Vendor::PrusaLink | Vendor::Octoprint => false,
// `marlin_serial.rs` is a real transport, but
// `PrusaSerialPrinter`'s command methods on top of it are not.
Vendor::PrusaSerial => false,
}
}
}
/// How a candidate was found, which bounds how much it can know.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Source {
/// A Bambu SSDP announcement.
Ssdp,
/// The subject CN of the TLS certificate on Bambu's MQTT port.
Certificate,
/// An unauthenticated HTTP identity endpoint.
HttpApi,
/// USB descriptors read out of sysfs, with no port opened.
UsbDescriptor,
/// An `M115` handshake, which required opening the port.
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 {
/// What goes in `host` - or in `port`, for a serial candidate, where it
/// is a device path like `/dev/ttyACM0`.
pub host: String,
pub vendor: Vendor,
/// The printer's serial number. Bambu's MQTT topics are addressed by it,
/// so for that vendor it is required rather than decorative.
pub serial: Option<String>,
pub model: Option<String>,
/// The name the operator gave the printer, where the vendor reports one.
pub name: Option<String>,
/// `DevBind.bambu.com`: whether the printer is bound to a Bambu cloud
/// account. Worth surfacing rather than acting on - a bound printer can
/// still be driven over the LAN if LAN mode is on, and "bound" is the
/// likeliest explanation for a printer that is visible here and then
/// refuses to connect.
pub bind_state: Option<String>,
/// Firmware or software version, free-form, for an operator to recognise
/// the machine by. Not rendered into the TOML - nothing in `fleet.rs`
/// takes a version, and a stale one in a config file would be a lie.
pub detail: Option<String>,
/// Another vendor this could equally be, when the probe genuinely cannot
/// tell - which today means only OctoPrint versus PrusaLink.
///
/// Carried rather than resolved by guessing, because the operator knows
/// what they own and the network does not. It renders as a comment above
/// `vendor`, so changing it is a one-word edit in the file they are
/// already pasting into.
pub ambiguous_with: Option<Vendor>,
pub source: Source,
}
impl Candidate {
pub fn new(host: impl Into<String>, vendor: Vendor, source: Source) -> Self {
Self {
host: host.into(),
vendor,
serial: None,
model: None,
name: None,
bind_state: None,
detail: None,
ambiguous_with: None,
source,
}
}
/// 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
/// others, since an unused key is a key someone later has to work out the
/// meaning of. The credential, where there is one, comes last, empty, and
/// labelled - it is the one thing discovery cannot supply, and the entry
/// does not load until a human supplies it.
pub fn to_toml_entry(&self, id: &str) -> String {
let name = self
.name
.clone()
.or_else(|| self.model.clone())
.unwrap_or_else(|| id.to_string());
let mut out = String::new();
if let Some(detail) = &self.detail {
out.push_str(&format!("# {detail}\n"));
}
if !self.vendor.driver_is_implemented() {
out.push_str(&format!(
"# WARNING: continuum-proxy's {} client is still a stub - it will connect\n\
# and do nothing. Found and reported so you know it is there.\n",
self.vendor.toml_value()
));
}
out.push_str("[[printer]]\n");
out.push_str(&format!("id = {}\n", toml_string(id)));
out.push_str(&format!("name = {}\n", toml_string(&name)));
if let Some(other) = self.ambiguous_with {
out.push_str(&format!(
"# vendor: change to {:?} if that is what this is - nothing on the network\n\
# distinguishes them, so this is a guess only you can settle.\n",
other.toml_value()
));
}
out.push_str(&format!("vendor = {}\n", toml_string(self.vendor.toml_value())));
match self.vendor {
Vendor::Bambu => {
out.push_str(&format!("host = {}\n", toml_string(&self.host)));
match &self.serial {
Some(sn) => out.push_str(&format!("sn = {}\n", toml_string(sn))),
// Required by `fleet.rs`, so a blank would not load
// either - but it must not be left looking filled in.
None => out.push_str("# sn = \"\" # NOT FOUND - printer screen > Settings > Device\n"),
}
out.push_str("access_code = \"\" # REQUIRED: printer screen > Settings > Network (LAN mode)\n");
}
// The only field `fleet.rs` wants, and Moonraker needs no
// credential from us - it authorizes by client address.
Vendor::Klipper => out.push_str(&format!("host = {}\n", toml_string(&self.host))),
// One client, two flavours - same required fields.
Vendor::PrusaLink | Vendor::Octoprint => {
out.push_str(&format!("host = {}\n", toml_string(&self.host)));
out.push_str("api_key = \"\" # REQUIRED: the printer's or OctoPrint's web UI > API key\n");
}
Vendor::PrusaSerial => {
out.push_str(&format!("port = {}\n", toml_string(&self.host)));
// Stated rather than left to the default, because a serial
// port that is wrong about its baud rate does not fail - it
// returns noise, which is far harder to diagnose than a
// refusal.
out.push_str("baud_rate = 115200 # Marlin's usual default; check your firmware\n");
}
}
if let Some(bind) = &self.bind_state
&& bind != "free"
{
out.push_str(&format!("# note: this printer reports DevBind = {bind:?} (bound to a Bambu account).\n"));
out.push_str("# LAN mode has to be on for a gateway to drive it.\n");
}
out
}
/// A plausible `id` for the entry: short, stable, and filename-safe.
///
/// Derived from the model or the operator's name rather than the serial,
/// because this id is what appears in logs and in the cloud, and
/// `01S09B441900143` is not something anyone recognises. Falls back to
/// the serial's tail only when there is nothing else.
pub fn suggested_id(&self) -> String {
let basis = self
.model
.as_deref()
.or(self.name.as_deref())
.map(slug)
.filter(|s| !s.is_empty());
if let Some(slug) = basis {
return slug;
}
match &self.serial {
// The tail, not the head: serials share a model prefix, so the
// last characters are what distinguishes two of the same machine.
Some(sn) if sn.len() > 6 => format!("printer-{}", slug(&sn[sn.len() - 6..])),
Some(sn) => format!("printer-{}", slug(sn)),
None => {
// A serial port has no identity but its path, and
// `/dev/ttyACM0` makes a better id than `printer`.
let from_path = self.host.rsplit('/').next().map(slug).filter(|s| !s.is_empty());
from_path.unwrap_or_else(|| "printer".to_string())
}
}
}
}
fn slug(input: &str) -> String {
let mut out = String::new();
for ch in input.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
} else if !out.ends_with('-') && !out.is_empty() {
out.push('-');
}
}
out.trim_end_matches('-').to_string()
}
/// Quotes a value for TOML, escaping what a basic string must escape.
///
/// None of these characters can appear in a serial or an IP address, but the
/// name comes from whatever the operator typed into their slicer, and a stray
/// quote in it would otherwise produce a block that does not parse.
fn toml_string(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
out.push('"');
for ch in value.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
other => out.push(other),
}
}
out.push('"');
out
}
/// Rejects anything that is not shaped like a printer serial number.
///
/// The point is not to validate a vendor's format - it is that this value
/// gets written into a config file as `sn`, and a `USN` carrying a UPnP UUID
/// or a human-readable device description would produce an entry that looks
/// filled in and then addresses a topic no printer publishes. Better to leave
/// it blank and say so.
pub(crate) fn valid_serial(value: String) -> Option<String> {
let ok = (8..=32).contains(&value.len()) && value.chars().all(|c| c.is_ascii_alphanumeric());
ok.then_some(value)
}
/// Parses one SSDP datagram, or `None` if it is not a Bambu announcement.
///
/// `from` is the sender's address, used when the message carries no usable
/// `Location` - which matters because the address a packet came from is the
/// 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 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();
// A NOTIFY is an unsolicited announcement; a `200 OK` is the reply to an
// M-SEARCH. Anything else is rejected, which is what keeps a listener
// from parsing the M-SEARCH queries it hears - including its own, which
// arrive back on the same socket.
let upper = start.to_ascii_uppercase();
if !upper.starts_with("NOTIFY") && !upper.contains("200 OK") {
return None;
}
let mut headers: Vec<(String, &str)> = Vec::new();
for line in lines {
if let Some((key, value)) = line.split_once(':') {
headers.push((key.trim().to_ascii_lowercase(), value.trim()));
}
}
let get = |name: &str| {
headers
.iter()
.find(|(key, _)| key == name)
.map(|(_, value)| *value)
.filter(|value| !value.is_empty())
};
// Only claim a Bambu printer. Every UPnP device on a LAN - televisions,
// routers, printers of the paper kind - announces on this same group, and
// offering an operator a TOML block for their TV would be worse than
// finding nothing.
let device_type = get("nt").or_else(|| get("st")).unwrap_or("");
let looks_bambu = device_type.to_ascii_lowercase().contains("bambulab")
|| headers.iter().any(|(key, _)| key.ends_with(".bambu.com"));
if !looks_bambu {
debug!(%from, %device_type, "ignoring a non-Bambu SSDP announcement");
return None;
}
let host = get("location").map(host_from_location).unwrap_or_else(|| from.to_string());
let mut candidate = Candidate::new(host, Vendor::Bambu, Source::Ssdp);
candidate.serial = get("usn").map(|usn| usn.trim_start_matches("uuid:").trim().to_string()).and_then(valid_serial);
candidate.model = get("devmodel.bambu.com").map(str::to_string);
candidate.name = get("devname.bambu.com").map(str::to_string);
candidate.bind_state = get("devbind.bambu.com").map(str::to_string);
candidate.detail = get("devversion.bambu.com").map(|v| format!("Bambu firmware {v}"));
Some(candidate)
}
/// Bambu's `Location` is a bare address, but SSDP's is a URL. Accept either.
fn host_from_location(location: &str) -> String {
let without_scheme = location.split_once("://").map(|(_, rest)| rest).unwrap_or(location);
let host = without_scheme.split('/').next().unwrap_or(without_scheme);
// Strip a port, while leaving a bracketed IPv6 literal alone.
match host.rsplit_once(':') {
Some((before, after)) if !before.ends_with(']') && after.chars().all(|c| c.is_ascii_digit()) => {
before.to_string()
}
_ => host.to_string(),
}
}
/// Listens for SSDP announcements on one port for `window`, de-duplicated.
///
/// `raw` logs every datagram that arrives, Bambu or not, at info level. That
/// is here because this path cannot be tested from the development
/// environment: the printers are on `192.168.0.0/24` and the only host
/// available is routed to them from another subnet, so their multicast never
/// arrives - a router does not forward it. The first run on a real segment is
/// therefore also the first real test, and it should print what it saw rather
/// than only what it understood.
pub async fn listen_ssdp(port: u16, window: Duration, raw: bool) -> anyhow::Result<Vec<Candidate>> {
let socket = bind_ssdp(port)?;
let mut found: Vec<Candidate> = Vec::new();
let mut buf = vec![0u8; 65_535];
let deadline = tokio::time::Instant::now() + window;
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
break;
}
let received = match tokio::time::timeout(remaining, socket.recv_from(&mut buf)).await {
Ok(Ok(received)) => received,
// The window closed, which is the ordinary way out.
Err(_) => break,
// One malformed or oversized datagram must not end the scan.
Ok(Err(err)) => {
warn!(?err, "SSDP receive failed");
continue;
}
};
let (len, from) = received;
let text = String::from_utf8_lossy(&buf[..len]);
let sender = match from {
SocketAddr::V4(v4) => *v4.ip(),
SocketAddr::V6(_) => continue,
};
if raw {
tracing::info!(%from, bytes = len, "SSDP datagram:\n{}", text.trim());
}
if let Some(candidate) = parse_ssdp(&text, sender)
&& !found.iter().any(|existing| existing.host == candidate.host)
{
found.push(candidate);
}
}
Ok(found)
}
/// Binds the SSDP port and joins the group on every IPv4 interface.
///
/// 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?)")
})?;
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)?)
}
/// Drops candidates already covered by another, so one printer found twice is
/// reported once.
///
/// A sweep and an SSDP listen overlap on every Bambu printer, and the two
/// know different things - SSDP has the model and the operator's name, the
/// certificate has the serial - so the merge keeps whichever field is
/// present rather than preferring a source.
pub fn merge(mut found: Vec<Candidate>) -> Vec<Candidate> {
let mut merged: Vec<Candidate> = Vec::new();
for candidate in found.drain(..) {
let existing = merged
.iter_mut()
.find(|other| other.host == candidate.host && other.vendor == candidate.vendor);
match existing {
Some(other) => {
other.serial = other.serial.take().or(candidate.serial);
other.model = other.model.take().or(candidate.model);
other.name = other.name.take().or(candidate.name);
other.bind_state = other.bind_state.take().or(candidate.bind_state);
other.detail = other.detail.take().or(candidate.detail);
}
None => merged.push(candidate),
}
}
merged
}
#[cfg(test)]
mod tests {
use super::*;
/// Bambu's announcement shape, with the vendor headers it is worth
/// extracting. Not a capture - see `parse_ssdp`'s note.
const NOTIFY: &str = "NOTIFY * HTTP/1.1\r\n\
HOST: 239.255.255.250:2021\r\n\
Server: UPnP/1.0\r\n\
Location: 192.168.0.21\r\n\
NT: urn:bambulab-com:device:3dprinter:1\r\n\
USN: 31B8BP5A1800585\r\n\
Cache-Control: max-age=1800\r\n\
DevModel.bambu.com: O1C2\r\n\
DevName.bambu.com: H2C\r\n\
DevSignal.bambu.com: -48\r\n\
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");
assert_eq!(found.host, "192.168.0.21");
assert_eq!(found.serial.as_deref(), Some("31B8BP5A1800585"));
assert_eq!(found.model.as_deref(), Some("O1C2"));
assert_eq!(found.name.as_deref(), Some("H2C"));
assert_eq!(found.bind_state.as_deref(), Some("free"));
assert_eq!(found.vendor, Vendor::Bambu);
assert_eq!(found.source, Source::Ssdp);
}
#[test]
fn a_missing_vendor_header_costs_one_field_and_not_the_printer() {
// The whole reason the parser is tolerant: the header spelling is
// unverified, so an announcement that spells `DevModel` differently
// must still produce a usable candidate.
let trimmed = NOTIFY.replace("DevModel.bambu.com: O1C2\r\n", "");
let found = parse_ssdp(&trimmed, Ipv4Addr::new(10, 0, 0, 1)).expect("still a printer");
assert_eq!(found.model, None);
assert_eq!(found.serial.as_deref(), Some("31B8BP5A1800585"));
}
#[test]
fn the_sender_address_stands_in_for_a_missing_location() {
let without = NOTIFY.replace("Location: 192.168.0.21\r\n", "");
let found = parse_ssdp(&without, Ipv4Addr::new(192, 168, 0, 21)).expect("a printer");
assert_eq!(found.host, "192.168.0.21");
}
#[test]
fn a_url_location_is_reduced_to_a_host() {
for (location, expected) in [
("http://192.168.0.21:80/description.xml", "192.168.0.21"),
("http://192.168.0.21/", "192.168.0.21"),
("192.168.0.21", "192.168.0.21"),
] {
let message = NOTIFY.replace("Location: 192.168.0.21", &format!("Location: {location}"));
let found = parse_ssdp(&message, Ipv4Addr::LOCALHOST).expect("a printer");
assert_eq!(found.host, expected, "for {location}");
}
}
#[test]
fn a_search_query_is_not_an_announcement() {
// A listener hears its own M-SEARCH back off the multicast group, and
// an M-SEARCH names a device type without naming a device - parsing
// one would invent a printer at whatever address asked the question.
let query = "M-SEARCH * HTTP/1.1\r\n\
HOST: 239.255.255.250:2021\r\n\
MAN: \"ssdp:discover\"\r\n\
MX: 1\r\n\
ST: urn:bambulab-com:device:3dprinter:1\r\n";
assert!(parse_ssdp(query, Ipv4Addr::new(10, 200, 0, 4)).is_none());
}
#[test]
fn another_vendors_upnp_device_is_ignored() {
let television = "NOTIFY * HTTP/1.1\r\n\
HOST: 239.255.255.250:1900\r\n\
Location: http://192.168.0.9:8080/desc.xml\r\n\
NT: urn:schemas-upnp-org:device:MediaRenderer:1\r\n\
USN: uuid:4d696361-736f-6674-2d77-696e646f7773\r\n";
assert!(parse_ssdp(television, Ipv4Addr::new(192, 168, 0, 9)).is_none());
}
#[test]
fn a_usn_that_is_not_a_serial_is_dropped_rather_than_written_out() {
let uuid = NOTIFY.replace("USN: 31B8BP5A1800585", "USN: uuid:4d696361-736f-6674-2d77-696e646f7773");
let found = parse_ssdp(&uuid, Ipv4Addr::LOCALHOST).expect("a printer");
assert_eq!(found.serial, None, "a UUID is not a serial and must not be written into `sn`");
assert!(found.to_toml_entry("h2c").contains("# sn = \"\" # NOT FOUND"));
}
#[test]
fn a_bambu_entry_parses_as_toml_and_names_the_access_code() {
let found = parse_ssdp(NOTIFY, Ipv4Addr::LOCALHOST).expect("a printer");
let entry = found.to_toml_entry("h2c");
let parsed: toml::Value = toml::from_str(&entry).expect("the emitted block must be valid TOML");
let printer = &parsed["printer"][0];
assert_eq!(printer["id"].as_str(), Some("h2c"));
assert_eq!(printer["host"].as_str(), Some("192.168.0.21"));
assert_eq!(printer["sn"].as_str(), Some("31B8BP5A1800585"));
assert_eq!(printer["name"].as_str(), Some("H2C"));
// Auto-detecting, not a guess from the TLS version - see `toml_value`.
assert_eq!(printer["vendor"].as_str(), Some("bambu"));
// Present and empty: the operator has to fill it in, and `fleet::load`
// refuses the entry until they do.
assert_eq!(printer["access_code"].as_str(), Some(""));
// Bambu is the one vendor that works, so no stub warning.
assert!(!entry.contains("WARNING"), "{entry}");
}
/// Each vendor's entry has to carry exactly the fields `fleet.rs`
/// requires of it - this is the test that fails if those two drift apart.
#[test]
fn every_vendors_entry_carries_the_fields_its_loader_demands() {
let cases = [
(Vendor::Bambu, "192.168.0.21", vec!["host", "sn", "access_code"]),
(Vendor::Klipper, "192.168.0.50", vec!["host"]),
(Vendor::PrusaLink, "192.168.0.51", vec!["host", "api_key"]),
(Vendor::Octoprint, "192.168.0.60", vec!["host", "api_key"]),
(Vendor::PrusaSerial, "/dev/ttyACM0", vec!["port", "baud_rate"]),
];
for (vendor, host, expected) in cases {
let mut candidate = Candidate::new(host, vendor, Source::HttpApi);
candidate.serial = Some("31B8BP5A1800585".to_string());
let entry = candidate.to_toml_entry("x");
let parsed: toml::Value = toml::from_str(&entry).unwrap_or_else(|err| panic!("{vendor:?}: {err}\n{entry}"));
let printer = &parsed["printer"][0];
assert_eq!(printer["vendor"].as_str(), Some(vendor.toml_value()));
for key in &expected {
assert!(printer.get(key).is_some(), "{vendor:?} must render {key}:\n{entry}");
}
// No stray keys: an unused one is a key someone later has to
// work out the meaning of.
for absent in ["host", "sn", "access_code", "api_key", "port", "baud_rate"] {
if !expected.contains(&absent) {
assert!(printer.get(absent).is_none(), "{vendor:?} must not render {absent}:\n{entry}");
}
}
}
}
#[test]
fn a_vendor_with_a_stub_client_says_so_in_its_entry() {
for vendor in [Vendor::Klipper, Vendor::PrusaLink, Vendor::Octoprint, Vendor::PrusaSerial] {
let entry = Candidate::new("192.168.0.50", vendor, Source::HttpApi).to_toml_entry("x");
assert!(entry.contains("WARNING"), "{vendor:?} is a stub and must say so:\n{entry}");
assert!(entry.contains("stub"), "{vendor:?}:\n{entry}");
// Still valid TOML, because the operator is meant to paste it.
toml::from_str::<toml::Value>(&entry).expect("a warning must be a comment, not a syntax error");
}
}
#[test]
fn a_name_with_a_quote_in_it_still_renders_parseable_toml() {
let awkward = NOTIFY.replace("DevName.bambu.com: H2C", "DevName.bambu.com: Bob\"s \\printer");
let found = parse_ssdp(&awkward, Ipv4Addr::LOCALHOST).expect("a printer");
let parsed: toml::Value = toml::from_str(&found.to_toml_entry("h2c")).expect("valid TOML");
assert_eq!(parsed["printer"][0]["name"].as_str(), Some("Bob\"s \\printer"));
}
#[test]
fn a_bound_printer_is_flagged_rather_than_hidden() {
let bound = NOTIFY.replace("DevBind.bambu.com: free", "DevBind.bambu.com: bound");
let found = parse_ssdp(&bound, Ipv4Addr::LOCALHOST).expect("a printer");
let entry = found.to_toml_entry("h2c");
assert!(entry.contains("DevBind"), "a bound printer needs the explanation: {entry}");
assert!(entry.contains("LAN mode"));
}
#[test]
fn a_firmware_version_is_a_comment_and_never_a_field() {
// Nothing in `fleet.rs` takes a version, and one written into a
// config file would be a lie the moment the printer updates.
let versioned = NOTIFY.replace("DevBind.bambu.com: free", "DevVersion.bambu.com: 01.08.00.00");
let found = parse_ssdp(&versioned, Ipv4Addr::LOCALHOST).expect("a printer");
assert_eq!(found.detail.as_deref(), Some("Bambu firmware 01.08.00.00"));
let entry = found.to_toml_entry("h2c");
assert!(entry.contains("# Bambu firmware 01.08.00.00"));
let parsed: toml::Value = toml::from_str(&entry).expect("valid TOML");
assert!(parsed["printer"][0].get("detail").is_none());
}
#[test]
fn an_id_is_suggested_from_the_model_then_the_name_then_the_serial() {
let found = parse_ssdp(NOTIFY, Ipv4Addr::LOCALHOST).expect("a printer");
assert_eq!(found.suggested_id(), "o1c2");
let no_model = parse_ssdp(&NOTIFY.replace("DevModel.bambu.com: O1C2\r\n", ""), Ipv4Addr::LOCALHOST).unwrap();
assert_eq!(no_model.suggested_id(), "h2c");
let mut bare = Candidate::new("192.168.0.20", Vendor::Bambu, Source::Certificate);
bare.serial = Some("01S09B441900143".to_string());
// The tail distinguishes two of the same model; the prefix does not.
assert_eq!(bare.suggested_id(), "printer-900143");
// A serial port has no identity but its path, which still beats
// calling it "printer".
let port = Candidate::new("/dev/ttyACM0", Vendor::PrusaSerial, Source::UsbDescriptor);
assert_eq!(port.suggested_id(), "ttyacm0");
}
#[test]
fn an_operators_name_becomes_a_safe_id() {
for (name, expected) in [("Bob's P1P!", "bob-s-p1p"), (" spaced out ", "spaced-out"), ("!!!", "")] {
assert_eq!(slug(name), expected, "for {name:?}");
}
}
#[test]
fn one_printer_found_by_two_mechanisms_is_reported_once_with_both_halves() {
// SSDP knows the model and the operator's name; the certificate
// knows the serial. Neither is complete, and an operator should not
// have to merge two blocks by hand.
let mut from_ssdp = Candidate::new("192.168.0.21", Vendor::Bambu, Source::Ssdp);
from_ssdp.model = Some("O1C2".to_string());
from_ssdp.name = Some("H2C".to_string());
let mut from_cert = Candidate::new("192.168.0.21", Vendor::Bambu, Source::Certificate);
from_cert.serial = Some("31B8BP5A1800585".to_string());
let merged = merge(vec![from_ssdp, from_cert]);
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].model.as_deref(), Some("O1C2"));
assert_eq!(merged[0].serial.as_deref(), Some("31B8BP5A1800585"));
}
#[test]
fn two_vendors_at_one_address_stay_separate() {
// A Raspberry Pi can run Moonraker and have PrusaLink reachable
// through it; merging those into one entry would invent a printer
// that is neither.
let merged = merge(vec![
Candidate::new("192.168.0.50", Vendor::Klipper, Source::HttpApi),
Candidate::new("192.168.0.50", Vendor::PrusaLink, Source::HttpApi),
]);
assert_eq!(merged.len(), 2);
}
#[test]
fn a_certificate_authoritys_name_is_not_a_serial_number() {
// The failure mode this guards: an issuer CN sits earlier in the DER
// than the subject's, so a scan for "the first CN" reports `BBL CA`.
assert!(valid_serial("BBL CA".to_string()).is_none(), "a space disqualifies it");
assert!(valid_serial("BBL Device CA O1C2-V2".to_string()).is_none());
// Both real printers, as read off their certificates on 2026-10-19.
assert_eq!(valid_serial("01S09B441900143".to_string()).as_deref(), Some("01S09B441900143"));
assert_eq!(valid_serial("31B8BP5A1800585".to_string()).as_deref(), Some("31B8BP5A1800585"));
}
}
+581
View File
@@ -0,0 +1,581 @@
//! Active probes: ask an address what it is, rather than waiting to be told.
//!
//! This is the half of discovery that works from anywhere that can route to
//! the printers, which is why it carries the weight - SSDP finds more about a
//! Bambu printer but only on its own broadcast domain.
//!
//! Each probe answers two different questions, and they are worth keeping
//! apart. "Is there a printer of this kind here?" needs no credential from
//! any of the three. "What is it called, what firmware does it run?" only has
//! an answer where the vendor publishes one unauthenticated - which Bambu
//! does, Moonraker usually does, and PrusaLink never does.
//!
//! That is why **an HTTP 401 counts as a successful detection.** It is not a
//! failure to work around: a password prompt from `/api/version` is proof
//! that something is serving `/api/version`, which is the question being
//! asked. Treating it as a find also makes the Moonraker probe correct
//! without having to know how a given host configured `[authorization]`,
//! which is not knowable from outside.
use std::net::{Ipv4Addr, SocketAddr};
use std::time::Duration;
use tracing::debug;
use super::{Candidate, Source, Vendor, valid_serial};
/// Bambu's MQTT port, which is also where its identifying certificate is.
pub const BAMBU_MQTT_PORT: u16 = 8883;
/// Moonraker's default HTTP port (`[server] port`, documented default 7125).
pub const MOONRAKER_PORT: u16 = 7125;
/// PrusaLink serves its OctoPrint-compatible API on plain HTTP.
pub const PRUSALINK_PORT: u16 = 80;
/// Reads a Bambu printer's serial number out of the certificate it presents
/// on 8883, sending no credentials and opening no MQTT session.
///
/// This accepts an unverifiable certificate, which deserves saying plainly:
/// the certificate is being read as a *name tag*, not trusted. There is
/// nothing to verify it against yet - the serial is exactly what is being
/// learned, and a P1P does not chain to the bundled Bambu CA at all. Trust is
/// still decided later and elsewhere, by `BambuTls::resolve` and the
/// trust-on-first-connect pinning in `BambuPrinter::ensure_trusted`, at the
/// point where credentials are actually sent. Nothing secret is transmitted
/// here, so a host impersonating a printer learns nothing and gains only the
/// chance to have its made-up serial printed to a terminal.
pub async fn identify_bambu(host: &str, timeout: Duration) -> anyhow::Result<Candidate> {
let host = host.to_string();
// A blocking handshake on its own thread, the same shape as
// `printer/bambu_ftps.rs` and for the same reason: `native_tls` is the
// blocking API, and this is a brief one-shot.
tokio::task::spawn_blocking(move || -> anyhow::Result<Candidate> {
let addr = resolve(&host, BAMBU_MQTT_PORT)?;
let tcp = std::net::TcpStream::connect_timeout(&addr, timeout)
.map_err(|err| anyhow::anyhow!("{host}:{BAMBU_MQTT_PORT} did not accept a connection: {err}"))?;
tcp.set_read_timeout(Some(timeout))?;
tcp.set_write_timeout(Some(timeout))?;
let connector = native_tls::TlsConnector::builder()
.danger_accept_invalid_certs(true)
.danger_accept_invalid_hostnames(true)
.build()?;
let stream = connector
.connect(&host, tcp)
.map_err(|err| anyhow::anyhow!("TLS handshake with {host} failed: {err}"))?;
let certificate = stream
.peer_certificate()?
.ok_or_else(|| anyhow::anyhow!("{host} completed a TLS handshake but presented no certificate"))?;
let serial = serial_from_der(&certificate.to_der()?)?;
let mut candidate = Candidate::new(&host, Vendor::Bambu, Source::Certificate);
candidate.serial = Some(serial);
Ok(candidate)
})
.await?
}
/// Resolves a host to one address, so a name works as well as a literal.
fn resolve(host: &str, port: u16) -> anyhow::Result<SocketAddr> {
use std::net::ToSocketAddrs;
(host, port)
.to_socket_addrs()
.map_err(|err| anyhow::anyhow!("could not resolve {host}: {err}"))?
.next()
.ok_or_else(|| anyhow::anyhow!("{host} resolved to no addresses"))
}
/// Pulls the subject CN out of a DER certificate and checks it is a serial.
pub fn serial_from_der(der: &[u8]) -> anyhow::Result<String> {
let cert = openssl::x509::X509::from_der(der)
.map_err(|err| anyhow::anyhow!("could not parse the presented certificate: {err}"))?;
// `entries_by_nid` on the *subject*, never the whole certificate: a Bambu
// certificate is issued by "BBL CA", whose own CN sits earlier in the
// DER, so anything that scans for the first CN it can find reports the
// certificate authority as the printer.
let cn = cert
.subject_name()
.entries_by_nid(openssl::nid::Nid::COMMONNAME)
.next()
.ok_or_else(|| anyhow::anyhow!("the certificate's subject has no CN, so it names no printer"))?
.data()
// `to_string` and not `as_utf8`: that one truncates at the first
// interior NUL, so a CN of `01S09B441900143\0anything` would read as
// a valid serial. `valid_serial` would reject the untruncated form,
// which is the point of checking the shape rather than trusting a
// name read off an unverified certificate.
.to_string()
.map_err(|err| anyhow::anyhow!("the certificate's CN is not text: {err}"))?;
valid_serial(cn.clone())
.ok_or_else(|| anyhow::anyhow!("the certificate's CN {cn:?} is not shaped like a Bambu serial number"))
}
/// One HTTP GET with a deadline, returning the status and the body.
///
/// Deliberately not `reqwest`'s JSON support: this crate builds reqwest with
/// `default-features = false` so that a presigned download pulls in no more
/// than it has to, and `serde_json` on the bytes costs nothing extra.
async fn http_get(url: &str, timeout: Duration) -> anyhow::Result<(u16, Vec<u8>, Option<String>)> {
let client = reqwest::Client::builder().timeout(timeout).build()?;
let response = client.get(url).send().await.map_err(|err| anyhow::anyhow!("{url}: {err}"))?;
let status = response.status().as_u16();
let challenge = response
.headers()
.get(reqwest::header::WWW_AUTHENTICATE)
.and_then(|value| value.to_str().ok())
.map(str::to_string);
let body = response.bytes().await.map_err(|err| anyhow::anyhow!("{url}: {err}"))?.to_vec();
Ok((status, body, challenge))
}
/// Probes for Moonraker, i.e. a Klipper host.
///
/// `GET /printer/info` is Moonraker's own identity endpoint and returns
/// `hostname` and `software_version` among others. A 401 still identifies
/// Moonraker - see this module's note - so the probe does not need to know
/// whether the host restricts `trusted_clients`, which it cannot know anyway.
pub async fn probe_moonraker(host: &str, timeout: Duration) -> anyhow::Result<Candidate> {
let url = format!("http://{host}:{MOONRAKER_PORT}/printer/info");
let (status, body, _) = http_get(&url, timeout).await?;
match status {
200 => parse_moonraker_info(host, &body),
401 | 403 => {
let mut candidate = Candidate::new(host, Vendor::Klipper, Source::HttpApi);
candidate.detail =
Some("Moonraker is here but would not say more without authorization (trusted_clients)".to_string());
Ok(candidate)
}
other => anyhow::bail!("{host} answered {url} with HTTP {other}, which is not Moonraker"),
}
}
/// Reads a `/printer/info` body. Separate from the request so it can be
/// tested, which matters because no Klipper host was available to try it on.
pub fn parse_moonraker_info(host: &str, body: &[u8]) -> anyhow::Result<Candidate> {
let parsed: serde_json::Value = serde_json::from_slice(body)
.map_err(|err| anyhow::anyhow!("{host} answered /printer/info with something that is not JSON: {err}"))?;
// Moonraker wraps every HTTP reply in `result`. If that is missing, this
// is some other service that happens to serve the path, and claiming it
// as a printer would be worse than missing it.
let result = parsed
.get("result")
.ok_or_else(|| anyhow::anyhow!("{host} answered /printer/info without a `result` - not Moonraker"))?;
let mut candidate = Candidate::new(host, Vendor::Klipper, Source::HttpApi);
candidate.name = result.get("hostname").and_then(|v| v.as_str()).map(str::to_string);
let version = result.get("software_version").and_then(|v| v.as_str());
let state = result.get("state").and_then(|v| v.as_str());
candidate.detail = Some(match (version, state) {
(Some(version), Some(state)) => format!("Klipper {version}, state {state}"),
(Some(version), None) => format!("Klipper {version}"),
// Reached only if Moonraker ever drops both fields. Still a
// Moonraker, so still a find.
_ => "Klipper (Moonraker gave no version)".to_string(),
});
Ok(candidate)
}
/// Probes for PrusaLink.
///
/// There is no unauthenticated identity endpoint: PrusaLink's own OpenAPI
/// specification puts `digestAuth` on every operation, including
/// `/api/version` and `/api/v1/info`, so a 401 is the *expected* and best
/// available answer. Which is enough - it establishes the address and the
/// vendor, and the one field that is still missing is the API key, which no
/// probe could ever supply.
///
/// **What this detects is the OctoPrint API, not a product.** PrusaLink
/// deliberately implements that API, so without a credential the two are
/// indistinguishable - and `vendor = "octoprint"` is what gets reported,
/// because that is what was actually observed. Naming `prusa_link` would
/// assert a product nothing on the wire mentions. The candidate carries
/// `ambiguous_with` so the rendered entry offers the other choice as a
/// one-word edit.
///
/// 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 rather than guessed.
pub async fn probe_prusalink(host: &str, timeout: Duration) -> anyhow::Result<Candidate> {
let url = format!("http://{host}:{PRUSALINK_PORT}/api/version");
let (status, body, challenge) = http_get(&url, timeout).await?;
classify_octoprint_api(host, status, &body, challenge)
}
/// The decision `probe_prusalink` makes once the response is in hand, split
/// out so the 401 path can be tested without a printer.
pub fn classify_octoprint_api(
host: &str,
status: u16,
body: &[u8],
challenge: Option<String>,
) -> anyhow::Result<Candidate> {
match status {
// Some builds answer unauthenticated. Take the detail if so.
200 => parse_octoprint_version(host, body),
401 => {
// A bare 401 is *not* enough. Plenty of things on a LAN password
// protect port 80 - routers, NASes, cameras - and accepting any
// of them would hand the operator a TOML block for their router.
// PrusaLink's specification puts `digestAuth` on every operation,
// so requiring a Digest challenge keeps the Basic-auth crowd out
// while still matching the only answer a real PrusaLink gives.
let challenge = challenge.ok_or_else(|| {
anyhow::anyhow!("{host} asked for credentials on /api/version but sent no challenge - not PrusaLink")
})?;
if !challenge.to_ascii_lowercase().contains("digest") {
anyhow::bail!(
"{host} wants {challenge:?} on /api/version, not Digest - some other password-protected web server, not PrusaLink"
);
}
// Reported as the generic flavour: a digest challenge proves the
// OctoPrint API is here and proves nothing about which
// implementation serves it.
let mut candidate = Candidate::new(host, Vendor::Octoprint, Source::HttpApi);
candidate.ambiguous_with = Some(Vendor::PrusaLink);
// The challenge is worth quoting back: `Digest realm="Printer API"`
// is what an operator can check against what they expect there.
candidate.detail =
Some(format!("an OctoPrint-compatible API needing a key - OctoPrint or PrusaLink ({challenge})"));
Ok(candidate)
}
other => anyhow::bail!("{host} answered /api/version with HTTP {other}, which is not PrusaLink"),
}
}
/// Reads an `/api/version` body, for the builds that answer without auth.
///
/// This is the one path that can name the implementation rather than the
/// protocol, because `text` says which it is.
pub fn parse_octoprint_version(host: &str, body: &[u8]) -> anyhow::Result<Candidate> {
let parsed: serde_json::Value = serde_json::from_slice(body)
.map_err(|err| anyhow::anyhow!("{host} answered /api/version with something that is not JSON: {err}"))?;
// `api` is the field OctoPrint's contract guarantees; without it this is
// some other web server that happens to answer /api/version.
if parsed.get("api").is_none() {
anyhow::bail!("{host} answered /api/version without an `api` field - not PrusaLink");
}
let text = parsed.get("text").and_then(|v| v.as_str()).unwrap_or("");
let lowered = text.to_ascii_lowercase();
// `text` names the implementation when it is there, so this is the only
// place the distinction is known rather than left to the operator.
let (vendor, ambiguous_with) = if lowered.contains("prusalink") {
(Vendor::PrusaLink, None)
} else if lowered.contains("octoprint") {
(Vendor::Octoprint, None)
} else {
(Vendor::Octoprint, Some(Vendor::PrusaLink))
};
let mut candidate = Candidate::new(host, vendor, Source::HttpApi);
candidate.ambiguous_with = ambiguous_with;
let firmware = parsed.get("firmware").and_then(|v| v.as_str());
let described = if text.is_empty() { "an OctoPrint-compatible API" } else { text };
candidate.detail = Some(match firmware {
Some(firmware) => format!("{described}, firmware {firmware}"),
None => described.to_string(),
});
candidate.name = parsed.get("hostname").and_then(|v| v.as_str()).map(str::to_string);
Ok(candidate)
}
/// Runs every network probe against one address, concurrently, and returns
/// whatever answered.
///
/// Concurrent rather than sequential so that one host costs one timeout
/// instead of three - which is the difference between a `/24` sweep taking
/// 24 seconds and taking a minute and a half. A host can legitimately answer
/// more than one probe: a Raspberry Pi running Moonraker may also have
/// something on port 80.
pub async fn identify_any(host: &str, timeout: Duration) -> Vec<Candidate> {
let (bambu, moonraker, prusalink) = tokio::join!(
identify_bambu(host, timeout),
probe_moonraker(host, timeout),
probe_prusalink(host, timeout),
);
let mut found = Vec::new();
for result in [bambu, moonraker, prusalink] {
match result {
Ok(candidate) => found.push(candidate),
// Expected for all but a handful of addresses in any subnet, so
// this is debug and not a warning.
Err(err) => debug!("{err}"),
}
}
found
}
/// Expands `a.b.c.d/prefix` into the hosts worth probing.
///
/// Refuses anything larger than a `/24`. That is a guard rather than a
/// limitation: 256 addresses is every printer anyone has on one segment, and
/// a mistyped `/8` would be sixteen million connection attempts across
/// somebody's network - which is not a thing this tool should be able to do
/// by accident.
pub fn hosts_in_subnet(subnet: &str) -> anyhow::Result<Vec<Ipv4Addr>> {
let (base, prefix) = subnet
.split_once('/')
.ok_or_else(|| anyhow::anyhow!("{subnet:?} is not a subnet - write it as 192.168.0.0/24"))?;
let base: Ipv4Addr = base.parse().map_err(|err| anyhow::anyhow!("{base:?} is not an IPv4 address: {err}"))?;
let prefix: u32 = prefix.parse().map_err(|err| anyhow::anyhow!("{prefix:?} is not a prefix length: {err}"))?;
if !(24..=32).contains(&prefix) {
anyhow::bail!(
"/{prefix} is too large to sweep - /24 (256 addresses) is the limit, \
and anything bigger is more likely a typo than an intention"
);
}
let mask = if prefix == 32 { u32::MAX } else { !(u32::MAX >> prefix) };
let network = u32::from(base) & mask;
let broadcast = network | !mask;
// A /32 is a single host, and it is neither a network nor a broadcast
// address - naming one address has to probe that address.
if prefix == 32 {
return Ok(vec![Ipv4Addr::from(network)]);
}
Ok((network + 1..broadcast).map(Ipv4Addr::from).collect())
}
/// Probes every host in a subnet and returns the printers that answered.
pub async fn sweep(subnet: &str, timeout: Duration) -> anyhow::Result<Vec<Candidate>> {
Ok(sweep_hosts(&hosts_in_subnet(subnet)?, timeout).await)
}
/// The probing half of `sweep`, for a caller that has already expanded a
/// subnet - so it can say how many addresses it is about to try before
/// trying them, rather than announcing work that validation then refuses.
///
/// Probes run in batches rather than all at once: 254 hosts times three
/// protocols is a burst that a consumer router notices, and the point is to
/// find printers without making the network worse while doing it.
pub async fn sweep_hosts(hosts: &[Ipv4Addr], timeout: Duration) -> Vec<Candidate> {
const BATCH: usize = 32;
let mut found = Vec::new();
for batch in hosts.chunks(BATCH) {
let probes = batch.iter().map(|host| {
let host = host.to_string();
async move { identify_any(&host, timeout).await }
});
for candidates in futures_util::future::join_all(probes).await {
found.extend(candidates);
}
}
found
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_subnet_expands_to_its_usable_hosts() {
let hosts = hosts_in_subnet("192.168.0.0/24").expect("a /24");
assert_eq!(hosts.len(), 254);
assert_eq!(hosts[0], Ipv4Addr::new(192, 168, 0, 1));
assert_eq!(hosts[253], Ipv4Addr::new(192, 168, 0, 254));
// A host address, not just a network address, names its own subnet.
let from_host = hosts_in_subnet("192.168.0.20/24").expect("a /24");
assert_eq!(from_host, hosts);
assert_eq!(hosts_in_subnet("192.168.0.20/32").expect("one host"), vec![Ipv4Addr::new(192, 168, 0, 20)]);
assert_eq!(hosts_in_subnet("192.168.0.0/30").expect("a /30").len(), 2);
}
#[test]
fn a_sweep_refuses_a_subnet_big_enough_to_be_a_typo() {
for too_big in ["10.0.0.0/8", "192.168.0.0/16", "192.168.0.0/23"] {
let err = hosts_in_subnet(too_big).expect_err("must refuse");
assert!(err.to_string().contains("too large"), "for {too_big}: {err}");
}
assert!(hosts_in_subnet("192.168.0.0").is_err(), "a bare address is not a subnet");
assert!(hosts_in_subnet("not-an-ip/24").is_err());
}
/// Moonraker's own documented `/printer/info` response.
const MOONRAKER_INFO: &[u8] = br#"{"result": {
"state": "ready",
"state_message": "Printer is ready",
"hostname": "voron-pi",
"software_version": "v0.12.0-191-g2ba5e0b2",
"cpu_info": "4 core ARMv7 Processor rev 4 (v7l)",
"klipper_path": "/home/pi/klipper",
"python_path": "/home/pi/klippy-env/bin/python",
"log_file": "/tmp/klippy.log",
"config_file": "/home/pi/printer.cfg"
}}"#;
/// PrusaLink's own documented `/api/version` response.
const PRUSALINK_VERSION: &[u8] = br#"{
"api": "2.0.0", "version": "2.1.2", "printer": "1.3.1",
"text": "PrusaLink 0.7.0", "firmware": "3.10.1-4697", "sdk": "0.7.0"
}"#;
#[test]
fn a_moonraker_reply_names_the_host_and_the_klipper_version() {
let found = parse_moonraker_info("192.168.0.50", MOONRAKER_INFO).expect("Moonraker");
assert_eq!(found.vendor, Vendor::Klipper);
assert_eq!(found.name.as_deref(), Some("voron-pi"));
assert_eq!(found.detail.as_deref(), Some("Klipper v0.12.0-191-g2ba5e0b2, state ready"));
// Klipper needs only a host, and Moonraker authorizes by client
// address rather than a key we could paste in.
let entry = found.to_toml_entry("voron");
let parsed: toml::Value = toml::from_str(&entry).expect("valid TOML");
assert_eq!(parsed["printer"][0]["host"].as_str(), Some("192.168.0.50"));
assert!(parsed["printer"][0].get("api_key").is_none());
}
#[test]
fn something_else_serving_printer_info_is_not_a_printer() {
// Moonraker wraps every reply in `result`; a bare object does not.
let err = parse_moonraker_info("192.168.0.50", br#"{"state":"ready"}"#).expect_err("must refuse");
assert!(err.to_string().contains("not Moonraker"), "{err}");
assert!(parse_moonraker_info("192.168.0.50", b"<html>hello</html>").is_err());
}
/// `text` names the implementation, so this is the one case where the
/// product is known rather than inferred - and it must be taken, not
/// flattened into the generic answer.
#[test]
fn a_body_that_names_itself_prusalink_is_reported_as_prusalink() {
let found = parse_octoprint_version("192.168.0.51", PRUSALINK_VERSION).expect("PrusaLink");
assert_eq!(found.vendor, Vendor::PrusaLink);
assert_eq!(found.ambiguous_with, None, "`text` settled it - nothing to offer the operator");
assert_eq!(found.detail.as_deref(), Some("PrusaLink 0.7.0, firmware 3.10.1-4697"));
// The API key is the field no probe can supply, so it must be
// rendered empty and labelled rather than omitted.
let parsed: toml::Value = toml::from_str(&found.to_toml_entry("mk4")).expect("valid TOML");
assert_eq!(parsed["printer"][0]["api_key"].as_str(), Some(""));
assert_eq!(parsed["printer"][0]["vendor"].as_str(), Some("prusa_link"));
}
#[test]
fn a_body_that_names_itself_octoprint_is_reported_as_octoprint() {
let body = br#"{"api":"0.1","server":"1.9.3","text":"OctoPrint 1.9.3"}"#;
let found = parse_octoprint_version("192.168.0.60", body).expect("OctoPrint");
assert_eq!(found.vendor, Vendor::Octoprint);
assert_eq!(found.ambiguous_with, None);
}
/// The common case: the API answers but names no product, so the vendor
/// reported is the protocol that was observed and the alternative is
/// offered rather than guessed away.
#[test]
fn an_unnamed_octoprint_api_reports_the_protocol_and_offers_the_choice() {
let body = br#"{"api":"2.0.0","version":"2.1.2"}"#;
let found = parse_octoprint_version("192.168.0.51", body).expect("the API");
assert_eq!(found.vendor, Vendor::Octoprint);
assert_eq!(found.ambiguous_with, Some(Vendor::PrusaLink));
let entry = found.to_toml_entry("mk4");
assert_eq!(
toml::from_str::<toml::Value>(&entry).expect("valid TOML")["printer"][0]["vendor"].as_str(),
Some("octoprint")
);
// The other choice has to be a one-word edit in the file the
// operator is already pasting into.
assert!(entry.contains("prusa_link"), "the alternative must be offered:\n{entry}");
assert!(entry.contains("only you can settle"), "{entry}");
}
#[test]
fn a_web_server_that_is_not_octoprint_shaped_is_refused() {
let err = parse_octoprint_version("192.168.0.51", br#"{"version":"1.0"}"#).expect_err("must refuse");
assert!(err.to_string().contains("not PrusaLink"), "{err}");
}
/// Captured from the real segment on 2026-10-19. Three devices there
/// 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. Without
/// the `api`-field guard every one of them would be reported as a
/// printer, so these are the cases that guard exists for.
#[test]
fn the_things_that_actually_answer_api_version_on_a_real_lan_are_not_printers() {
let real_bodies: [&[u8]; 4] = [
br#"<!DOCTYPE html>
<html lang="en">
<head>
<title></title>"#,
br#"<script>
var logonInfo = new Array(
0,
0,0);
var g_Lan = 394;"#,
// gzip, which is not even text.
&[0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0a],
b"",
];
for body in real_bodies {
let err = classify_octoprint_api("192.168.0.1", 200, body, None).expect_err("not a printer");
assert!(err.to_string().contains("not JSON") || err.to_string().contains("not PrusaLink"), "{err}");
}
}
#[test]
fn a_password_protected_router_is_not_a_printer() {
// The false positive that a bare "401 means PrusaLink" would create.
// Routers, NASes and cameras all password protect port 80.
let basic = classify_octoprint_api("192.168.0.1", 401, b"", Some(r#"Basic realm="RouterOS""#.into()));
let err = basic.expect_err("Basic auth is not PrusaLink");
assert!(err.to_string().contains("not Digest"), "{err}");
let silent = classify_octoprint_api("192.168.0.1", 401, b"", None);
assert!(silent.is_err(), "a 401 with no challenge names nothing");
// What a real PrusaLink answers, which must still be found.
let found = classify_octoprint_api("192.168.0.51", 401, b"", Some(r#"Digest realm="Printer API""#.into()))
.expect("a digest challenge on /api/version is the OctoPrint API");
// Generic, because a challenge proves the API is here and proves
// nothing about which implementation serves it.
assert_eq!(found.vendor, Vendor::Octoprint);
assert_eq!(found.ambiguous_with, Some(Vendor::PrusaLink));
assert!(found.detail.unwrap().contains("Printer API"));
}
/// The one link between a real socket and `classify_octoprint_api` that
/// unit tests on the classifier cannot reach: whether the challenge
/// header actually survives the trip. PrusaLink is identified *by* that
/// header, so if `http_get` dropped it every PrusaLink would be reported
/// as "no challenge - not PrusaLink". Port 80 cannot be bound without
/// root here, so this stands in for the real probe's plumbing.
#[tokio::test]
async fn a_digest_challenge_survives_the_round_trip_and_identifies_the_api() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("a port");
let port = listener.local_addr().expect("an address").port();
std::thread::spawn(move || {
use std::io::{Read, Write};
let (mut socket, _) = listener.accept().expect("one connection");
let mut scratch = [0u8; 1024];
let _ = socket.read(&mut scratch);
// Exactly what PrusaLink answers an unauthenticated GET.
let _ = socket.write_all(
b"HTTP/1.1 401 Unauthorized\r\n\
WWW-Authenticate: Digest realm=\"Printer API\", nonce=\"abc\", qop=\"auth\"\r\n\
Content-Length: 0\r\n\r\n",
);
});
let (status, body, challenge) =
http_get(&format!("http://127.0.0.1:{port}/api/version"), Duration::from_secs(5))
.await
.expect("the stand-in answers");
assert_eq!(status, 401);
assert!(challenge.as_deref().unwrap_or_default().contains("Digest"), "{challenge:?}");
// And the classifier turns that into the generic flavour with the
// alternative offered - the whole path, end to end.
let found = classify_octoprint_api("127.0.0.1", status, &body, challenge).expect("the OctoPrint API");
assert_eq!(found.vendor, Vendor::Octoprint);
assert_eq!(found.ambiguous_with, Some(Vendor::PrusaLink));
assert!(found.detail.unwrap().contains("Printer API"));
}
#[test]
fn a_garbage_certificate_is_an_error_and_not_a_panic() {
let err = serial_from_der(b"not a certificate").expect_err("must not parse");
assert!(err.to_string().contains("could not parse"), "{err}");
}
}
+164
View File
@@ -0,0 +1,164 @@
//! 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, 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(configured) => 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
}
+495
View File
@@ -0,0 +1,495 @@
//! Finding printers on USB/serial ports - old Prusa machines and any other
//! Marlin-family board.
//!
//! # Why this is passive by default
//!
//! **Opening a serial port reboots the printer.** On Arduino-based boards the
//! act of opening the port toggles DTR, which resets the MCU;
//! `printer/marlin_serial.rs` notes the same thing from the other direction,
//! which is why it holds one connection open rather than reopening per line.
//! So a firmware handshake against a machine that happens to be mid-print
//! *ruins the print* - and discovery is exactly the sort of thing someone
//! runs while a farm is busy, to add a sixth printer to five working ones.
//!
//! `list_ports` therefore opens nothing. It reads USB descriptors out of
//! sysfs, which is where udev reads them from too, and gets the manufacturer,
//! the product name and the USB serial without touching the device. That is
//! enough to render an entry, because a serial printer's `[[printer]]` block
//! needs only the device path and a baud rate.
//!
//! `probe_m115` is the opt-in extra: it learns the firmware and the exact
//! machine type, which nothing else can, and it costs a reset. It is never
//! called unless a caller asks for that specific port.
//!
//! # Why sysfs rather than libudev
//!
//! `tokio-serial` can enumerate ports with USB metadata, but only with its
//! `libudev` feature, which needs `libudev-dev` at build time. A gateway
//! appliance should not need a system library to list its own ports, and
//! this container does not have one. Reading the four files that udev would
//! read is about thirty lines and no dependency.
//!
//! # Unverified
//!
//! None of this has run against hardware: no serial device is attached here
//! and `/sys/class/tty` holds nothing but virtual consoles. The sysfs walk
//! and the `M115` parser are unit tested against recorded text, and
//! `list_ports` on a machine with no ports correctly returns an empty list,
//! which is the only part this environment can actually demonstrate.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use super::{Candidate, Source, Vendor};
/// Marlin's usual default, and `fleet.rs`'s default too.
pub const DEFAULT_BAUD_RATE: u32 = 115_200;
/// How long to wait for a board to finish booting after the port is opened.
///
/// The open resets the board, so the first thing on the wire is a bootloader
/// pause and then Marlin's startup banner - asking for `M115` before that is
/// finished gets no reply.
const BOOT_WAIT: Duration = Duration::from_secs(3);
/// A serial port, as described by USB without being opened.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SerialDevice {
/// The device path, e.g. `/dev/ttyACM0`.
pub path: String,
pub vendor_id: Option<u16>,
pub product_id: Option<u16>,
pub manufacturer: Option<String>,
pub product: Option<String>,
/// The USB serial string, which is not a printer serial number - it
/// identifies the USB-serial bridge chip, and on a generic CH340 it is
/// often absent or shared between every board of that type.
pub usb_serial: Option<String>,
}
impl SerialDevice {
/// What is known about this port's USB vendor, if anything.
pub fn vendor_description(&self) -> Option<&'static str> {
describe_usb_vendor(self.vendor_id?)
}
/// Whether this is probably a 3D printer rather than any other serial
/// device.
///
/// Only Prusa's own USB vendor id is a real answer. Everything else is a
/// USB-serial bridge that a printer is *likely* to be behind but that a
/// GPS, an Arduino project or a label printer could equally be - so this
/// is a hint for an operator to confirm, never a basis for connecting to
/// something and sending it gcode.
pub fn is_probably_a_printer(&self) -> bool {
self.vendor_id == Some(PRUSA_USB_VENDOR)
}
pub fn to_candidate(&self) -> Candidate {
let mut candidate = Candidate::new(&self.path, Vendor::PrusaSerial, Source::UsbDescriptor);
// The product string is the operator-facing name here - a Prusa
// reports something like "Original Prusa i3 MK3" - but it describes
// the USB interface, so fall back to the vendor rather than invent.
candidate.name = self.product.clone().or_else(|| self.manufacturer.clone());
candidate.model = self.product.clone();
let ids = match (self.vendor_id, self.product_id) {
(Some(vid), Some(pid)) => format!("USB {vid:04x}:{pid:04x}"),
_ => "no USB ids".to_string(),
};
let vendor = self.vendor_description().unwrap_or("unrecognised USB vendor");
candidate.detail = Some(if self.is_probably_a_printer() {
format!("{ids} - {vendor}")
} else {
// Said plainly, because the operator is the one who knows what is
// plugged in and this is a guess they need to check.
format!("{ids} - {vendor}; could be a printer or any other serial device")
});
candidate
}
}
/// Prusa Research's own USB vendor id, the one strong positive signal.
const PRUSA_USB_VENDOR: u16 = 0x2c99;
/// The USB vendors a Marlin board is plausibly behind.
///
/// Everything except Prusa here is a generic USB-serial bridge, which is why
/// `is_probably_a_printer` does not treat them as evidence. They are listed
/// so an operator reading the output can recognise what they plugged in.
fn describe_usb_vendor(vendor_id: u16) -> Option<&'static str> {
Some(match vendor_id {
PRUSA_USB_VENDOR => "Prusa Research",
0x0403 => "FTDI serial bridge (common on older RAMPS boards)",
0x1a86 => "QinHeng CH340/CH341 serial bridge",
0x10c4 => "Silicon Labs CP210x serial bridge",
0x2341 => "Arduino",
0x2a03 => "Arduino (dog hunter)",
0x1b4f => "SparkFun",
0x16c0 => "Teensy / Van Ooijen",
0x0483 => "STMicroelectronics (many 32-bit printer boards)",
_ => return None,
})
}
/// Lists serial ports and what USB says about them, opening nothing.
///
/// Ports with no USB identity are included only when their name says they are
/// USB-attached (`ttyUSB*`/`ttyACM*`). A bare `ttyS0` is skipped, because
/// most systems enumerate a row of 8250 ports that are not physically there
/// and listing thirty-two phantom devices would bury the two real ones.
pub fn list_ports() -> anyhow::Result<Vec<SerialDevice>> {
let class = Path::new("/sys/class/tty");
if !class.is_dir() {
anyhow::bail!("{} does not exist - this is not a Linux host with sysfs", class.display());
}
let mut ports = Vec::new();
for entry in std::fs::read_dir(class)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
let device_path = format!("/dev/{name}");
if !Path::new(&device_path).exists() {
continue;
}
let usb_dir = usb_parent(&entry.path());
let looks_usb = name.starts_with("ttyUSB") || name.starts_with("ttyACM");
if usb_dir.is_none() && !looks_usb {
continue;
}
let mut device = SerialDevice {
path: device_path,
vendor_id: None,
product_id: None,
manufacturer: None,
product: None,
usb_serial: None,
};
if let Some(dir) = usb_dir {
device.vendor_id = read_hex(&dir, "idVendor");
device.product_id = read_hex(&dir, "idProduct");
device.manufacturer = read_trimmed(&dir, "manufacturer");
device.product = read_trimmed(&dir, "product");
device.usb_serial = read_trimmed(&dir, "serial");
}
ports.push(device);
}
ports.sort_by(|a, b| a.path.cmp(&b.path));
Ok(ports)
}
/// Walks up from a tty's `device` link to the USB device that owns it.
///
/// The number of levels differs by driver - a `ttyACM` link points at a USB
/// *interface* while a `ttyUSB` link points at a usb-serial port, each a
/// different depth below the device that holds `idVendor` - so this climbs
/// until it finds the files rather than counting parents.
fn usb_parent(tty_dir: &Path) -> Option<PathBuf> {
let mut current = std::fs::canonicalize(tty_dir.join("device")).ok()?;
loop {
if current.join("idVendor").is_file() && current.join("idProduct").is_file() {
return Some(current);
}
// Stops at /sys rather than walking to the filesystem root.
if !current.pop() || current.as_os_str().len() <= "/sys".len() {
return None;
}
}
}
fn read_trimmed(dir: &Path, file: &str) -> Option<String> {
let text = std::fs::read_to_string(dir.join(file)).ok()?;
let trimmed = text.trim().to_string();
(!trimmed.is_empty()).then_some(trimmed)
}
fn read_hex(dir: &Path, file: &str) -> Option<u16> {
u16::from_str_radix(read_trimmed(dir, file)?.trim_start_matches("0x"), 16).ok()
}
/// Asks a board what firmware it runs, by opening the port and sending
/// `M115`.
///
/// **This reboots the printer**, because opening the port toggles DTR on an
/// Arduino-based board. Never call it against a machine that might be
/// printing. It is separate from `list_ports` for exactly that reason, and
/// the caller has to name the port.
///
/// What it buys: `MACHINE_TYPE` and `FIRMWARE_NAME`, which is the only way to
/// tell an MK3S from an MK2 or a Prusa from an Ender - USB descriptors
/// describe the bridge chip, not the printer.
pub async fn probe_m115(port: &str, baud_rate: u32, timeout: Duration) -> anyhow::Result<Candidate> {
use tokio_serial::SerialPortBuilderExt;
let stream = tokio_serial::new(port, baud_rate)
.timeout(timeout)
.open_native_async()
.map_err(|err| anyhow::anyhow!("could not open {port} at {baud_rate} baud: {err}"))?;
let (read_half, mut write_half) = tokio::io::split(stream);
let mut lines = BufReader::new(read_half).lines();
// The open just reset the board; asking before it has booted gets
// nothing back.
tokio::time::sleep(BOOT_WAIT).await;
write_half
.write_all(b"M115\n")
.await
.map_err(|err| anyhow::anyhow!("could not write M115 to {port}: {err}"))?;
// Collect until Marlin's "ok" terminates the reply, or the budget runs
// out. Lines before the reply - the boot banner - are kept, because
// `echo:Marlin 2.1.2` identifies the firmware even if `M115` is somehow
// unanswered.
let mut collected = String::new();
let deadline = tokio::time::Instant::now() + timeout;
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
break;
}
match tokio::time::timeout(remaining, lines.next_line()).await {
Ok(Ok(Some(line))) => {
let is_ok = line.trim() == "ok" || line.trim().starts_with("ok ");
collected.push_str(&line);
collected.push('\n');
if is_ok && collected.contains("FIRMWARE_NAME") {
break;
}
}
// The port closed.
Ok(Ok(None)) => break,
Ok(Err(err)) => anyhow::bail!("error reading from {port}: {err}"),
Err(_) => break,
}
}
if collected.trim().is_empty() {
anyhow::bail!(
"{port} opened but said nothing in {timeout:?} - wrong baud rate, \
or not a Marlin board (it may have been reset regardless)"
);
}
candidate_from_m115(port, &collected)
.ok_or_else(|| anyhow::anyhow!("{port} replied but named no firmware:\n{}", collected.trim()))
}
/// Builds a candidate from an `M115` reply, or `None` if it names no firmware.
pub fn candidate_from_m115(port: &str, response: &str) -> Option<Candidate> {
let fields = parse_m115(response);
let firmware = fields.get("FIRMWARE_NAME");
let machine = fields.get("MACHINE_TYPE");
// Marlin's boot banner, for a board that answered M115 with nothing
// useful but announced itself on reset.
let banner = response
.lines()
.find(|line| line.starts_with("echo:") && line.to_ascii_lowercase().contains("marlin"))
.map(|line| line.trim_start_matches("echo:").trim().to_string());
if firmware.is_none() && machine.is_none() && banner.is_none() {
return None;
}
let mut candidate = Candidate::new(port, Vendor::PrusaSerial, Source::SerialHandshake);
candidate.model = machine.cloned();
candidate.name = machine.cloned();
candidate.detail = firmware.cloned().or(banner);
// `UUID` is Marlin's own board identifier. Kept as the serial because it
// is the only stable per-machine value a serial printer offers, and it is
// what distinguishes two identical MK3S units on one host.
candidate.serial = fields.get("UUID").cloned();
Some(candidate)
}
/// Splits an `M115` reply into its `KEY:value` fields.
///
/// The format has no delimiter between fields and values may contain spaces -
/// `FIRMWARE_NAME:Prusa-Firmware 3.13.3 based on Marlin FIRMWARE_URL:...` -
/// so the only way to find where a value ends is to find where the next key
/// begins. A key is a run of capitals, digits and underscores followed by a
/// colon, at the start of a line or after a space, which is also what keeps
/// Marlin's `Cap:EEPROM:1` capability lines out of the result: `Cap` is not
/// all capitals.
pub fn parse_m115(response: &str) -> BTreeMap<String, String> {
let bytes = response.as_bytes();
let is_key_char = |b: u8| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_';
// Every (key, value-start) pair, in order.
let mut keys: Vec<(String, usize)> = Vec::new();
let mut index = 0;
while index < bytes.len() {
if !is_key_char(bytes[index]) || (index > 0 && !matches!(bytes[index - 1], b' ' | b'\n' | b'\r' | b'\t')) {
index += 1;
continue;
}
let start = index;
while index < bytes.len() && is_key_char(bytes[index]) {
index += 1;
}
if index < bytes.len() && bytes[index] == b':' && index > start {
keys.push((response[start..index].to_string(), index + 1));
index += 1;
}
}
let mut fields = BTreeMap::new();
for (position, (key, value_start)) in keys.iter().enumerate() {
// The value runs to the start of the next key...
let next_key = keys
.get(position + 1)
.map(|(next_key, next_start)| next_start - next_key.len() - 1)
.unwrap_or(response.len());
// ...or to the end of its own line, whichever comes first. These
// fields are line-oriented, and without the newline bound the last
// value on a line swallows everything after it - which is how `UUID`
// came to be read as "cede2a2f-...\nok".
let line_end = response[*value_start..].find('\n').map(|at| value_start + at).unwrap_or(response.len());
let end = next_key.min(line_end).max(*value_start);
let value = response[*value_start..end].trim();
if !value.is_empty() {
fields.insert(key.clone(), value.to_string());
}
}
fields
}
#[cfg(test)]
mod tests {
use super::*;
/// A real MK3S reply's shape: space-separated keys, values with spaces in
/// them, and capability lines after.
const MK3S: &str = "FIRMWARE_NAME:Prusa-Firmware 3.13.3 based on Marlin \
FIRMWARE_URL:https://github.com/prusa3d/Prusa-Firmware \
PROTOCOL_VERSION:1.0 MACHINE_TYPE:Prusa i3 MK3S EXTRUDER_COUNT:1 \
UUID:00000000-0000-0000-0000-000000000000\n\
Cap:EEPROM:1\nCap:AUTOREPORT_TEMP:1\nok\n";
#[test]
fn a_firmware_reply_splits_on_the_next_key_not_on_spaces() {
let fields = parse_m115(MK3S);
// The thing that makes this format awkward: the value has spaces in
// it and ends only where the next key starts.
assert_eq!(fields.get("FIRMWARE_NAME").map(String::as_str), Some("Prusa-Firmware 3.13.3 based on Marlin"));
assert_eq!(fields.get("MACHINE_TYPE").map(String::as_str), Some("Prusa i3 MK3S"));
assert_eq!(fields.get("PROTOCOL_VERSION").map(String::as_str), Some("1.0"));
assert_eq!(fields.get("EXTRUDER_COUNT").map(String::as_str), Some("1"));
assert_eq!(fields.get("FIRMWARE_URL").map(String::as_str), Some("https://github.com/prusa3d/Prusa-Firmware"));
}
#[test]
fn capability_lines_are_not_fields() {
// `Cap:` is mixed case, so the all-capitals rule excludes it - which
// is lucky, because `Cap:EEPROM:1` would otherwise parse as a key
// whose value is another key.
let fields = parse_m115(MK3S);
assert!(!fields.contains_key("Cap"), "{fields:?}");
assert!(!fields.contains_key("EEPROM"), "{fields:?}");
}
#[test]
fn a_reply_becomes_a_candidate_naming_the_machine() {
let candidate = candidate_from_m115("/dev/ttyACM0", MK3S).expect("a printer");
assert_eq!(candidate.host, "/dev/ttyACM0");
assert_eq!(candidate.vendor, Vendor::PrusaSerial);
assert_eq!(candidate.model.as_deref(), Some("Prusa i3 MK3S"));
assert_eq!(candidate.detail.as_deref(), Some("Prusa-Firmware 3.13.3 based on Marlin"));
assert_eq!(candidate.source, Source::SerialHandshake);
let entry = candidate.to_toml_entry("mk3s");
let parsed: toml::Value = toml::from_str(&entry).expect("valid TOML");
// A serial printer is addressed by path, not host - the field name
// `fleet.rs` wants is `port`.
assert_eq!(parsed["printer"][0]["port"].as_str(), Some("/dev/ttyACM0"));
assert_eq!(parsed["printer"][0]["vendor"].as_str(), Some("prusa_serial"));
assert!(parsed["printer"][0].get("host").is_none());
}
#[test]
fn a_generic_marlin_board_is_still_identified() {
// Not a Prusa, and the point of the handshake: USB descriptors would
// have said only "CH340 serial bridge".
let ender = "FIRMWARE_NAME:Marlin 2.1.2.1 (Github) SOURCE_CODE_URL:... \
PROTOCOL_VERSION:1.0 MACHINE_TYPE:Ender-3 EXTRUDER_COUNT:1 UUID:cede2a2f-41a2-4748-9b12-c55c62f367ff\nok\n";
let candidate = candidate_from_m115("/dev/ttyUSB0", ender).expect("a printer");
assert_eq!(candidate.model.as_deref(), Some("Ender-3"));
assert_eq!(candidate.serial.as_deref(), Some("cede2a2f-41a2-4748-9b12-c55c62f367ff"));
}
#[test]
fn a_boot_banner_alone_still_names_the_firmware() {
// The open resets the board, so sometimes all that arrives is the
// startup banner. That still identifies Marlin, and throwing it away
// would mean resetting a printer for nothing.
let banner = "start\necho:Marlin 2.1.2.1\necho: Last Updated: 2024-01-01\nok\n";
let candidate = candidate_from_m115("/dev/ttyUSB0", banner).expect("a board");
assert_eq!(candidate.detail.as_deref(), Some("Marlin 2.1.2.1"));
assert_eq!(candidate.model, None, "the banner names firmware, not a machine");
}
#[test]
fn silence_and_noise_are_not_printers() {
assert!(candidate_from_m115("/dev/ttyUSB0", "").is_none());
assert!(candidate_from_m115("/dev/ttyUSB0", "ok\n").is_none());
// A GPS on a CH340 answers, but not with firmware fields.
assert!(candidate_from_m115("/dev/ttyUSB0", "$GPGGA,123519,4807.038,N\n").is_none());
}
#[test]
fn only_prusas_own_usb_id_counts_as_evidence() {
let prusa = SerialDevice {
path: "/dev/ttyACM0".into(),
vendor_id: Some(0x2c99),
product_id: Some(0x0002),
manufacturer: Some("Prusa Research (prusa3d.com)".into()),
product: Some("Original Prusa i3 MK3".into()),
usb_serial: Some("CZPX1234X567XK98765".into()),
};
assert!(prusa.is_probably_a_printer());
assert_eq!(prusa.vendor_description(), Some("Prusa Research"));
let candidate = prusa.to_candidate();
assert_eq!(candidate.model.as_deref(), Some("Original Prusa i3 MK3"));
assert_eq!(candidate.detail.as_deref(), Some("USB 2c99:0002 - Prusa Research"));
assert!(!candidate.detail.as_deref().unwrap().contains("could be"));
// A CH340 is a serial bridge, not a printer. Report it, but say so -
// the operator knows what they plugged in and we do not.
let ch340 = SerialDevice {
path: "/dev/ttyUSB0".into(),
vendor_id: Some(0x1a86),
product_id: Some(0x7523),
manufacturer: None,
product: None,
usb_serial: None,
};
assert!(!ch340.is_probably_a_printer());
let detail = ch340.to_candidate().detail.unwrap();
assert!(detail.contains("CH340"), "{detail}");
assert!(detail.contains("could be a printer or any other serial device"), "{detail}");
}
#[test]
fn an_unknown_usb_vendor_is_reported_rather_than_dropped() {
let unknown = SerialDevice {
path: "/dev/ttyUSB0".into(),
vendor_id: Some(0xdead),
product_id: Some(0xbeef),
manufacturer: None,
product: None,
usb_serial: None,
};
assert_eq!(unknown.vendor_description(), None);
assert!(unknown.to_candidate().detail.unwrap().contains("unrecognised USB vendor"));
}
/// The one part of serial discovery this environment can demonstrate:
/// with no ports attached it finds none, and does not error doing it.
#[test]
fn listing_ports_where_there_are_none_is_empty_and_not_an_error() {
let ports = list_ports().expect("sysfs exists on this host");
for port in &ports {
assert!(port.path.starts_with("/dev/"), "{port:?}");
}
}
}
+140 -25
View File
@@ -3,11 +3,15 @@
//! 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 crate::printer::{BambuTls, BambuV1Printer, BambuV2Printer, KlipperPrinter, PrinterHandle, PrusaLinkPrinter, PrusaSerialPrinter};
use crate::printer::{
BambuPrinter, BambuProtocolPreference, BambuTls, KlipperPrinter, OctoPrintFlavour, OctoPrintPrinter,
PrinterHandle, PrusaSerialPrinter,
};
#[derive(Debug, Deserialize)]
struct FleetFile {
@@ -19,7 +23,15 @@ struct FleetFile {
struct PrinterEntry {
id: String,
name: String,
/// One of: "bambu_v1", "bambu_v2", "prusa_link", "prusa_serial", "klipper".
/// 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),
/// "prusa_link", "octoprint", "prusa_serial", "klipper".
///
/// `prusa_link` and `octoprint` are the same REST client with a different
/// 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>,
@@ -27,7 +39,10 @@ struct PrinterEntry {
/// by serial (`device/{sn}/report`), not by our own `id`.
sn: Option<String>,
api_key: Option<String>,
com_port: Option<i16>,
/// Serial device path, e.g. `/dev/ttyACM0` on Linux.
port: Option<String>,
#[serde(default = "default_baud_rate")]
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).
@@ -40,6 +55,11 @@ fn default_true() -> bool {
true
}
/// Marlin's usual default on most current Prusa firmware.
fn default_baud_rate() -> u32 {
115_200
}
/// Reads and parses a printers file into ready-to-use `PrinterHandle`s.
pub fn load(path: &Path) -> anyhow::Result<Vec<PrinterHandle>> {
let text = std::fs::read_to_string(path).map_err(|err| {
@@ -52,30 +72,86 @@ pub fn load(path: &Path) -> anyhow::Result<Vec<PrinterHandle>> {
file.printers.into_iter().map(build_printer).collect()
}
/// 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();
for entry in file.printers {
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);
}
}
};
add(&mut out.hosts, entry.host);
add(&mut out.serials, entry.sn);
add(&mut out.ports, entry.port);
}
Ok(out)
}
fn build_printer(entry: PrinterEntry) -> anyhow::Result<PrinterHandle> {
match entry.vendor.as_str() {
"bambu_v1" => {
let host = require_field(&entry.id, "host", entry.host)?;
let access_code = require_field(&entry.id, "access_code", entry.access_code)?;
let sn = require_field(&entry.id, "sn", entry.sn)?;
let tls = BambuTls::resolve(entry.ca_cert_path.as_deref().map(Path::new), entry.require_valid_cert)?;
Ok(PrinterHandle::BambuV1(BambuV1Printer::new(&entry.id, &entry.name, &host, &access_code, &sn, tls)))
}
"bambu_v2" => {
let host = require_field(&entry.id, "host", entry.host)?;
let access_code = require_field(&entry.id, "access_code", entry.access_code)?;
let sn = require_field(&entry.id, "sn", entry.sn)?;
let tls = BambuTls::resolve(entry.ca_cert_path.as_deref().map(Path::new), entry.require_valid_cert)?;
Ok(PrinterHandle::BambuV2(BambuV2Printer::new(&entry.id, &entry.name, &host, &access_code, &sn, tls)))
}
"prusa_link" => {
let host = require_field(&entry.id, "host", entry.host)?;
let api_key = require_field(&entry.id, "api_key", entry.api_key)?;
Ok(PrinterHandle::PrusaLink(PrusaLinkPrinter::new(&entry.id, &entry.name, &host, &api_key)))
}
"bambu" => build_bambu(entry, BambuProtocolPreference::Auto),
"bambu_v1" => build_bambu(entry, BambuProtocolPreference::ForceV1),
"bambu_v2" => build_bambu(entry, BambuProtocolPreference::ForceV2),
// Two names for one client, because PrusaLink implements OctoPrint's
// API. The distinction is kept rather than collapsed: the two differ
// in which endpoints they offer beyond `/api/version`, and - the
// reason it has to come from config - nothing on the network can tell
// them apart, since every PrusaLink identity endpoint needs a
// credential. So this is the operator's answer, and `octoprint` is
// the honest default for a printer found by discovery.
"prusa_link" => build_octoprint(entry, OctoPrintFlavour::PrusaLink),
"octoprint" => build_octoprint(entry, OctoPrintFlavour::Generic),
"prusa_serial" => {
let com_port = entry.com_port.ok_or_else(|| anyhow::anyhow!("{}: prusa_serial needs com_port", entry.id))?;
Ok(PrinterHandle::PrusaSerial(PrusaSerialPrinter::new(&entry.id, &entry.name, com_port)))
let port = require_field(&entry.id, "port", entry.port)?;
Ok(PrinterHandle::PrusaSerial(PrusaSerialPrinter::new(&entry.id, &entry.name, &port, entry.baud_rate)))
}
"klipper" => {
let host = require_field(&entry.id, "host", entry.host)?;
@@ -85,6 +161,45 @@ fn build_printer(entry: PrinterEntry) -> anyhow::Result<PrinterHandle> {
}
}
fn build_octoprint(entry: PrinterEntry, flavour: OctoPrintFlavour) -> anyhow::Result<PrinterHandle> {
let host = require_field(&entry.id, "host", entry.host)?;
let api_key = require_field(&entry.id, "api_key", entry.api_key)?;
Ok(PrinterHandle::OctoPrint(OctoPrintPrinter::new(&entry.id, &entry.name, &host, &api_key, flavour)))
}
fn build_bambu(entry: PrinterEntry, preference: BambuProtocolPreference) -> anyhow::Result<PrinterHandle> {
let host = require_field(&entry.id, "host", entry.host)?;
let access_code = require_field(&entry.id, "access_code", entry.access_code)?;
let sn = require_field(&entry.id, "sn", entry.sn)?;
let tls = BambuTls::resolve(entry.ca_cert_path.as_deref().map(Path::new), entry.require_valid_cert)?;
Ok(PrinterHandle::Bambu(BambuPrinter::new(&entry.id, &entry.name, &host, &access_code, &sn, tls, preference)))
}
/// A required field, where **empty counts as missing**.
///
/// The emptiness check is the load-bearing half. `Option` only catches an
/// absent key, so `access_code = ""` arrived here as `Some("")` and loaded
/// happily - and an empty credential is not a usable one, it just moves the
/// failure somewhere worse. A Bambu printer configured that way reaches MQTT
/// and is refused, a PrusaLink one gets a 401 on every poll, and nothing in
/// either chain says "that field is blank in printers.toml".
///
/// It is also what makes a half-finished entry safe. `discovery` prints
/// blocks with `access_code = ""`/`api_key = ""` for an operator to
/// complete, and pasting one in and forgetting to finish it has to fail at
/// startup naming the field - which is only true because of this check.
/// `tests/discovery_to_fleet.rs` asserts exactly that, and is how the gap
/// was found.
///
/// This is the same trap as `??`-instead-of-`||` on an environment variable
/// that ships empty, which `continuum-backend`'s `auth/edge_secret.ts` and
/// `s3/index.ts` both document. It bites identically on a TOML key.
fn require_field(printer_id: &str, field: &str, value: Option<String>) -> anyhow::Result<String> {
value.ok_or_else(|| anyhow::anyhow!("{printer_id}: missing required field '{field}'"))
match value {
Some(value) if !value.trim().is_empty() => Ok(value),
Some(_) => Err(anyhow::anyhow!(
"{printer_id}: required field '{field}' is empty - fill it in (discovery leaves it blank on purpose; an empty value is not a usable one)"
)),
None => Err(anyhow::anyhow!("{printer_id}: missing required field '{field}'")),
}
}
+172 -7
View File
@@ -1,12 +1,97 @@
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use tokio::process::Command;
use tokio::sync::Notify;
use tracing::{error, info, warn};
/// Supervises the go2rtc subprocess used for camera restreaming (RTSP/USB -> WebRTC).
/// Restarts it with a fixed backoff whenever it exits, for as long as the daemon runs.
pub async fn watchdog(bin: String, config_path: String) {
/// 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.
///
/// `restart` is what `camera::run` rings after rewriting the config: go2rtc
/// reads its configuration once at startup, so a config that changed while it
/// was running would otherwise be ignored until the process happened to die.
/// 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");
@@ -19,10 +104,34 @@ pub async fn watchdog(bin: String, config_path: String) {
.spawn();
match spawned {
Ok(mut child) => match child.wait().await {
Ok(status) => warn!(%status, "go2rtc exited, restarting after backoff"),
Err(err) => error!(?err, "failed to wait on go2rtc process"),
},
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
// of this arm, so the restart path needs no explicit
// kill - and cannot leave an orphan serving the old
// config.
waited = child.wait() => match waited {
Ok(status) => warn!(%status, "go2rtc exited, restarting after backoff"),
Err(err) => error!(?err, "failed to wait on go2rtc process"),
},
_ = restart.notified() => {
info!("go2rtc config changed, restarting it");
// Explicitly, rather than relying on the drop: this
// awaits the kill so the next iteration cannot race
// the old process for the API port.
if let Err(err) = child.kill().await {
warn!(?err, "could not stop go2rtc for a config change");
}
// No backoff on this path - a config change is a
// deliberate act, not a crash loop.
continue;
}
}
}
Err(err) => {
error!(?err, "failed to spawn go2rtc, is it installed and on PATH?");
}
@@ -31,3 +140,59 @@ pub async fn watchdog(bin: String, config_path: String) {
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);
}
}
+16 -4
View File
@@ -1,7 +1,19 @@
//! Library surface for `continuum-proxy`. The daemon itself is a binary
//! (`src/main.rs`); this crate root exists so standalone examples (see
//! `examples/`) and future integration tests can `use continuum_proxy::...`
//! without duplicating module declarations.
//! Library surface for `continuum-proxy`. The daemon itself is a thin binary
//! (`src/main.rs`) that uses this crate; the examples in `examples/` and the
//! integration tests in `tests/` use it too.
//!
//! Everything the daemon needs lives here rather than in the binary, so it is
//! compiled once. `go2rtc` is the one exception: supervising that subprocess
//! is the daemon's own concern and nothing else calls it. Deciding *what* it
//! 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 discovery;
pub mod fleet;
pub mod printer;
pub mod uplink;
+187 -13
View File
@@ -1,11 +1,19 @@
mod config;
// Everything but go2rtc comes from the library crate (src/lib.rs) rather than
// being re-declared here. Declaring `mod printer;` in both places compiles the
// same files twice, once per crate, and the binary copy warned about every
// `pub use` re-export and every method only examples or tests call - "unused"
// is true inside a binary, where nothing is externally reachable, and wrong
// about the library's actual public API. One copy, no false warnings.
mod go2rtc;
mod printer;
mod uplink;
use tracing::info;
use tracing_subscriber::EnvFilter;
use crate::config::Config;
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};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
@@ -14,20 +22,186 @@ async fn main() -> anyhow::Result<()> {
.init();
dotenvy::dotenv().ok();
let config = Config::from_env()?;
// 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");
// Two things run at once for now: the cloud uplink, and the go2rtc
// camera-restreaming process. More will join them later - printer
// discovery, the plate changer - as you build past src/printer/'s
// stubs. `tokio::select!` runs both and stops as soon as either one
// returns; both loop forever below, so in practice this runs until the
// process is killed.
// 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))?;
connect_fleet(&mut fleet).await;
// The fleet is now shared with the uplink rather than just parked here
// keeping connections alive: the uplink reads each printer's status to
// send telemetry up, and looks printers up by id to dispatch commands
// down. `Arc<Mutex<..>>` is how two tasks share one mutable thing -
// see `uplink::SharedFleet` for why that type and what it costs.
let fleet: uplink::SharedFleet = std::sync::Arc::new(tokio::sync::Mutex::new(fleet));
// 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,
// because the person it is for is standing next to the printer. See
// `completion::run`.
//
// The camera builder and the watchdog are two tasks and not one because
// they answer different questions - what should be served, and keeping
// the thing that serves it alive. They meet at one `Notify`: the builder
// rings it after writing a new config, because go2rtc only reads its
// configuration at startup.
//
// `tokio::select!` stops as soon as any returns; all four loop forever,
// so in practice this runs until the process is killed.
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 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()?;
info!(
user = %camera_auth.username,
config = %config.go2rtc_config,
"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()) => {}
_ = go2rtc::watchdog(config.go2rtc_bin.clone(), config.go2rtc_config.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(), outbound.clone(), inbox) => {}
_ = continuum_proxy::discovery::report::run(
config.discovery.clone(),
config.printers_path.clone(),
outbound.clone(),
) => {}
_ = go2rtc::watchdog(
config.go2rtc_bin.clone(),
config.go2rtc_config.clone(),
restart_go2rtc.clone(),
) => {}
_ = camera::run(
fleet.clone(),
config.go2rtc_config.clone(),
config.go2rtc_api_listen.clone(),
camera_auth,
restart_go2rtc.clone(),
) => {}
_ = completion::run(fleet.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;
/// serially, a fleet of twenty would take twenty times as long to start).
/// Logs success/failure per printer instead of failing startup over one -
/// a farm shouldn't refuse to start because one printer is unplugged.
async fn connect_fleet(fleet: &mut [PrinterHandle]) {
let results =
futures_util::future::join_all(fleet.iter_mut().map(|printer| async {
let name = printer.display_name();
(name, printer.connect().await)
}))
.await;
for (name, result) in results {
match result {
Ok(()) => tracing::info!(printer = %name, "connected"),
Err(err) => tracing::warn!(printer = %name, %err, "failed to connect"),
}
}
}
+1408 -153
View File
File diff suppressed because it is too large Load Diff
+14 -4
View File
@@ -7,9 +7,10 @@
//! continuum-schemas for continuum.v1, which is a schema we designed
//! ourselves and that both TS and Rust genuinely need.
//!
//! One file per Bambu *category* (`get_version.rs` for "info", `pushall.rs`
//! + `print.rs` for "pushing"/"print" - see below for why those are two
//! files, not one). Copy `get_version.rs`'s shape for your next command.
//! One file per Bambu *category* (`get_version.rs` for "info",
//! `pushall.rs` + `print.rs` for "pushing"/"print" - see below for why
//! those are two files, not one). Copy `get_version.rs`'s shape for your
//! next command.
//!
//! Mirrors continuum-schemas' `schemas/bambulab/` JSON Schemas - same
//! category split, same "print vs pushing" correction (see that
@@ -25,7 +26,10 @@ mod print;
mod pushall;
pub use get_version::{GetVersionRequest, GetVersionResponse, ModuleInfo};
pub use print::{AmsState, AmsTray, AmsUnit, EmptyTray, LoadedTray, PrintReport, XcamSettings};
pub use print::{
AmsState, AmsTray, AmsUnit, EmptyTray, Ipcam, LightReport, LoadedTray, PrintReport, VtTray,
XcamSettings,
};
pub use pushall::PushallRequest;
/// Fields every Bambu command/response shares. This is the "inheritance"
@@ -49,6 +53,12 @@ pub struct CommandEnvelope {
/// category can have more than one possible request shape - if that ever
/// happens here, that variant's payload becomes its own small
/// `#[serde(tag = "command")]` enum instead of a single struct.
///
/// The "print" category isn't a variant here at all: unlike `info`/
/// `pushing`, it has many possible request shapes (`gcode_line`, `pause`,
/// `resume`, ...) in continuum-schemas' own `oneOf`, so
/// `bambu.rs`'s `send_gcode_line_impl` builds that one directly via
/// `continuum-schema-instance` instead of adding it here.
#[derive(serde::Serialize, Debug)]
#[serde(rename_all = "snake_case")]
pub enum BambuRequest {
+323 -23
View File
@@ -1,11 +1,28 @@
//! The ongoing print-state push (root key "print"). Doesn't carry a
//! `CommandEnvelope` - no `sequence_id`/`command` seen on this one in the
//! real capture, and it may genuinely not have them (an async push, not a
//! reply to a specific request) - revisit if a real capture proves
//! otherwise.
//! The ongoing print-state push (root key "print"). The one real capture
//! this struct was originally built from didn't show `sequence_id`/
//! `command` - turned out to be because that capture was itself a
//! truncated example (it's also missing dozens of other fields the real
//! schema defines), not because the message genuinely lacks them:
//! `push_status.schema.json` composes the shared envelope schema like
//! every other Bambu message, so it does carry `sequence_id` - which
//! matters a lot, see `BambuMqttSession`'s doc comment on how it's used
//! both to correlate a `pushall` reply and to detect a gap in the
//! ongoing organic stream.
use super::CommandEnvelope;
#[derive(serde::Deserialize, Debug)]
#[derive(serde::Deserialize, Debug, Clone)]
pub struct PrintReport {
#[serde(flatten)]
pub envelope: CommandEnvelope,
/// BambuMQTT v1 only: `1` on an automatic/organic push (a partial
/// update - only the fields that changed), `0` on a forced one (a
/// `pushing.pushall`-triggered reply - the full object). This is the
/// actual, reliable signal for which kind a given message is -
/// confirmed against a real printer, unlike trying to infer it from
/// request/reply timing (see `BambuMqttSession`'s `PrintStatusCache`,
/// which used to try that and got it wrong twice). Now declared in
/// `push_status.schema.json` too.
pub msg: Option<i32>,
pub ams: Option<AmsState>,
pub ams_exist_bits: Option<String>,
pub insert_flag: Option<bool>,
@@ -14,57 +31,293 @@ pub struct PrintReport {
pub tray_is_bbl_bits: Option<String>,
pub xcam: Option<XcamSettings>,
pub xcam_status: Option<String>,
// The real payload has a lot more fields than shown here (this
// example was truncated) - add them the same way, one field per line,
// `Option<T>` for anything that isn't always present.
// --- Job progress. The reason this struct exists at all, for a print
// --- farm: a real capture has 97 top-level fields and the nine above
// --- contained none of the ones an operator actually wants.
/// `"IDLE"`, `"RUNNING"`, `"PAUSE"`, `"FINISH"`, `"FAILED"` - observed
/// `IDLE` on an idle P1P and `RUNNING` mid-print on an H2C. Treated as a
/// string rather than an enum deliberately: an unknown state should show
/// up as itself, not fail the whole report.
pub gcode_state: Option<String>,
/// Percent complete, 0-100. v2 also sends an identical `percent`.
pub mc_percent: Option<i32>,
/// Minutes remaining. v2 also sends an identical `remain_time`.
pub mc_remaining_time: Option<i32>,
pub layer_num: Option<i32>,
pub total_layer_num: Option<i32>,
/// Path on the printer, e.g. `/data/Metadata/plate_1.gcode`.
pub gcode_file: Option<String>,
/// The human-facing job name, e.g. a model filename.
pub subtask_name: Option<String>,
/// `0` when nothing is wrong.
pub print_error: Option<i32>,
// --- Temperatures. `f32` throughout, not `i32`: v1 reports an idle
// --- target as the integer `0` while v2 reports `35.0`, and serde
// --- widens an integer into a float but not the reverse.
pub nozzle_temper: Option<f32>,
pub nozzle_target_temper: Option<f32>,
pub bed_temper: Option<f32>,
pub bed_target_temper: Option<f32>,
/// Not sent by every model - absent from the H2C capture.
pub chamber_temper: Option<f32>,
// --- Fans and connectivity.
/// Fan speeds come back as *strings* holding a **0-15 gear**, not a
/// percentage and not the 0-255 PWM value `set_fan_speed` sends. All
/// three scales are in play at once, so converting carelessly gives a
/// number that looks plausible and is wrong:
///
/// - we send PWM 0-255 (`M106 S<pwm>`, see `percent_to_pwm`),
/// - the operator thinks in percent 0-100,
/// - the printer reports gear 0-15.
///
/// Confirmed live on a P1P: a full-speed heatbreak fan reads `"15"`, and
/// `set_fan_speed(50)` (PWM 127) read back as `"7"`. Divide by 15 for a
/// fraction, not by 100 and not by 255.
pub cooling_fan_speed: Option<String>,
pub heatbreak_fan_speed: Option<String>,
pub big_fan1_speed: Option<String>,
pub big_fan2_speed: Option<String>,
pub wifi_signal: Option<String>,
/// Health-management-system entries - the printer's own error/warning
/// list, empty when healthy. Left as raw JSON for now: an H2C mid-print
/// reported several `{attr, code}` pairs whose meaning needs decoding
/// against Bambu's HMS tables before it's worth a typed shape.
pub hms: Option<Vec<serde_json::Value>>,
// --- Stage. Without these a print that is busy preparing is
// --- indistinguishable from one that has stalled.
/// The stage the printer is in right now; `0` is "none in particular"
/// (idle, or actively printing). Confirmed live on a P1P mid-print: it
/// walked `54 -> 14 -> 13 -> 0` while `layer_num` stayed `0` and
/// `mc_percent` climbed to 14%. That is the only signal separating "doing
/// bed levelling and the like before the first layer" from "stuck at
/// layer 0", so a stall detector built on `layer_num` alone would raise
/// a false alarm on every healthy print start. Deliberately a bare
/// number: the code-to-meaning table is Bambu's, and an unknown code
/// should still show up as itself.
pub stg_cur: Option<i32>,
/// The stages planned for this print, in order - `[54, 14, 13]` on the
/// live P1P above, `[]` on an idle one, twelve entries on the H2C
/// fixture. `stg_cur` walks through these.
pub stg: Option<Vec<i32>>,
/// The external spool ("virtual tray"). **Observed on v1 only:** the P1P
/// fixture carries it, the H2C one has no `vt_tray` key at all. That is a
/// genuine generation divergence, not a gap in the capture, so this must
/// stay `Option` - the `tray_temp`/`drying_temp` split on `LoadedTray`
/// is the precedent, and making that one required broke every report from
/// the other generation.
pub vt_tray: Option<VtTray>,
/// What each light is actually doing. This is the read-back for
/// `set_light`, and the only one there is: a `ledctrl` reply says
/// `result: "success"` as soon as the command parses, which on a Bambu
/// means "accepted" and not "the light changed" (the same trap as the
/// print-control verbs). Confirmed on the P1P (2026-10-15) by setting
/// the chamber light on and off and reading
/// `[{"node":"chamber_light","mode":"on"}]` / `"off"` back here - which
/// is the first time `set_light` was shown to do anything at all.
///
/// Observed on v1 despite only the v2 schema declaring it; the v1
/// schema was simply missing it.
pub lights_report: Option<Vec<LightReport>>,
/// The printer's camera settings - and, on the models that have one, the
/// URL of its stream. See `Ipcam`.
pub ipcam: Option<Ipcam>,
// There are still ~60 more top-level fields in a real v2 payload (see
// `tests/fixtures/push_status_v2_h2c_printing.json` for the full set):
// the `job`/`device`/`net`/`upload` sub-objects, queue state, the
// `2D`/`3D` blocks, and so on. Add them the same way - one field per
// line, `Option<T>` unless it was seen on every capture from both
// generations. `merge_from` below needs no edit when you do.
}
impl PrintReport {
/// Merges `newer`'s fields into `self`, in place - BambuMQTT v1's
/// organic `push_status` pushes only carry the fields that changed,
/// so any field `newer` doesn't have (`None`) leaves `self`'s
/// existing value alone instead of clobbering it. Not used for v2
/// (always a full object) or for a `pushall`-triggered reply on
/// either version (also guaranteed full) - see `BambuMqttSession`.
/// Every field below is the same one-liner:
/// `self.x = newer.x.or(self.x.take())`. `Option::or` returns the first
/// `Option` if it's `Some` and the second otherwise, so this reads as
/// "prefer the new value, fall back to what we had" - exactly the merge
/// rule, in the place a TS version would write `newer.x ?? self.x`.
/// `take()` is what makes it work for a `String`/`Vec` field too: it
/// moves the old value out and leaves `None` behind, since the value
/// can't be copied out of `self` and we're overwriting it anyway.
///
/// This replaced two dozen four-line `if newer.x.is_some()` blocks. The
/// point isn't brevity for its own sake - it's that the old shape made
/// adding a field a three-place edit (struct, merge, and remembering to),
/// and forgetting the merge half produced a field that silently never
/// persisted across a partial update.
pub fn merge_from(&mut self, newer: PrintReport) {
self.envelope = newer.envelope;
self.msg = newer.msg;
self.ams = newer.ams.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());
self.tray_exist_bits = newer.tray_exist_bits.or(self.tray_exist_bits.take());
self.tray_is_bbl_bits = newer.tray_is_bbl_bits.or(self.tray_is_bbl_bits.take());
self.xcam = newer.xcam.or(self.xcam.take());
self.xcam_status = newer.xcam_status.or(self.xcam_status.take());
self.gcode_state = newer.gcode_state.or(self.gcode_state.take());
self.mc_percent = newer.mc_percent.or(self.mc_percent.take());
self.mc_remaining_time = newer.mc_remaining_time.or(self.mc_remaining_time.take());
self.layer_num = newer.layer_num.or(self.layer_num.take());
self.total_layer_num = newer.total_layer_num.or(self.total_layer_num.take());
self.gcode_file = newer.gcode_file.or(self.gcode_file.take());
self.subtask_name = newer.subtask_name.or(self.subtask_name.take());
self.print_error = newer.print_error.or(self.print_error.take());
self.nozzle_temper = newer.nozzle_temper.or(self.nozzle_temper.take());
self.nozzle_target_temper = newer.nozzle_target_temper.or(self.nozzle_target_temper.take());
self.bed_temper = newer.bed_temper.or(self.bed_temper.take());
self.bed_target_temper = newer.bed_target_temper.or(self.bed_target_temper.take());
self.chamber_temper = newer.chamber_temper.or(self.chamber_temper.take());
self.cooling_fan_speed = newer.cooling_fan_speed.or(self.cooling_fan_speed.take());
self.heatbreak_fan_speed = newer.heatbreak_fan_speed.or(self.heatbreak_fan_speed.take());
self.big_fan1_speed = newer.big_fan1_speed.or(self.big_fan1_speed.take());
self.big_fan2_speed = newer.big_fan2_speed.or(self.big_fan2_speed.take());
self.wifi_signal = newer.wifi_signal.or(self.wifi_signal.take());
self.hms = newer.hms.or(self.hms.take());
self.stg_cur = newer.stg_cur.or(self.stg_cur.take());
self.stg = newer.stg.or(self.stg.take());
self.vt_tray = newer.vt_tray.or(self.vt_tray.take());
self.lights_report = newer.lights_report.or(self.lights_report.take());
self.ipcam = newer.ipcam.or(self.ipcam.take());
}
}
/// The camera block, which is where a printer says whether it has a stream
/// anything else can connect to.
///
/// Only `rtsp_url` is modelled of the dozen-odd fields in here, because it is
/// the only one that is actionable: the rest are recording and timelapse
/// preferences the printer manages itself.
///
/// **It is absent on most printers, and that is the headline.** Confirmed
/// from live captures of two machines: an H2C reports
/// `rtsps://<ip>:322/streaming/live/1`, while a P1P's block carries
/// `ipcam_dev: "1"`, `ipcam_record: "enable"` and *no URL at all*. So the
/// P1/A1 class has a camera that simply cannot be reached this way - it
/// needs Bambu's proprietary port-6000 JPEG stream, which this crate does
/// not implement. `ipcam_dev: "1"` means "a camera exists", not "a camera
/// you can connect to", and reading it as the latter would have the gateway
/// advertise streams that never play.
#[derive(serde::Deserialize, Debug, Clone)]
pub struct Ipcam {
/// `rtsps://<ip>:322/streaming/live/1` on the models that have it. Note
/// it carries no credentials: a client has to add `bblp:<access_code>`
/// itself.
pub rtsp_url: Option<String>,
}
/// One light's state, as the printer reports it rather than as we asked for
/// it. `mode` is the same vocabulary `ledctrl` accepts (`"on"`, `"off"`,
/// `"flashing"`), and `node` the same light name - so a round-trip is
/// directly comparable, which is the point of having it.
///
/// Both fields optional on principle rather than from evidence: every entry
/// seen on the P1P carried both, but one light in a list is not a sample of
/// every light on every model, and a missing `node` must not cost us the
/// whole report.
#[derive(serde::Deserialize, Debug, Clone)]
pub struct LightReport {
pub node: Option<String>,
pub mode: Option<String>,
}
/// Bambu's own naming has the outer container and the inner array both
/// called `ams` - `print.ams.ams`, not a typo here, just what the printer
/// actually sends.
#[derive(serde::Deserialize, Debug)]
#[derive(serde::Deserialize, Debug, Clone)]
pub struct AmsState {
pub ams: Vec<AmsUnit>,
}
/// One physical AMS unit (Bambu's numbering starts at 0 via `id`).
#[derive(serde::Deserialize, Debug)]
/// One physical AMS unit (Bambu's numbering starts at 0 via `id`). Only
/// `id` and `tray` were present on every unit across both captures, so the
/// rest are optional - an H2C reports three units of different kinds
/// (`n3f`/`n3s`) and they don't all carry the same fields.
#[derive(serde::Deserialize, Debug, Clone)]
pub struct AmsUnit {
pub humidity: String,
pub id: String,
pub temp: String,
pub tray: Vec<AmsTray>,
pub humidity: Option<String>,
pub temp: Option<String>,
pub humidity_raw: Option<String>,
pub dry_time: Option<i32>,
pub info: Option<String>,
}
/// A tray slot is either empty (just `id`) or loaded (the full field set)
/// - two genuinely different shapes, not one shape with optional fields,
/// so this is an untagged enum rather than a struct full of `Option<T>`.
/// A tray slot is either empty (just `id`) or loaded (the full field set) -
/// two genuinely different shapes, not one shape with optional fields, so
/// this is an untagged enum rather than a struct full of `Option<T>`.
/// `#[serde(deny_unknown_fields)]` on `Empty` is load-bearing: untagged
/// enums try variants in order, and without it a *loaded* tray (which has
/// every field `Empty` allows, plus more) would incorrectly match `Empty`
/// first, since a plain struct doesn't reject extra fields by default.
/// Verified this the hard way - see git history.
#[derive(serde::Deserialize, Debug)]
#[derive(serde::Deserialize, Debug, Clone)]
#[serde(untagged)]
pub enum AmsTray {
Empty(EmptyTray),
Loaded(LoadedTray),
}
#[derive(serde::Deserialize, Debug)]
/// `deny_unknown_fields` stays - it's what stops a *loaded* tray (23 fields,
/// a superset of this one) matching `Empty` first. But an empty slot is not
/// always just `{"id": "..."}`: an H2C reports `{"id": "2", "state": 0}` on
/// an empty slot, and that two-field shape matched neither variant, which
/// failed the entire report (and so every `push_status` the printer sent
/// while an AMS slot was empty). Captured in
/// `tests/fixtures/push_status_v2_h2c_printing.json`.
///
/// Anything genuinely new here will fail loudly rather than be quietly
/// dropped, which is the point of keeping `deny_unknown_fields`: better a
/// visible error naming the field than a report that silently vanishes.
#[derive(serde::Deserialize, Debug, Clone)]
#[serde(deny_unknown_fields)]
pub struct EmptyTray {
pub id: String,
/// v2 sends this on an empty slot; v1 omits it entirely.
pub state: Option<i32>,
}
#[derive(serde::Deserialize, Debug)]
/// Captured from both generations (see `tests/push_status_parsing.rs`), and
/// they do **not** agree. A v2 tray carries `drying_temp`/`drying_time`; a
/// v1 tray carries `tray_temp`/`tray_time` and neither `drying_*` field.
/// Every one of these was `String` (required) before, which meant every
/// single v1 push carrying AMS data failed to deserialize - silently, so it
/// looked like the printer simply wasn't sending anything. All four are
/// `Option` now, and so is anything else only one generation was observed
/// to send.
#[derive(serde::Deserialize, Debug, Clone)]
pub struct LoadedTray {
pub id: String,
pub bed_temp: String,
pub bed_temp_type: String,
pub cols: Vec<String>,
pub drying_temp: String,
pub drying_time: String,
/// v2 only.
pub drying_temp: Option<String>,
/// v2 only.
pub drying_time: Option<String>,
/// v1 only - the same idea as `drying_temp` under a different name.
pub tray_temp: Option<String>,
/// v1 only - the same idea as `drying_time` under a different name.
pub tray_time: Option<String>,
pub nozzle_temp_max: String,
pub nozzle_temp_min: String,
pub remain: i32,
@@ -78,11 +331,58 @@ pub struct LoadedTray {
pub tray_uuid: String,
pub tray_weight: String,
pub xcam_info: String,
/// Seen on both, but not in the original truncated example this struct
/// was built from.
pub state: Option<i32>,
pub cali_idx: Option<i32>,
pub ctype: Option<i32>,
pub total_len: Option<i64>,
/// Flow-calibration coefficients - v1 sends these on a loaded tray.
pub k: Option<f32>,
pub n: Option<i32>,
}
/// The external spool. Kept separate from `LoadedTray` rather than shared:
/// the shapes differ (no `cols`/`state`, but `k`/`n`/`cali_idx` are always
/// there), and bending `LoadedTray` to fit would loosen its required fields
/// for AMS trays, which have been seen on both generations. Only `id` is
/// required; the rest come from a single P1P capture, and any of them being
/// required would fail the whole report the first time another model or
/// firmware omits one - the same silent failure `LoadedTray` already hit.
/// Not `deny_unknown_fields`: a new key here should be ignored, not take
/// every `push_status` down with it.
#[derive(serde::Deserialize, Debug, Clone)]
pub struct VtTray {
/// `"254"` on the P1P - Bambu's fixed id for the external spool.
pub id: String,
pub bed_temp: Option<String>,
pub bed_temp_type: Option<String>,
pub nozzle_temp_max: Option<String>,
pub nozzle_temp_min: Option<String>,
pub remain: Option<i32>,
pub tag_uid: Option<String>,
pub tray_color: Option<String>,
pub tray_diameter: Option<String>,
pub tray_id_name: Option<String>,
pub tray_info_idx: Option<String>,
pub tray_sub_brands: Option<String>,
pub tray_type: Option<String>,
pub tray_uuid: Option<String>,
pub tray_weight: Option<String>,
pub xcam_info: Option<String>,
/// v1 spelling; v2 AMS trays call these `drying_temp`/`drying_time`,
/// and it is unknown what a v2 external spool would send.
pub tray_temp: Option<String>,
pub tray_time: Option<String>,
/// Flow-calibration profile index (`-1` when none) and coefficients.
pub cali_idx: Option<i32>,
pub k: Option<f32>,
pub n: Option<i32>,
}
/// Camera-based AI print monitoring settings (spaghetti detection,
/// first-layer inspection, etc.).
#[derive(serde::Deserialize, Debug)]
#[derive(serde::Deserialize, Debug, Clone)]
pub struct XcamSettings {
pub allow_skip_parts: bool,
pub buildplate_marker_detector: bool,
+227
View File
@@ -0,0 +1,227 @@
//! Getting a file *onto* a Bambu printer - the other half of
//! `upload_project_file`, which `bambu.rs` stubbed with a `println!` until
//! now. MQTT can start a print, but it can only name a file the printer
//! already has; moving the bytes there is a completely separate protocol.
//!
//! This is deliberately suppaftp's **blocking** client, not its async one,
//! driven from `tokio::task::spawn_blocking` at the single call site in
//! `bambu.rs`. Two reasons:
//!
//! 1. The blocking client takes a plain `native_tls::TlsConnector`, so this
//! module reuses `BambuTls::build_connector()` exactly as-is and every
//! TLS trust decision for a printer stays in that one method (which has
//! a long doc comment explaining some hard-won specifics - duplicating
//! that reasoning against a second TLS stack is how it drifts).
//! 2. suppaftp's async client needs that second TLS stack
//! (`async-native-tls`), whose futures-vs-tokio `AsyncRead` mismatch has
//! already cost this project one debugging session.
//!
//! **Unverified against real hardware.** The port, the username, and
//! implicit TLS are well-established for Bambu LAN mode, and the TLS layer
//! itself is the same one `test_tls_handshake` already proves against a
//! real printer on 8883. But no physical printer has accepted an upload
//! from *this* code yet. Expect the first live run to be where the
//! remaining details get settled - see `bambu.rs::upload_project_file` for
//! the list of specific things to watch.
use std::io::Cursor;
use suppaftp::types::FileType;
use suppaftp::{NativeTlsConnector, NativeTlsFtpStream};
/// Bambu LAN mode speaks *implicit* FTPS: TLS starts the instant the socket
/// opens, with no plaintext `AUTH TLS` negotiation first. That's the older
/// of the two FTPS styles and formally deprecated, which is exactly why
/// suppaftp hides `connect_secure_implicit` behind a `deprecated` cargo
/// feature. The printers only speak this, so the feature is switched on in
/// `Cargo.toml`.
const FTPS_PORT: u16 = 990;
/// The same for every Bambu printer in LAN mode. The password is the same
/// access code MQTT authenticates with - one shared secret, two protocols.
const FTPS_USER: &str = "bblp";
/// Storage the printer's own firmware owns, and the directory Bambu Studio
/// uploads to as well. A `project_file`/`gcode_file` MQTT command can only
/// print something that already lives here.
pub const CACHE_DIR: &str = "cache";
/// The SD card's root, which is where an FTPS login lands and - confirmed
/// live on a P1P, 2026-10-11 - the only directory a `print.project_file`
/// command has been seen to print from.
///
/// The card's two interesting directories hold different things, and a
/// listing of the real P1P is the clearest way to say which:
///
/// - the root holds 43 `<name>.gcode.3mf` files, the printable project
/// archives Bambu Studio sends when it uploads a plate;
/// - `cache/` holds 20 `<project>_plate_N.gcode` files with
/// `<N>_<project>.gcode.bbl` sidecars - the printer's own extracted
/// per-plate working copies.
///
/// Those extracted copies look like the obvious thing to print and are not:
/// `print.gcode_file` with `param: "/cache/Cube_plate_1.gcode"` was
/// rejected (`result: "fail"`) for a file that demonstrably exists, while
/// `print.project_file` with `url: "ftp:///Cube.gcode.3mf"` against the
/// root started a print. So uploads go here, not into `cache/`.
pub const SD_ROOT: &str = "/";
/// Blocking: opens an implicit-FTPS session to `host`, logs in with
/// `access_code`, and writes `data` to `cache/{file_name}`. Returns however
/// many bytes the server reported accepting.
///
/// Takes an already-built `connector` rather than a `&BambuTls` so the
/// caller resolves trust on the async side and this function can be moved
/// wholesale onto a blocking thread - `native_tls::TlsConnector` is `Send`,
/// which is what makes that legal.
pub fn upload(
host: &str,
access_code: &str,
connector: native_tls::TlsConnector,
dir: &str,
file_name: &str,
data: &[u8],
) -> anyhow::Result<u64> {
let mut ftp = NativeTlsFtpStream::connect_secure_implicit(
(host, FTPS_PORT),
NativeTlsConnector::from(connector),
host,
)?;
ftp.login(FTPS_USER, access_code)?;
// A .3mf is a zip archive and a .gcode has meaningful line endings -
// either way, letting the server treat the stream as text is free
// corruption.
ftp.transfer_type(FileType::Binary)?;
// `SD_ROOT` means "don't change directory at all" - the FTPS login
// already lands in the card's root, and `cwd("/")` is a needless round
// trip that some servers answer less predictably than no request.
if dir != SD_ROOT {
ftp.cwd(dir)?;
}
// `put_file` wants something that implements `Read`. `data` is already
// bytes in memory (see `GenericPrinter::upload_project_file`'s doc
// comment for why it's bytes and not a path), and `Cursor` is the
// standard way to let something read from a slice - roughly wrapping a
// Buffer in a stream in Node.
let mut reader = Cursor::new(data);
let written = ftp.put_file(file_name, &mut reader)?;
// Best-effort: the bytes are already delivered and acknowledged, so
// failing to say goodbye politely isn't worth failing the upload over.
let _ = ftp.quit();
Ok(written)
}
/// Blocking: reads one file off the printer's SD card.
///
/// Read-only. The reason this exists: a `.3mf` already on the card is the
/// only record of which filament each of its plates was sliced for, and
/// `ams_mapping` cannot be built without that - see
/// `schemas/bambulab/v1/common/ams/ams_mapping.schema.json`. Printing a
/// project with the wrong mapping is not a cosmetic error; it draws the
/// wrong filament, or none.
pub fn download(
host: &str,
access_code: &str,
connector: native_tls::TlsConnector,
path: &str,
) -> anyhow::Result<Vec<u8>> {
let mut ftp = NativeTlsFtpStream::connect_secure_implicit(
(host, FTPS_PORT),
NativeTlsConnector::from(connector),
host,
)?;
ftp.login(FTPS_USER, access_code)?;
// A `.3mf` is a zip archive; letting the server treat it as text is free
// corruption, exactly as on the way up.
ftp.transfer_type(FileType::Binary)?;
// `retr_as_buffer` rather than `retr_as_stream`: suppaftp 12's
// `TransferStream` finalizes itself on drop, and a project archive is
// read whole anyway - `plate_filaments` needs the central directory at
// its end before it can read anything.
//
// Confirmed fine at 4.96 MB on the P1P (2026-10-15, byte-identical
// round trip). The qualifier here used to be "a few hundred KB", which
// the card itself disproves - the largest project on it is 62 MB, and
// that one would be held in memory twice during a round trip. Streaming
// is the answer if that ever matters; it has not yet.
let bytes = ftp.retr_as_buffer(path)?.into_inner();
let _ = ftp.quit();
Ok(bytes)
}
/// Blocking: removes one file from the card.
///
/// The counterpart to `upload`, and the only reason it did not exist before
/// is that nothing had uploaded anything worth removing. It is needed now:
/// the P1P's card already holds 45 project archives and 47 extracted
/// `cache/` entries, so a gateway that uploads a project per job and never
/// removes one is a gateway that eventually fills a customer's SD card and
/// starts failing prints for a reason nobody will connect to this code.
///
/// Deliberately takes one explicit path and does no globbing or recursion:
/// the obvious convenience here ("clear the old ones") is also the obvious
/// way to delete a file somebody printed from last week.
pub fn delete(
host: &str,
access_code: &str,
connector: native_tls::TlsConnector,
path: &str,
) -> anyhow::Result<()> {
let mut ftp = NativeTlsFtpStream::connect_secure_implicit(
(host, FTPS_PORT),
NativeTlsConnector::from(connector),
host,
)?;
ftp.login(FTPS_USER, access_code)?;
ftp.rm(path)?;
let _ = ftp.quit();
Ok(())
}
/// Blocking: lists `cache/` on the printer.
///
/// Read-only, and the counterpart to `upload`. The previous version of this
/// comment said a `project_file` command "can only print something that is
/// already in this directory", which is wrong twice over: the printable
/// projects are in the root (see `SD_ROOT`), and this directory's contents
/// were rejected when asked for directly. It is still how you find a real
/// slicer-produced `.3mf`
/// to test against, instead of hand-building one and then not knowing
/// whether a failure was the command or the file.
///
/// Returns the server's raw LIST lines rather than parsed entries: Bambu's
/// FTPS server formats them in the usual unix style, but the parsing is the
/// caller's problem until something needs more than the file names.
pub fn list(
host: &str,
access_code: &str,
connector: native_tls::TlsConnector,
dir: &str,
) -> anyhow::Result<Vec<String>> {
let mut ftp = NativeTlsFtpStream::connect_secure_implicit(
(host, FTPS_PORT),
NativeTlsConnector::from(connector),
host,
)?;
ftp.login(FTPS_USER, access_code)?;
ftp.transfer_type(FileType::Binary)?;
let entries = ftp.list(Some(dir))?;
let _ = ftp.quit();
Ok(entries)
}
+511
View File
@@ -0,0 +1,511 @@
//! The real MQTT wiring for Bambu printers - the piece `connect`/
//! `send_gcode_line` in `bambu.rs` used to just stub out with a
//! `println!`. Three things make this more than "call `publish`, get a
//! `String` back":
//!
//! 1. MQTT v3.1.1 (BambuMQTT v1) and MQTT v5 (BambuMQTT v2) are two
//! entirely separate rumqttc client types (`rumqttc::AsyncClient` vs
//! `rumqttc::v5::AsyncClient`) with different event/error types -
//! `BambuMqttClient` is the enum that hides which one a connection
//! ended up using, same pattern as `PrinterHandle` hides which vendor
//! struct you're holding.
//! 2. A response to a published request doesn't come back from that same
//! call - it arrives later, asynchronously, on a *subscription* to
//! `device/{sn}/report`. That needs a background task continuously
//! polling the connection (rumqttc doesn't do this for you - nothing
//! happens until you poll the `EventLoop` yourself, repeatedly,
//! forever) - this is the first real use of `tokio::spawn` in this
//! crate, flagged as deferred since `printer/mod.rs`'s very first doc
//! comment, and this is why: it only makes sense once there's a
//! long-running background job that has to run independently of
//! whatever call site is waiting on a response.
//!
//! Correlating a reply to its request isn't as uniform as it looks -
//! confirmed live, not assumed. `info.get_version`'s reply echoes back
//! the `sequence_id` you sent; `request` relies on that. A tempting
//! generalization - add your own extra field instead of `sequence_id`,
//! since most replies are supposed to echo back whatever extra fields
//! a request carried - turned out false for this exact command: a real
//! H2C's `get_version` reply had precisely `["command", "module",
//! "sequence_id"]`, nothing else, so a homemade correlation field never
//! survived and every request timed out. `sequence_id` - proven to
//! round-trip, not just assumed to - is what's actually used.
//! `print.push_status` doesn't echo `sequence_id` either (the printer
//! stamps that with its own free-running counter regardless of what
//! triggered it) - but it was never a request/reply case to begin with
//! (`pushing.pushall` triggers an ongoing *stream*, not a single
//! reply) - see `PrintStatusCache` for how that's handled instead.
//! 3. `print.push_status` also needs to be kept as running in-memory
//! state, not just handed to whoever happens to be waiting at the
//! moment it arrives - see `PrintStatusCache` below for why (short
//! version: v1 only ever sends partial updates, so something has to
//! remember what the fields *were* to produce a coherent whole
//! object).
//!
//! Unverified against a live printer (no way to force it in a test): the
//! exact `ConnectReturnCode`/error shape a real Bambu printer's embedded
//! broker gives when it doesn't understand MQTT v5's CONNECT at all
//! (rather than a clean version-rejection CONNACK) - `open()` below
//! treats *any* failure of the v5 attempt as "fall back to v3.1.1", not
//! just a precisely-matched version-rejection code, since a broker that
//! can't parse a v5 CONNECT packet may just drop the connection instead
//! of replying with one.
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use rumqttc::v5::mqttbytes::v5::Packet as PacketV5;
use rumqttc::v5::mqttbytes::QoS as QoSV5;
use rumqttc::{Event, Packet, QoS, Transport, TlsConfiguration};
use serde_json::Value;
use tokio::sync::{oneshot, Mutex};
use super::bambu::BambuTls;
use super::bambu_commands::PrintReport;
use super::BambuProtocolVersion;
use super::PrinterError;
/// Requests waiting on a report with a matching `sequence_id` - only
/// valid for a command empirically confirmed to echo it back, like
/// `get_version` (see this module's doc comment - a homemade correlation
/// field was tried instead and confirmed *not* to survive on this
/// command's reply). Shared between whichever call registered one (in
/// `request`) and the background poll task that completes it.
type PendingTable = Arc<Mutex<HashMap<String, oneshot::Sender<Value>>>>;
const REQUEST_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
/// (25625 bytes - it reports a lot of modules: dual AMS, AMS HT, six WTM
/// units, ...) blew straight through that limit and killed the whole
/// connection outright (a real MQTT v5 protocol error, not a timeout).
/// `print.push_status` reports (full AMS/xcam state) can get large too.
/// 1MB has real headroom for that.
const MAX_PACKET_SIZE: usize = 1024 * 1024;
/// Every `sequence_id` this crate generates gets this prefix - a printer
/// runs its own independent counter for unsolicited messages (confirmed
/// live: `print.push_status`'s was already in the 500s at the start of a
/// session, climbing steadily), and that counter has only ever been
/// observed as a plain unprefixed integer string. An earlier version of
/// this just started our own counter from a large offset instead of
/// prefixing it, to dodge the same range a printer's counter climbs
/// through - confirmed live (with an ISO-timestamp-prefixed sequence_id)
/// that `sequence_id` isn't required to be numeric at all, so a
/// non-numeric prefix rules out collision outright instead of merely
/// making it improbable, and the counter itself can go back to starting
/// at 1 each session.
const SEQUENCE_ID_PREFIX: &str = "cxproxy-";
enum BambuMqttClient {
V1(rumqttc::AsyncClient),
V2(rumqttc::v5::AsyncClient),
}
/// The running in-memory `print.push_status` object, plus enough state to
/// know how to fold the next update into it:
///
/// - BambuMQTT v2 sends the *entire* object every time - always replace.
/// - BambuMQTT v1 only sends the fields that changed since last time -
/// merge into the existing object, or the picture goes incomplete the
/// instant anything not present in the latest push gets treated as
/// "unset". The exception is a `pushing.pushall`-triggered push, which
/// is always the full object - and v1 self-describes which kind a given
/// message is via its own `msg` field (`1` automatic/partial, `0`
/// forced/full), confirmed against a real printer. `push_status` also
/// doesn't echo `sequence_id` (see this module's doc comment), so it
/// couldn't be identified via `request`'s correlation even if the
/// timing worked out; `msg` needs none of that: it's on the message
/// itself, correct regardless of which client (or nothing at all)
/// triggered it, and regardless of timing.
/// - **v2 does not use `sequence_id` as a counter here at all.** Confirmed
/// live against an H2C mid-print on 2026-10-03: every organic
/// `push_status` carried the *same* `sequence_id` (`"2021"`) for minutes
/// on end while the contents (percent, layer, remaining time) changed
/// normally. So on v2 it identifies nothing and orders nothing - don't
/// use it to detect staleness or to deduplicate. The gap check below
/// already skips v2 (every v2 message is a full object), which turns out
/// to be necessary rather than merely tidy: it would otherwise warn on
/// every single message.
/// - `last_sequence_id` exists to notice when v1's organic stream skipped
/// an update (network hiccup, a message this client wasn't fast enough
/// to read): if the incoming `sequence_id` isn't exactly one more than
/// the last one seen, the merged object is missing whatever changed in
/// the gap. There's no way to know what that baseline number even is
/// ahead of time - the printer runs its own counter, not one this
/// client started - so the very first `push_status` received after
/// connecting just seeds `last_sequence_id` rather than being checked
/// against anything.
struct PrintStatusCache {
report: Option<PrintReport>,
last_sequence_id: Option<u64>,
}
impl PrintStatusCache {
fn new() -> Self {
Self { report: None, last_sequence_id: None }
}
fn apply(&mut self, incoming: PrintReport, version: BambuProtocolVersion) {
let incoming_seq = incoming.envelope.sequence_id.parse::<u64>().ok();
// A forced/full update isn't missing anything by definition, so a
// sequence gap right after one (or on v2, where every message is
// full) isn't a real problem worth warning about.
let is_full_object = matches!(version, BambuProtocolVersion::V2) || incoming.msg != Some(1);
// Only reachable for v1 partial updates. Historically this fired
// constantly and blamed the network, when the real cause was local:
// a push that failed to deserialize never reached this function, so
// the next one that did looked like it had skipped ahead. That is now
// reported where it happens (see `handle_incoming_report`), which
// leaves this warning meaning what it says.
if let (Some(last), Some(incoming_seq), false) = (self.last_sequence_id, incoming_seq, is_full_object)
&& incoming_seq != last.wrapping_add(1)
{
eprintln!(
"print.push_status sequence gap: expected {}, got {incoming_seq} - the in-memory \
object may be missing whatever changed in between",
last.wrapping_add(1)
);
}
if let Some(incoming_seq) = incoming_seq {
self.last_sequence_id = Some(incoming_seq);
}
match (&mut self.report, is_full_object) {
(_, true) => self.report = Some(incoming),
(Some(existing), false) => existing.merge_from(incoming),
(None, false) => self.report = Some(incoming),
}
}
}
/// One live MQTT connection to a Bambu printer - whichever protocol
/// version `open()` settled on.
pub struct BambuMqttSession {
client: BambuMqttClient,
pending: PendingTable,
print_status: Arc<Mutex<PrintStatusCache>>,
sn: String,
next_sequence_id: AtomicU64,
}
impl BambuMqttSession {
/// `ForceV1`/`ForceV2` connect with that protocol directly. `Auto`
/// tries v5 first and falls back to v3.1.1 if that attempt fails -
/// see this module's doc comment for why "any failure", not just a
/// precisely-matched version-rejection code.
pub async fn open(
host: &str,
access_code: &str,
sn: &str,
tls: &BambuTls,
preference: super::bambu::BambuProtocolPreference,
) -> Result<(Self, BambuProtocolVersion), PrinterError> {
use super::bambu::BambuProtocolPreference as Pref;
match preference {
Pref::ForceV1 => {
let session = Self::open_v1(host, access_code, sn, tls).await?;
Ok((session, BambuProtocolVersion::V1))
}
Pref::ForceV2 => {
let session = Self::open_v2(host, access_code, sn, tls).await?;
Ok((session, BambuProtocolVersion::V2))
}
Pref::Auto => match Self::open_v2(host, access_code, sn, tls).await {
Ok(session) => Ok((session, BambuProtocolVersion::V2)),
Err(_) => {
let session = Self::open_v1(host, access_code, sn, tls).await?;
Ok((session, BambuProtocolVersion::V1))
}
},
}
}
async fn open_v1(host: &str, access_code: &str, sn: &str, tls: &BambuTls) -> Result<Self, PrinterError> {
let connector = tls.build_connector().map_err(|err| PrinterError(format!("TLS setup failed: {err}")))?;
let mut options = rumqttc::MqttOptions::new(format!("continuum-{sn}"), host, 8883);
options.set_credentials("bblp", access_code);
options.set_transport(Transport::tls_with_config(TlsConfiguration::NativeConnector(connector)));
options.set_keep_alive(Duration::from_secs(30));
options.set_max_packet_size(MAX_PACKET_SIZE, MAX_PACKET_SIZE);
let (client, mut event_loop) = rumqttc::AsyncClient::new(options, 10);
client
.subscribe(format!("device/{sn}/report"), QoS::AtMostOnce)
.await
.map_err(|err| PrinterError(format!("subscribe failed: {err}")))?;
// Nothing actually happens on the wire until the eventloop is
// polled - this first poll is what makes the TCP+TLS+CONNECT
// handshake happen, and its result is the only way to know
// whether it worked.
match event_loop.poll().await {
Ok(_) => {}
Err(err) => return Err(PrinterError(format!("MQTT v3.1.1 connect failed: {err}"))),
}
let pending: PendingTable = Arc::new(Mutex::new(HashMap::new()));
let print_status = Arc::new(Mutex::new(PrintStatusCache::new()));
let sn_owned = sn.to_string();
tokio::spawn(poll_loop_v1(event_loop, pending.clone(), print_status.clone(), BambuProtocolVersion::V1, sn_owned));
Ok(Self {
client: BambuMqttClient::V1(client),
pending,
print_status,
sn: sn.to_string(),
next_sequence_id: AtomicU64::new(1),
})
}
async fn open_v2(host: &str, access_code: &str, sn: &str, tls: &BambuTls) -> Result<Self, PrinterError> {
let connector = tls.build_connector().map_err(|err| PrinterError(format!("TLS setup failed: {err}")))?;
let mut options = rumqttc::v5::MqttOptions::new(format!("continuum-{sn}"), host, 8883);
options.set_credentials("bblp", access_code);
options.set_transport(Transport::tls_with_config(TlsConfiguration::NativeConnector(connector)));
options.set_keep_alive(Duration::from_secs(30));
options.set_max_packet_size(Some(MAX_PACKET_SIZE as u32));
let (client, mut event_loop) = rumqttc::v5::AsyncClient::new(options, 10);
client
.subscribe(format!("device/{sn}/report"), QoSV5::AtMostOnce)
.await
.map_err(|err| PrinterError(format!("subscribe failed: {err}")))?;
match event_loop.poll().await {
Ok(_) => {}
Err(err) => return Err(PrinterError(format!("MQTT v5 connect failed: {err}"))),
}
let pending: PendingTable = Arc::new(Mutex::new(HashMap::new()));
let print_status = Arc::new(Mutex::new(PrintStatusCache::new()));
let sn_owned = sn.to_string();
tokio::spawn(poll_loop_v2(event_loop, pending.clone(), print_status.clone(), BambuProtocolVersion::V2, sn_owned));
Ok(Self {
client: BambuMqttClient::V2(client),
pending,
print_status,
sn: sn.to_string(),
next_sequence_id: AtomicU64::new(1),
})
}
/// Publishes `body` under `device/{sn}/request`, wrapped as
/// `{category: body}` (Bambu's top-level key names the *category*,
/// e.g. "print"/"info" - `body`'s own `command` field says which
/// specific one), and waits up to `REQUEST_TIMEOUT` for a report
/// whose `sequence_id` matches the one this call sent.
///
/// This crate tried adding its own correlation field
/// (`continuum_correlation_id`) instead of relying on `sequence_id`, on
/// the understanding that extra fields survive into most replies.
/// Confirmed live that this doesn't hold for `get_version`
/// specifically - a real H2C's reply had exactly `["command", "module",
/// "sequence_id"]`, nothing extra at all, so the correlation field
/// never came back and every `get_version` call just timed out.
/// `sequence_id` is what's actually proven to round-trip for it
/// (confirmed earlier, live, on both protocol versions), so that's what
/// this uses. Whether a custom field survives on some *other* command
/// (`calibration`, say) is unverified, not confirmed false - this was
/// only ever checked for `get_version`.
///
/// Not for `print.push_status` - that's not a single reply, see
/// `PrintStatusCache`. Not for a command with no reply at all either,
/// like `gcode_line` - use `publish` (fire-and-forget) for those.
pub async fn request(&self, category: &str, mut body: Value) -> Result<Value, PrinterError> {
let sequence_id = format!(
"{SEQUENCE_ID_PREFIX}{}",
self.next_sequence_id.fetch_add(1, Ordering::Relaxed)
);
body["sequence_id"] = Value::String(sequence_id.clone());
let (tx, rx) = oneshot::channel();
self.pending.lock().await.insert(sequence_id.clone(), tx);
if let Err(err) = self.publish(category, &body).await {
self.pending.lock().await.remove(&sequence_id);
return Err(err);
}
match tokio::time::timeout(REQUEST_TIMEOUT, rx).await {
Ok(Ok(response)) => Ok(response),
Ok(Err(_)) => Err(PrinterError(format!("{} closed before replying", self.sn))),
Err(_) => {
self.pending.lock().await.remove(&sequence_id);
Err(PrinterError(format!("{} did not reply within {REQUEST_TIMEOUT:?}", self.sn)))
}
}
}
/// Publishes `body` under `device/{sn}/request`, wrapped as
/// `{category: body}`, without waiting for a reply - for commands
/// like `gcode_line` and `pushing.pushall` that don't get a direct
/// correlated reply (an implicit ack the printer just executes, or an
/// ongoing stream instead of a single answer, respectively).
pub async fn publish(&self, category: &str, body: &Value) -> Result<(), PrinterError> {
self.publish_raw(serde_json::json!({ category: body }).to_string()).await
}
/// Publishes an already-serialized payload under `device/{sn}/request`
/// as-is, with no category wrapping - the low-level primitive both
/// `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> {
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}"))),
}
}
/// The current in-memory `print.push_status` object, if at least one
/// has arrived since connecting - `None` until then. Cloned out from
/// behind the lock rather than handing out a guard, so callers aren't
/// holding the poll loop's own lock while they use it.
pub async fn print_status(&self) -> Option<PrintReport> {
self.print_status.lock().await.report.clone()
}
}
/// Runs forever, polling the v3.1.1 eventloop and routing incoming
/// reports (pending-request correlation, `print.push_status` caching).
/// Exits - loudly, not silently like before (that silence is exactly
/// what made a real dropped connection to a live H2C look identical to
/// "the printer just isn't sending anything") - once the connection is
/// gone for good. Dropping the `BambuMqttSession` drops its `AsyncClient`,
/// which is what eventually makes that happen during normal shutdown;
/// this also fires on an unexpected disconnect, which is the case this
/// logging is actually for. No reconnect logic yet - that's the next
/// thing to add here, not something to fake by pretending the loop
/// didn't end.
async fn poll_loop_v1(
mut event_loop: rumqttc::EventLoop,
pending: PendingTable,
print_status: Arc<Mutex<PrintStatusCache>>,
version: BambuProtocolVersion,
sn: String,
) {
loop {
match event_loop.poll().await {
Ok(Event::Incoming(Packet::Publish(publish))) => {
handle_incoming_report(&sn, &pending, &print_status, version, &publish.payload).await;
}
Ok(_) => {}
Err(err) => {
eprintln!("[{sn}] MQTT v3.1.1 connection lost, no longer receiving reports: {err}");
return;
}
}
}
}
async fn poll_loop_v2(
mut event_loop: rumqttc::v5::EventLoop,
pending: PendingTable,
print_status: Arc<Mutex<PrintStatusCache>>,
version: BambuProtocolVersion,
sn: String,
) {
loop {
match event_loop.poll().await {
Ok(rumqttc::v5::Event::Incoming(PacketV5::Publish(publish))) => {
handle_incoming_report(&sn, &pending, &print_status, version, &publish.payload).await;
}
Ok(_) => {}
Err(err) => {
eprintln!("[{sn}] MQTT v5 connection lost, no longer receiving reports: {err}");
return;
}
}
}
}
/// Parses an incoming report, unwraps its single category key (a report
/// is `{category: {"sequence_id": "...", ...}}`, same shape a request is
/// published as - `request` publishes `body` and wants `body`-shaped data
/// back, not the wrapper around it), and:
///
/// 1. If its `sequence_id` matches something `request` is waiting on,
/// hands it the unwrapped body - but doesn't wake that waiter yet
/// (see 3 for why).
/// 2. If the unwrapped body is a `print.push_status`, folds it into the
/// running `PrintStatusCache` (which reads the message's own `msg`
/// field to decide full-vs-merge, not anything to do with step 1 - see
/// `PrintStatusCache::apply`'s doc comment for why not).
/// 3. Only now - once the cache is guaranteed up to date - actually sends
/// on whichever oneshot matched in step 1, waking `request`. The order
/// of 2 and 3 is load-bearing, caught live on an earlier version of
/// this mechanism: sending on the oneshot first let a waiter's task
/// resume and check `print_status()` before this function had gotten
/// around to updating it. Nothing here awaits between the cache write
/// and the send, so that race stays closed.
///
/// A report matching nothing pending isn't an error - most Bambu reports
/// (including every organic `push_status` push) aren't a reply to
/// anything specific.
async fn handle_incoming_report(
sn: &str,
pending: &PendingTable,
print_status: &Arc<Mutex<PrintStatusCache>>,
version: BambuProtocolVersion,
payload: &[u8],
) {
// The raw bytes, before anything gets parsed or thrown away. `PrintReport`
// deliberately models only a fraction of what a real `push_status`
// carries (see its doc comment), so without this the rest is
// unrecoverable - you can't add a field to that struct if you can never
// see what the printer actually sent. At `trace` so it costs nothing
// normally; turn it on with
// `RUST_LOG=continuum_proxy::printer::bambu_mqtt=trace`.
tracing::trace!(target: "continuum_proxy::printer::bambu_mqtt", sn, raw = %String::from_utf8_lossy(payload), "incoming report");
let Ok(value) = serde_json::from_slice::<Value>(payload) else { return };
let Some(object) = value.as_object() else { return };
let Some((category, body)) = object.iter().next() else { return };
let request_sender = match body.get("sequence_id").and_then(Value::as_str) {
Some(sequence_id) => pending.lock().await.remove(sequence_id),
None => None,
};
if category == "print" && body.get("command").and_then(Value::as_str) == Some("push_status") {
match serde_json::from_value::<PrintReport>(body.clone()) {
Ok(report) => print_status.lock().await.apply(report, version),
// Loudly, not silently. This arm used to be an `if let Ok(..)`
// with no `else`, and it cost two misdiagnosed symptoms on real
// hardware: `print_status()` stayed `None` forever on an H2C
// that was in fact pushing a full report every second (read as
// "v2 doesn't push unprompted", which was wrong), and every v1
// push carrying AMS data vanished - which the gap check below
// then reported as a *sequence gap*, blaming the network for a
// local deserialization bug. A report this process can't parse
// is a real defect in `PrintReport`, and the only way to fix it
// is to be told which field.
Err(err) => eprintln!(
"[{sn}] print.push_status (sequence_id {}) did not match PrintReport, dropping it: \
{err} - add the field to PrintReport and a fixture to \
tests/push_status_parsing.rs",
body.get("sequence_id").and_then(Value::as_str).unwrap_or("?")
),
}
}
if let Some(sender) = request_sender {
let _ = sender.send(body.clone());
}
}
+35
View File
@@ -13,6 +13,20 @@ impl KlipperPrinter {
}
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> {
let endpoint = match action {
super::PrintControl::Pause => "pause",
super::PrintControl::Resume => "resume",
super::PrintControl::Stop => "cancel",
};
println!("[{}] POST /printer/print/{endpoint}", self.base.name);
Ok(())
}
fn base(&self) -> &PrinterBase {
&self.base
}
@@ -29,6 +43,11 @@ impl GenericPrinter for KlipperPrinter {
Ok(())
}
async fn publish(&mut self, _payload: String) -> Result<(), PrinterError> {
// TODO: add publishing via HTTP
Ok(())
}
/// 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:
@@ -37,4 +56,20 @@ impl GenericPrinter for KlipperPrinter {
println!("[{}] Moonraker: SET_FAN_SPEED SPEED={:.2}", self.base.name, percent as f32 / 100.0);
Ok(())
}
/// 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.
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());
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(())
}
}
+69
View File
@@ -0,0 +1,69 @@
//! A held-open serial connection to Marlin/RepRap-family firmware - usable
//! by any printer connected over a raw serial port, since there's no
//! printer-side storage to bulk-upload to the way there is for Bambu/
//! PrusaLink/Klipper (see `prusa.rs`'s `PrusaSerialPrinter`).
//!
//! Marlin only ever buffers a handful of commands at once, so lines have
//! to be paced by its own "ok" acknowledgment - send faster than it acks
//! and you overrun that buffer, you don't just queue up harmlessly. That
//! write-a-line/wait-for-"ok" pattern is the one new idea here. Splitting
//! the connection into a read half and a write half so both can be used at
//! once isn't new - `uplink/mod.rs` already does the same thing
//! (`ws_stream.split()`) for the WebSocket to `continuum-backend`.
//!
//! The connection has to stay open across many `send_line` calls, not
//! reopen per line: opening/closing per line would be wasteful, and on
//! Arduino-based boards toggling the serial line's DTR signal (which
//! opening the port does) resets the printer.
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines, ReadHalf, WriteHalf};
use tokio_serial::{SerialPortBuilderExt, SerialStream};
use super::PrinterError;
pub struct MarlinSerialConnection {
write_half: WriteHalf<SerialStream>,
lines_in: Lines<BufReader<ReadHalf<SerialStream>>>,
}
impl MarlinSerialConnection {
/// Opens `port` (a serial device path - e.g. `/dev/ttyACM0` on Linux) at
/// `baud_rate`.
pub async fn open(port: &str, baud_rate: u32) -> Result<Self, PrinterError> {
let stream = tokio_serial::new(port, baud_rate)
.open_native_async()
.map_err(|err| PrinterError(format!("failed to open {port}: {err}")))?;
// Same shape as `ws_stream.split()` in uplink/mod.rs: one
// connection, used for writing gcode out and reading
// acknowledgments back at the same time.
let (read_half, write_half) = tokio::io::split(stream);
let lines_in = BufReader::new(read_half).lines();
Ok(Self { write_half, lines_in })
}
/// Sends one gcode line and waits for Marlin's "ok" before returning.
pub async fn send_line(&mut self, line: &str) -> Result<(), PrinterError> {
self.write_half
.write_all(format!("{line}\n").as_bytes())
.await
.map_err(|err| PrinterError(format!("write failed: {err}")))?;
// Marlin also sends unsolicited lines between commands
// (temperature reports, "echo:" messages) - keep reading until the
// real "ok" shows up instead of treating the first response as it.
loop {
let response = self
.lines_in
.next_line()
.await
.map_err(|err| PrinterError(format!("read failed: {err}")))?
.ok_or_else(|| PrinterError("serial connection closed".into()))?;
if response.starts_with("ok") {
return Ok(());
}
}
}
}
+227 -35
View File
@@ -2,12 +2,10 @@
//! `GenericPrinter -> BambuV1/BambuV2/PrusaLink/PrusaSerial/Klipper`-style
//! inheritance, since Rust structs can't `extend` each other.
//!
//! Bambu goes one level deeper than the others: `bambu.rs` has
//! `BambuGenericPrinter` (fields + behavior every Bambu printer shares -
//! access code, CA trust, the TLS handshake test/fetch) which
//! `BambuV1Printer`/`BambuV2Printer` each *have* one of, mirroring the
//! `GenericPrinter -> BambuGenericPrinter -> BambuV1/V2` chain this project
//! actually wanted from the start - see that file's doc comment.
//! Bambu is a single `BambuPrinter` type, not a per-version split: which
//! MQTT/BambuMQTT protocol generation (v1 vs v2) a given printer uses is
//! something `connect()` discovers (or is told to force), not something
//! config picks a *type* for - see `bambu.rs`'s doc comment.
//!
//! Three pieces, each doing one job an OOP base class would normally do:
//!
@@ -23,23 +21,39 @@
//!
//! Run `cargo run --example printer_polymorphism` to see it in action.
//!
//! Deliberately left out of this module for now, to keep it focused on one
//! idea at a time: real MQTT/HTTP/FTPS calls (that's `src/adapters/`) and
//! `Send`-bound futures (matters once this is wired into a multi-threaded
//! `tokio::spawn`, not yet).
//! 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.
mod bambu;
mod bambu_commands;
mod bambu_ftps;
mod bambu_mqtt;
mod klipper;
mod marlin_serial;
mod prusa;
pub use bambu::{BambuGenericPrinter, BambuTls, BambuV1Printer, BambuV2Printer};
// 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,
ProjectFileError,
};
pub use bambu::{
BambuPrinter, BambuProtocolPreference, BambuProtocolVersion, BambuTls, FilamentSource,
LightState, CHAMBER_LIGHT,
};
pub use bambu_commands::{
AmsState, AmsTray, AmsUnit, BambuReport, BambuRequest, CommandEnvelope, EmptyTray, GetVersionRequest,
GetVersionResponse, LoadedTray, PrintReport, PushallRequest, XcamSettings,
GetVersionResponse, Ipcam, LightReport, LoadedTray, ModuleInfo, PrintReport, PushallRequest, VtTray,
XcamSettings,
};
pub use klipper::KlipperPrinter;
pub use prusa::{PrusaLinkPrinter, PrusaSerialPrinter};
pub use prusa::{OctoPrintFlavour, OctoPrintPrinter, PrusaSerialPrinter};
/// A tiny error type: just a message. `String` would work too - this exists
/// mainly so `?` has something concrete to convert into everywhere.
@@ -57,6 +71,19 @@ pub struct PrinterBase {
/// The shared interface - think `interface GenericPrinter` (Java/TS) or an
/// ABC (Python). Every vendor's struct implements this.
///
/// The `allow` is a considered decision, not a brush-off. `async fn` in a
/// public trait means callers cannot require the returned futures to be
/// `Send`, so such a future cannot be moved to another OS thread. That is
/// fine here, and checked: the daemon does run on tokio's multi-threaded
/// runtime, but every printer is reached through one `Arc<Mutex<Vec<..>>>`
/// from the uplink's own task (see `uplink::SharedFleet`), so these futures
/// are created and polled in the same place. Revisit if a printer ever gets
/// its own task - the fix is to desugar to
/// `fn connect(&mut self) -> impl Future<Output = ..> + Send`, which cannot
/// be done without changing every implementation, which is why it is not
/// done pre-emptively.
#[allow(async_fn_in_trait)]
pub trait GenericPrinter {
/// Read-only access to the shared fields.
fn base(&self) -> &PrinterBase;
@@ -65,19 +92,48 @@ pub trait GenericPrinter {
/// so there's nothing sensible to share here. Each `impl` in
/// `bambu.rs`/`prusa.rs`/`klipper.rs` provides a totally different
/// body - that's the override.
///
/// `cargo check` will warn about `async fn` in a public trait here.
/// That's about a more advanced concern (whether the resulting work can
/// safely move across OS threads) that only matters once this is wired
/// into the multi-threaded daemon - safe to ignore for now.
async fn connect(&mut self) -> Result<(), PrinterError>;
/// Sends one already-encoded message by whatever transport this vendor
/// uses. Lower level than the methods below and mostly a building block
/// for them - `bambu.rs` publishes MQTT with it; the stubs only log.
async fn publish(&mut self, payload: String) -> Result<(), PrinterError>;
/// REQUIRED, no default body: `percent` is 0-100, but how it actually
/// gets sent - an MQTT gcode-injection command, a REST call, raw bytes
/// on a serial port - is different for every vendor, same as
/// `connect()`.
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError>;
/// REQUIRED, no default body: takes the *whole file* already in memory
/// (`data`), not a path - whatever called this already had to download
/// it from the S3 URL the cloud handed over, so there'd be nothing on
/// disk to point a path at anyway. Each vendor does something quite
/// different with `data`: Bambu/PrusaLink/Klipper each do one bulk
/// transfer to storage the printer's own firmware owns; PrusaSerial
/// has no such storage at all - see that impl's doc comment.
async fn upload_project_file(&mut self, file_name: String, data: Vec<u8>) -> Result<(), PrinterError>;
/// REQUIRED, no default body: sends exactly *one* gcode line, using
/// whatever single-command mechanism this vendor has (an MQTT
/// `gcode_line` publish for Bambu, an HTTP command-injection endpoint
/// for PrusaLink/Klipper, a raw write over the port for PrusaSerial).
/// `upload_gcode` below is the generic driver that calls this once per
/// line for a whole file - this method is where each vendor's actual
/// difference lives, same division of labor as every other method here.
async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError>;
/// REQUIRED, no default body: pause, resume or cancel the print that is
/// already running. Each vendor has one mechanism for all three - an
/// MQTT `print` command, a PrusaLink job endpoint, a Klipper macro, a
/// Marlin gcode word - so they arrive here as one call taking which.
///
/// There is deliberately no "start a print" counterpart on this trait.
/// Starting one needs a file that is already on the printer, which is
/// `upload_project_file`'s job, and the two have very different
/// consequences; see `BambuPrinter::start_uploaded_file`.
async fn control_print(&mut self, action: PrintControl) -> Result<(), PrinterError>;
/// OPTIONAL: a default body every printer gets for free unless it
/// writes its own. None of ours do below, so all three vendors share
/// this exact implementation - the trait-method equivalent of
@@ -88,35 +144,119 @@ pub trait GenericPrinter {
}
/// M106's `S` parameter is a raw 0-255 PWM value, not a percentage. Shared
/// by anything that ultimately sends gcode (Bambu, PrusaLink, PrusaSerial)
/// - *not* a trait default, since Klipper's hypothetical native fan API
/// below takes a percent directly and has no use for this at all. Private,
/// but still visible to bambu.rs/prusa.rs: Rust's module privacy reaches
/// into child modules, not just the exact file it's defined in.
/// by anything that ultimately sends gcode (Bambu, PrusaLink,
/// PrusaSerial) - *not* a trait default, since Klipper's hypothetical
/// native fan API below takes a percent directly and has no use for this at
/// all. Private, but still visible to bambu.rs/prusa.rs: Rust's module
/// privacy reaches into child modules, not just the exact file it's defined
/// in.
fn percent_to_pwm(percent: u8) -> u8 {
((percent.min(100) as u16 * 255) / 100) as u8
}
/// The three things you can do to a print that is already running.
///
/// One enum and one trait method rather than three separate methods: every
/// vendor implements all three the same way it implements any of them (one
/// MQTT command for Bambu, one REST call for PrusaLink, one macro for
/// Klipper, one gcode word for Marlin), so splitting them would triple the
/// delegation boilerplate without separating anything that differs.
///
/// `Stop` is Bambu's own word for it and the one the schemas use. It
/// cancels the print - it is not a pause you can come back from.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PrintControl {
Pause,
Resume,
Stop,
}
impl PrintControl {
/// The vendor command name. Bambu's `print` category happens to use
/// exactly these three words, so this doubles as the MQTT command.
pub fn as_str(self) -> &'static str {
match self {
Self::Pause => "pause",
Self::Resume => "resume",
Self::Stop => "stop",
}
}
}
/// The closed set of printer kinds this fleet can talk to.
pub enum PrinterHandle {
BambuV1(BambuV1Printer),
BambuV2(BambuV2Printer),
PrusaLink(PrusaLinkPrinter),
Bambu(BambuPrinter),
OctoPrint(OctoPrintPrinter),
PrusaSerial(PrusaSerialPrinter),
Klipper(KlipperPrinter),
}
impl PrinterHandle {
/// The latest status this process has for the printer, if it has one.
///
/// An inherent method rather than a `GenericPrinter` trait method,
/// because right now only Bambu has a real status path - the other three
/// return `None` not because they have nothing to say but because their
/// protocol clients are still stubs. Putting it on the trait would make
/// every vendor claim to implement something three of them don't. Move it
/// onto the trait once a second vendor can genuinely answer.
pub async fn print_status(&self) -> Option<PrintReport> {
match self {
PrinterHandle::Bambu(p) => p.print_status().await,
PrinterHandle::OctoPrint(_) | PrinterHandle::PrusaSerial(_) | PrinterHandle::Klipper(_) => None,
}
}
/// Asks the printer to report its full state, for the printers that only
/// report when asked.
///
/// This exists because of one specific vendor quirk that should not leak
/// any further than this: a Bambu on protocol v1 pushes only *partial*
/// `print.push_status` objects unprompted - often a single temperature -
/// and sends a complete one only in response to `pushing.pushall`. Without
/// a periodic call here, `print_status()` on a v1 printer stays stuck on
/// whatever fragments it happened to volunteer. Found exactly that way: a
/// live P1P reached the cloud as a row of nulls with one real bed
/// temperature in it, while a v2 H2C beside it was fully populated.
///
/// Nothing arrives by the time this returns - the refreshed status comes
/// back over MQTT as an ordinary push some time later, so a caller that
/// refreshes and immediately reads gets the *previous* refresh's data. On
/// a timer that is simply a one-tick lag, which is why this is
/// fire-and-forget rather than something to await a result from.
///
/// A no-op for everyone else: `BambuPrinter::pushall` already no-ops on
/// v2 (which pushes complete objects unprompted), and the other three
/// vendors are still stubs. Same reasoning as `print_status` for why this
/// is inherent rather than a `GenericPrinter` method.
pub async fn refresh_status(&self) -> Result<(), PrinterError> {
match self {
PrinterHandle::Bambu(p) => p.pushall().await,
PrinterHandle::OctoPrint(_) | PrinterHandle::PrusaSerial(_) | PrinterHandle::Klipper(_) => Ok(()),
}
}
}
// Hand-written delegation: each trait method just matches on the variant
// and forwards to that variant's own implementation. This one block is the
// only "boilerplate tax" for not having inheritance - everything else reads
// like normal code.
impl GenericPrinter for PrinterHandle {
async fn control_print(&mut self, action: PrintControl) -> Result<(), PrinterError> {
match self {
PrinterHandle::Bambu(p) => p.control_print(action).await,
PrinterHandle::OctoPrint(p) => p.control_print(action).await,
PrinterHandle::PrusaSerial(p) => p.control_print(action).await,
PrinterHandle::Klipper(p) => p.control_print(action).await,
}
}
fn base(&self) -> &PrinterBase {
match self {
PrinterHandle::BambuV1(p) => p.base(),
PrinterHandle::BambuV2(p) => p.base(),
PrinterHandle::PrusaLink(p) => p.base(),
PrinterHandle::Bambu(p) => p.base(),
PrinterHandle::OctoPrint(p) => p.base(),
PrinterHandle::PrusaSerial(p) => p.base(),
PrinterHandle::Klipper(p) => p.base(),
}
@@ -124,21 +264,73 @@ impl GenericPrinter for PrinterHandle {
async fn connect(&mut self) -> Result<(), PrinterError> {
match self {
PrinterHandle::BambuV1(p) => p.connect().await,
PrinterHandle::BambuV2(p) => p.connect().await,
PrinterHandle::PrusaLink(p) => p.connect().await,
PrinterHandle::Bambu(p) => p.connect().await,
PrinterHandle::OctoPrint(p) => p.connect().await,
PrinterHandle::PrusaSerial(p) => p.connect().await,
PrinterHandle::Klipper(p) => p.connect().await,
}
}
async fn publish(&mut self, payload: String) -> Result<(), PrinterError> {
match self {
PrinterHandle::Bambu(p) => p.publish(payload).await,
PrinterHandle::OctoPrint(p) => p.publish(payload).await,
PrinterHandle::PrusaSerial(p) => p.publish(payload).await,
PrinterHandle::Klipper(p) => p.publish(payload).await,
}
}
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> {
match self {
PrinterHandle::BambuV1(p) => p.set_fan_speed(percent).await,
PrinterHandle::BambuV2(p) => p.set_fan_speed(percent).await,
PrinterHandle::PrusaLink(p) => p.set_fan_speed(percent).await,
PrinterHandle::Bambu(p) => p.set_fan_speed(percent).await,
PrinterHandle::OctoPrint(p) => p.set_fan_speed(percent).await,
PrinterHandle::PrusaSerial(p) => p.set_fan_speed(percent).await,
PrinterHandle::Klipper(p) => p.set_fan_speed(percent).await,
}
}
async fn upload_project_file(&mut self, file_name: String, data: Vec<u8>) -> Result<(), PrinterError> {
match self {
PrinterHandle::Bambu(p) => p.upload_project_file(file_name, data).await,
PrinterHandle::OctoPrint(p) => p.upload_project_file(file_name, data).await,
PrinterHandle::PrusaSerial(p) => p.upload_project_file(file_name, data).await,
PrinterHandle::Klipper(p) => p.upload_project_file(file_name, data).await,
}
}
async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError> {
match self {
PrinterHandle::Bambu(p) => p.send_gcode_line(gcode_line).await,
PrinterHandle::OctoPrint(p) => p.send_gcode_line(gcode_line).await,
PrinterHandle::PrusaSerial(p) => p.send_gcode_line(gcode_line).await,
PrinterHandle::Klipper(p) => p.send_gcode_line(gcode_line).await,
}
}
}
/// Splits `gcode` into lines and sends each one through `printer`'s own
/// `send_gcode_line` - works for *any* vendor, not just serial ones, since
/// Bambu/PrusaLink/Klipper each have their own single-line injection
/// mechanism too (see each `send_gcode_line` impl). They just don't use
/// this for `upload_project_file` - they have real file storage to bulk-
/// upload to instead, which is a different, faster path than sending a
/// whole file one line at a time.
///
/// `P: GenericPrinter` makes this a genuine Rust generic: one function
/// body, compiled separately for whichever concrete printer type you call
/// it with - like a TS function typed `<P extends GenericPrinter>`, except
/// Rust generates real, separate machine code per type instead of erasing
/// the type at compile time. Call it with any of the five printer structs,
/// or with `&mut PrinterHandle` itself (it implements `GenericPrinter` too,
/// via the delegation above).
pub async fn upload_gcode<P: GenericPrinter>(printer: &mut P, gcode: &str) -> Result<(), PrinterError> {
for line in gcode.lines() {
let line = line.trim();
// Blank lines and full-line comments have nothing to send.
if line.is_empty() || line.starts_with(';') {
continue;
}
printer.send_gcode_line(line.to_string()).await?;
}
Ok(())
}
+523
View File
@@ -0,0 +1,523 @@
//! Reading a Bambu `.3mf` project archive well enough to know which
//! filament each plate wants.
//!
//! This exists because `use_ams: false` is not a safe default. Hardcoding it
//! overrode the slicer's own choice and pointed an AMS-sliced project at the
//! external spool; on a real P1P the print started, heated, reached 1% and
//! then the printer paused itself with `print_error: 50364420` and
//! `tray_now: 255`. See `FilamentSource` in `bambu.rs`.
//!
//! Deliberately free of any MQTT or printer types: it takes archive bytes and
//! a plain list of loaded trays, and returns an `ams_mapping`. That keeps it
//! unit-testable without a printer, which is the only way this gets tested at
//! all - the alternative is discovering a mapping bug by watching a machine
//! print in the wrong colour.
use std::io::Read;
/// One filament a plate was sliced for.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectFilament {
/// 0-based. This is the position in `ams_mapping`, so it has to come
/// from the filament's own `id` minus one and not from the order the
/// entries happen to appear in the XML.
pub index: usize,
/// Bambu's filament identifier, e.g. `GFG99`. The strongest signal for
/// matching, because it names the exact product.
pub tray_info_idx: String,
/// e.g. `PETG`.
pub filament_type: String,
/// Six uppercase hex digits, no `#`. Normalised on the way in, because
/// the project writes `#2850E0` and the printer reports `2850E0FF` - the
/// same colour with a trailing alpha byte.
pub color: String,
}
/// A tray the printer actually has filament in, flattened to a single slot
/// number.
///
/// Deliberately not `bambu_commands::LoadedTray`, which is the wire shape:
/// that carries an `id` scoped to its own AMS unit, so slot 2 of unit 1 and
/// slot 2 of unit 0 are both `"2"`. Flattening to a global slot is the
/// caller's job, because only the caller knows how many units there are.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AvailableTray {
/// The AMS slot number, as it appears in `ams_mapping`.
pub slot: i32,
pub tray_info_idx: String,
pub filament_type: String,
/// Same normalisation as `ProjectFilament::color`.
pub color: String,
}
#[derive(Debug, thiserror::Error)]
pub enum ProjectFileError {
#[error("the project archive could not be opened: {0}")]
NotAnArchive(String),
#[error("the archive has no {0} - it may not be a Bambu project")]
MissingEntry(String),
#[error("{0} could not be read: {1}")]
UnreadableEntry(String, String),
#[error("the archive describes no plate {0}")]
NoSuchPlate(u32),
#[error("plate {0} lists no filaments")]
NoFilaments(u32),
/// A `<filament>` element that could not be read. An error rather than a
/// skip: see the comment at the parse site.
#[error("plate {plate} has an unreadable filament: {detail}")]
MalformedFilament { plate: u32, detail: String },
/// Deliberately an error and never a silent fallback to the external
/// spool: an unmatched filament means the print would draw the wrong
/// material or none.
#[error("no loaded tray matches {wanted} (filament index {index}); loaded: {loaded}")]
NoMatchingTray { index: usize, wanted: String, loaded: String },
}
/// Normalises a colour to six uppercase hex digits.
///
/// Accepts `#2850E0`, `2850E0`, `2850E0FF` and lowercase, because the
/// project archive and the printer's own report disagree on all three
/// points.
///
/// Input with fewer than six hex digits comes back uppercased and stripped
/// but otherwise untouched. It will not equal any real colour, so the matcher
/// fails to match - the safe outcome - rather than this panicking.
pub fn normalise_color(raw: &str) -> String {
let stripped = raw.trim().trim_start_matches('#').to_ascii_uppercase();
let head: String = stripped.chars().take(6).collect();
if head.len() == 6 && head.chars().all(|c| c.is_ascii_hexdigit()) {
head
} else {
stripped
}
}
const SLICE_INFO: &str = "Metadata/slice_info.config";
/// Reads the filaments one plate was sliced for out of a `.3mf`.
///
/// `plate_index` is 1-based, matching the archive's own numbering
/// (`Metadata/plate_1.gcode`, `<metadata key="index" value="1"/>`).
///
/// There is no XML crate in the dependency tree, so this scans the text.
/// That is tolerable only because `slice_info.config` is machine-written by
/// one program in a stable shape; the scanning is still defensive (tag names
/// must be followed by whitespace, attribute names must start at a word
/// boundary) so that a neighbouring element cannot be mistaken for the one
/// we want. Anything unexpected yields a typed error, never a guess.
pub fn plate_filaments(
archive: &[u8],
plate_index: u32,
) -> Result<Vec<ProjectFilament>, ProjectFileError> {
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(archive))
.map_err(|e| ProjectFileError::NotAnArchive(e.to_string()))?;
let mut entry = zip
.by_name(SLICE_INFO)
.map_err(|_| ProjectFileError::MissingEntry(SLICE_INFO.to_string()))?;
let mut xml = String::new();
entry
.read_to_string(&mut xml)
.map_err(|e| ProjectFileError::UnreadableEntry(SLICE_INFO.to_string(), e.to_string()))?;
let block = plate_blocks(&xml)
.into_iter()
.find(|block| plate_number(block) == Some(plate_index))
.ok_or(ProjectFileError::NoSuchPlate(plate_index))?;
// Every `<filament>` must parse. A malformed one used to be dropped
// here, which is the worst available outcome: a dropped filament leaves
// a hole at its index, `map_to_trays` fills the hole with `-1`, and the
// printer is told that material is unused - so the plate prints in
// whatever remains mapped. Refusing is the only safe answer, because
// there is nothing to fall back to that is not a guess about what
// someone meant to print.
let mut filaments: Vec<ProjectFilament> = tags(block, "filament")
.into_iter()
.map(|tag| {
let missing = |what: &str| ProjectFileError::MalformedFilament {
plate: plate_index,
detail: format!("{what} in <filament {}>", tag.trim()),
};
// Ids are 1-based, so 0 or a non-number would underflow into a
// bogus position.
let raw_id = attr(tag, "id").ok_or_else(|| missing("no id"))?;
let id: usize = raw_id.parse().map_err(|_| missing("id is not a number"))?;
let index = id.checked_sub(1).ok_or_else(|| missing("id is 0, but ids are 1-based"))?;
Ok(ProjectFilament {
index,
tray_info_idx: attr(tag, "tray_info_idx")
.ok_or_else(|| missing("no tray_info_idx"))?
.to_string(),
filament_type: attr(tag, "type").ok_or_else(|| missing("no type"))?.to_string(),
color: normalise_color(attr(tag, "color").ok_or_else(|| missing("no color"))?),
})
})
.collect::<Result<_, ProjectFileError>>()?;
filaments.sort_by_key(|f| f.index);
if filaments.is_empty() {
return Err(ProjectFileError::NoFilaments(plate_index));
}
Ok(filaments)
}
/// Each `<plate>` element's text, up to the start of the next one.
fn plate_blocks(xml: &str) -> Vec<&str> {
xml.split("<plate")
.skip(1)
// `<plate_x` would be a different element; `</plate>` never matches
// the split at all because its `<` is followed by `/`.
.filter(|rest| rest.starts_with(|c: char| c == '>' || c.is_whitespace()))
.collect()
}
fn plate_number(block: &str) -> Option<u32> {
tags(block, "metadata")
.into_iter()
.find(|tag| attr(tag, "key") == Some("index"))
.and_then(|tag| attr(tag, "value")?.parse().ok())
}
/// Every `<name ...>` start tag in `text`, without the angle brackets.
/// Requires whitespace after the name so `filament` does not match
/// `<filament_settings>`.
fn tags<'a>(text: &'a str, name: &str) -> Vec<&'a str> {
let open = format!("<{name}");
let mut found = Vec::new();
let mut rest = text;
while let Some(at) = rest.find(&open) {
let after = &rest[at + open.len()..];
let Some(end) = after.find('>') else { break };
if after.starts_with(char::is_whitespace) {
found.push(&after[..end]);
}
rest = &after[end..];
}
found
}
/// The value of attribute `name` in a start tag.
///
/// The name must begin right after whitespace, so asking for `id` cannot
/// land inside `identify_id` or `group_id`, and asking for `type` cannot
/// land inside `volume_type`. Whitespace before the `=` is tolerated
/// (`error_code ="..."` occurs in real files).
fn attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
let mut from = 0;
while let Some(rel) = tag[from..].find(name) {
let at = from + rel;
from = at + name.len();
let at_boundary = tag[..at].ends_with(char::is_whitespace);
let after = tag[from..].trim_start();
if at_boundary && let Some(value) = after.strip_prefix('=') {
let value = value.trim_start().strip_prefix('"')?;
return value.split('"').next();
}
}
None
}
/// Builds the `ams_mapping` body for a plate: position = project filament
/// index, value = the AMS slot to draw it from.
///
/// Unused positions are `-1`. The caller pads to the printer's full tray
/// count - see `FilamentSource::ams`.
///
/// Each filament takes the best tray by this order:
/// 1. same `tray_info_idx` and same colour - the exact product, as sliced.
/// 2. same `tray_info_idx`, different colour - a deliberate "right product,
/// wrong colour" allowance.
/// 3. same `filament_type` and same colour - "right material", a different
/// product.
///
/// Tiers 2 and 3 are better than refusing a print, but they are not what the
/// slicer asked for, so a caller may want to warn. Ties within a tier go to
/// the lowest slot so the result never depends on tray ordering. A filament
/// matching no tier is an error, never a fallback.
pub fn map_to_trays(
filaments: &[ProjectFilament],
trays: &[AvailableTray],
) -> Result<Vec<i32>, ProjectFileError> {
let Some(max) = filaments.iter().map(|f| f.index).max() else {
return Ok(Vec::new());
};
let mut mapping = vec![-1; max + 1];
for filament in filaments {
let best = trays
.iter()
.filter_map(|tray| Some((tier(filament, tray)?, tray.slot)))
.min();
let Some((_, slot)) = best else {
let loaded = trays
.iter()
.map(|t| format!("slot {}: {} {} #{}", t.slot, t.filament_type, t.tray_info_idx, t.color))
.collect::<Vec<_>>()
.join(", ");
return Err(ProjectFileError::NoMatchingTray {
index: filament.index,
wanted: format!(
"{} {} #{}",
filament.filament_type, filament.tray_info_idx, filament.color
),
loaded,
});
};
mapping[filament.index] = slot;
}
Ok(mapping)
}
/// Match quality, lower is better; `None` means no match.
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;
let same_type = filament.filament_type.eq_ignore_ascii_case(&tray.filament_type);
match (same_product, same_color, same_type) {
(true, true, _) => Some(1),
(true, false, _) => Some(2),
(false, true, true) => Some(3),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
const REAL: &str = r##"<?xml version="1.0" encoding="UTF-8"?>
<config>
<header>
<header_item key="X-BBL-Client-Type" value="slicer"/>
</header>
<plate>
<metadata key="index" value="1"/>
<metadata key="nozzle_diameters" value="0.4"/>
<metadata key="prediction" value="2408"/>
<object identify_id="69" name="Bambu Cube" skipped="false" />
<filament id="1" tray_info_idx="GFG99" type="PETG" color="#2850E0" used_m="2.57" used_g="7.86" group_id="0" nozzle_diameter="0.40" volume_type="Standard" used_for_object="true" used_for_support="false"/>
<warning msg="bed_temperature_too_high_than_filament" level="3" error_code ="1000C001" />
<nozzle id="0" extruder_id="1" nozzle_diameter="0.4" volume_type="Standard"/>
</plate>
</config>
"##;
const TWO_PLATES: &str = r##"<config>
<plate>
<metadata key="index" value="1"/>
<filament id="1" tray_info_idx="GFA00" type="PLA" color="#FF0000" group_id="0"/>
</plate>
<plate>
<metadata key="index" value="2"/>
<filament id="2" tray_info_idx="GFB00" type="ABS" color="#00ff00" group_id="1"/>
<filament id="1" tray_info_idx="GFG99" type="PETG" color="#0000FFFF" group_id="0"/>
</plate>
</config>
"##;
fn zip_with(name: &str, body: &str) -> Vec<u8> {
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let opts = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored);
w.start_file(name, opts).unwrap();
w.write_all(body.as_bytes()).unwrap();
w.finish().unwrap().into_inner()
}
fn filament(index: usize, idx: &str, ty: &str, color: &str) -> ProjectFilament {
ProjectFilament {
index,
tray_info_idx: idx.into(),
filament_type: ty.into(),
color: color.into(),
}
}
fn tray(slot: i32, idx: &str, ty: &str, color: &str) -> AvailableTray {
AvailableTray {
slot,
tray_info_idx: idx.into(),
filament_type: ty.into(),
color: color.into(),
}
}
#[test]
fn normalises_every_spelling() {
assert_eq!(normalise_color("#2850E0"), "2850E0");
assert_eq!(normalise_color("2850e0"), "2850E0");
assert_eq!(normalise_color("2850E0FF"), "2850E0");
assert_eq!(normalise_color("#2850e0ff"), "2850E0");
}
#[test]
fn short_or_garbage_colour_does_not_panic() {
assert_eq!(normalise_color("#abc"), "ABC");
assert_eq!(normalise_color(""), "");
assert_eq!(normalise_color("zzzzzzzz"), "ZZZZZZZZ");
}
#[test]
fn reads_the_real_single_filament_plate() {
let got = plate_filaments(&zip_with(SLICE_INFO, REAL), 1).unwrap();
assert_eq!(got, vec![filament(0, "GFG99", "PETG", "2850E0")]);
}
#[test]
fn picks_the_right_plate_and_converts_ids() {
let archive = zip_with(SLICE_INFO, TWO_PLATES);
let got = plate_filaments(&archive, 2).unwrap();
assert_eq!(
got,
vec![
filament(0, "GFG99", "PETG", "0000FF"),
filament(1, "GFB00", "ABS", "00FF00"),
]
);
let first = plate_filaments(&archive, 1).unwrap();
assert_eq!(first, vec![filament(0, "GFA00", "PLA", "FF0000")]);
}
#[test]
fn archive_errors() {
assert!(matches!(
plate_filaments(b"not a zip", 1),
Err(ProjectFileError::NotAnArchive(_))
));
assert!(matches!(
plate_filaments(&zip_with("other.txt", "x"), 1),
Err(ProjectFileError::MissingEntry(_))
));
assert!(matches!(
plate_filaments(&zip_with(SLICE_INFO, REAL), 7),
Err(ProjectFileError::NoSuchPlate(7))
));
}
#[test]
fn plate_without_filaments_is_an_error() {
let xml = r#"<config><plate><metadata key="index" value="1"/></plate></config>"#;
assert!(matches!(
plate_filaments(&zip_with(SLICE_INFO, xml), 1),
Err(ProjectFileError::NoFilaments(1))
));
}
#[test]
fn attr_does_not_match_longer_names() {
let tag = r#"filament group_id="9" identify_id="8" volume_type="Std" id="1" type="PLA""#;
assert_eq!(attr(tag, "id"), Some("1"));
assert_eq!(attr(tag, "type"), Some("PLA"));
assert_eq!(attr(r#"x error_code ="E1""#, "error_code"), Some("E1"));
}
#[test]
fn same_product_different_colour_picks_the_colour_match() {
let filaments = [filament(0, "GFG99", "PETG", "2850E0")];
let trays = [
tray(2, "GFG99", "PETG", "161616"),
tray(3, "GFG99", "PETG", "2850E0"),
];
assert_eq!(map_to_trays(&filaments, &trays).unwrap(), vec![3]);
}
#[test]
fn tiers_and_tie_break() {
// Tier 2 (right product, wrong colour) beats nothing, lowest slot wins.
let f = [filament(0, "GFG99", "PETG", "2850E0")];
let t = [tray(5, "GFG99", "PETG", "111111"), tray(1, "gfg99", "PETG", "222222")];
assert_eq!(map_to_trays(&f, &t).unwrap(), vec![1]);
// Tier 3 (right material and colour, different product).
let t = [tray(4, "GFA00", "petg", "2850E0")];
assert_eq!(map_to_trays(&f, &t).unwrap(), vec![4]);
// Tier 1 outranks a lower-numbered tier 2.
let t = [tray(0, "GFG99", "PETG", "111111"), tray(6, "GFG99", "PETG", "2850E0")];
assert_eq!(map_to_trays(&f, &t).unwrap(), vec![6]);
}
#[test]
fn unmatched_filament_is_an_error() {
let f = [filament(0, "GFG99", "PETG", "2850E0")];
let t = [tray(1, "GFA00", "PLA", "FF0000")];
match map_to_trays(&f, &t) {
Err(ProjectFileError::NoMatchingTray { index, wanted, loaded }) => {
assert_eq!(index, 0);
assert_eq!(wanted, "PETG GFG99 #2850E0");
assert_eq!(loaded, "slot 1: PLA GFA00 #FF0000");
}
other => panic!("expected NoMatchingTray, got {other:?}"),
}
}
#[test]
fn gaps_become_minus_one() {
let f = [filament(0, "GFA00", "PLA", "FF0000"), filament(2, "GFG99", "PETG", "2850E0")];
let t = [tray(1, "GFA00", "PLA", "FF0000"), tray(3, "GFG99", "PETG", "2850E0")];
assert_eq!(map_to_trays(&f, &t).unwrap(), vec![1, -1, 3]);
}
}
/// Tests against a real project, not just hand-built XML.
///
/// `tests/fixtures/cube_slice_info.3mf` holds the `slice_info.config` of a
/// project read off a P1P over FTPS on 2026-10-11 - the one that exposed the
/// `use_ams: false` bug - re-zipped with **deflate**. The compression matters
/// as much as the content: every other test here builds a `Stored` archive,
/// so without this the decompression path (and the flate2 backend wired up
/// behind `zip`'s `deflate-flate2` feature) is never exercised, and a real
/// archive is always deflated.
#[cfg(test)]
mod real_project_tests {
use super::*;
const REAL_PROJECT: &[u8] = include_bytes!("../../tests/fixtures/cube_slice_info.3mf");
#[test]
fn reads_a_deflated_archive_from_a_real_printer() {
let filaments = plate_filaments(REAL_PROJECT, 1).expect("the real project should parse");
assert_eq!(filaments.len(), 1, "the cube is a single-filament plate");
assert_eq!(filaments[0].index, 0, "id=\"1\" is 1-based, so index 0");
assert_eq!(filaments[0].tray_info_idx, "GFG99");
assert_eq!(filaments[0].filament_type, "PETG");
// `#2850E0` in the project; the printer calls the same colour
// `2850E0FF`.
assert_eq!(filaments[0].color, "2850E0");
}
/// End to end on the exact case that cost a print: both AMS slots hold
/// the same product (PETG, GFG99) and only the colour distinguishes
/// them, so a matcher that stopped at `tray_info_idx` would pick slot 2
/// and print the cube in black.
#[test]
fn the_real_project_maps_to_the_slot_it_was_sliced_for() {
let filaments = plate_filaments(REAL_PROJECT, 1).expect("should parse");
let trays = vec![
AvailableTray {
slot: 2,
tray_info_idx: "GFG99".into(),
filament_type: "PETG".into(),
color: "161616".into(),
},
AvailableTray {
slot: 3,
tray_info_idx: "GFG99".into(),
filament_type: "PETG".into(),
color: "2850E0".into(),
},
];
assert_eq!(map_to_trays(&filaments, &trays).expect("should match"), vec![3]);
}
#[test]
fn the_real_project_has_no_second_plate() {
assert!(matches!(
plate_filaments(REAL_PROJECT, 2),
Err(ProjectFileError::NoSuchPlate(2))
));
}
}
+139 -23
View File
@@ -1,22 +1,66 @@
use super::{GenericPrinter, PrinterBase, PrinterError};
/****************** PrusaLink ******************/
/****************** OctoPrint API family ******************/
pub struct PrusaLinkPrinter {
base: PrinterBase,
pub api_key: String,
/// 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 PrusaLinkPrinter {
pub fn new(id: &str, name: &str, host: &str, api_key: &str) -> Self {
Self {
base: PrinterBase { id: id.into(), name: name.into(), host: host.into() },
api_key: api_key.into(),
impl OctoPrintFlavour {
pub fn as_str(self) -> &'static str {
match self {
OctoPrintFlavour::PrusaLink => "PrusaLink",
OctoPrintFlavour::Generic => "OctoPrint",
}
}
}
impl GenericPrinter for PrusaLinkPrinter {
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
}
@@ -25,7 +69,12 @@ impl GenericPrinter for PrusaLinkPrinter {
/// 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 (PrusaLink)", self.base.name);
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(())
}
@@ -37,6 +86,22 @@ impl GenericPrinter for PrusaLinkPrinter {
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(())
}
}
@@ -44,38 +109,89 @@ impl GenericPrinter for PrusaLinkPrinter {
pub struct PrusaSerialPrinter {
base: PrinterBase,
pub com_port: i16,
pub port: String,
pub baud_rate: u32,
/// `None` until `connect()` succeeds - a real serial connection, not a
/// stub, so there's something to hold onto: unlike Bambu/PrusaLink/
/// Klipper's stubbed sessions, this one has to survive across many
/// `send_gcode_line` calls (see `marlin_serial.rs` for why).
connection: Option<super::marlin_serial::MarlinSerialConnection>,
}
impl PrusaSerialPrinter {
pub fn new(id: &str, name: &str, com_port: i16) -> Self {
pub fn new(id: &str, name: &str, port: &str, baud_rate: u32) -> Self {
Self {
base: PrinterBase { id: id.into(), name: name.into(), host: "127.0.0.1".to_string() },
com_port,
port: port.into(),
baud_rate,
connection: None,
}
}
}
impl GenericPrinter for PrusaSerialPrinter {
/// OVERRIDE: Marlin has a gcode word for each - `M25` pause, `M24`
/// resume, `M524` abort - so this goes out over the port like any other
/// line, the same way `set_fan_speed` does.
async fn control_print(&mut self, action: super::PrintControl) -> Result<(), PrinterError> {
let word = match action {
super::PrintControl::Pause => "M25",
super::PrintControl::Resume => "M24",
super::PrintControl::Stop => "M524",
};
self.send_gcode_line(word.to_string()).await
}
fn base(&self) -> &PrinterBase {
&self.base
}
/// OVERRIDE: a fourth, again-different body - no network host at all,
/// just a local serial port (real version: open the port, e.g. via the
/// `serialport`/`tokio-serial` crate, and speak Prusa's G-code-over-
/// serial protocol).
/// OVERRIDE, and a real one (not a stub, unlike the other four vendors'
/// `connect`): opens the port and keeps it, since serial has no
/// separate "session" concept to stub out - the open connection *is*
/// the whole thing.
async fn connect(&mut self) -> Result<(), PrinterError> {
println!("[{}] connecting over serial (COM{})", self.base.name, self.com_port);
println!("[{}] connecting over serial ({}, {} baud)", self.base.name, self.port, self.baud_rate);
self.connection = Some(super::marlin_serial::MarlinSerialConnection::open(&self.port, self.baud_rate).await?);
Ok(())
}
async fn publish(&mut self, _payload: String) -> Result<(), PrinterError> {
// TODO: add publishing via serial
Ok(())
}
/// OVERRIDE: same gcode as PrusaLink (still M106, still the shared
/// conversion), but written straight to the serial port instead of
/// sent over HTTP - a third transport for the same underlying command.
/// conversion), but sent through `send_gcode_line` below instead of a
/// separate transport - serial only has the one way to talk to the
/// printer at all.
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> {
let pwm = super::percent_to_pwm(percent);
println!("[{}] writing to COM{}: M106 S{pwm}", self.base.name, self.com_port);
Ok(())
self.send_gcode_line(format!("M106 S{pwm}")).await
}
/// OVERRIDE, but not just "a fourth transport" like the others above -
/// there's no printer-side storage to upload `data` to over a raw
/// serial connection at all, so this decodes it as gcode text and
/// streams it through `send_gcode_line` one line at a time via the
/// generic `upload_gcode` driver, instead of one bulk write like
/// Bambu/PrusaLink/Klipper.
async fn upload_project_file(&mut self, file_name: String, data: Vec<u8>) -> Result<(), PrinterError> {
let gcode = String::from_utf8(data)
.map_err(|_| PrinterError(format!("{file_name} is not valid UTF-8 gcode")))?;
println!("[{}] streaming {file_name} over {}", self.base.name, self.port);
super::upload_gcode(self, &gcode).await
}
/// OVERRIDE, and the real implementation, not a stub: writes `gcode_line`
/// to the already-open port and waits for Marlin's "ok" - see
/// `marlin_serial.rs` for why that wait matters.
async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError> {
let connection = self
.connection
.as_mut()
.ok_or_else(|| PrinterError(format!("{} is not connected", self.base.name)))?;
connection.send_line(&gcode_line).await
}
}
+130
View File
@@ -0,0 +1,130 @@
//! Fetching a job file from the presigned URL a `start_print` command carries.
//!
//! The URL is attacker-influenceable in the worst case (a misconfigured or
//! compromised cloud, a bad object store) and the body is held in memory
//! because `upload_project_file` takes a `Vec<u8>`, so everything here is
//! bounded: size, time, redirects and scheme.
use std::time::Duration;
/// Hard ceiling on the body. A sliced `.3mf` is tens of megabytes (62 MB has
/// been seen for a multi-plate project); 256 MiB is roughly four times that,
/// so no honest job is refused, while a hostile URL cannot make a gateway on
/// small hardware (a Pi-class box is the target) buffer its way into the OOM
/// killer. Also well above anything a printer's own storage would take.
pub const MAX_JOB_FILE_BYTES: u64 = 256 * 1024 * 1024;
/// Whole-request budget, connect through last byte. 256 MiB at the slowest
/// link worth supporting (~2 MiB/s) is just over two minutes, so 3 minutes
/// covers the cap itself; anything slower than that is a stalled transfer, not
/// a slow one. Presigned URLs live for minutes, so a longer wait would only
/// be spent on a URL about to expire. Note `dispatch` runs inside the uplink
/// loop, so this is also the longest a download can hold up heartbeats.
pub const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(180);
/// Separate and short: an unreachable host should fail in seconds, not use
/// up the whole budget above.
pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
/// Downloads `url` fully into memory, or says why not.
///
/// The error is a `String` naming the failure; the caller prefixes the stage.
pub async fn fetch_job_file(url: &str) -> Result<Vec<u8>, String> {
fetch_job_file_capped(url, MAX_JOB_FILE_BYTES).await
}
/// Same as [`fetch_job_file`] with the cap as a parameter, so tests can
/// exercise the limit without serving 256 MiB.
pub async fn fetch_job_file_capped(url: &str, max_bytes: u64) -> Result<Vec<u8>, String> {
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" {
return Err(format!("refusing non-HTTPS URL (scheme '{}')", parsed.scheme()));
}
let client = reqwest::Client::builder()
.connect_timeout(CONNECT_TIMEOUT)
.timeout(DOWNLOAD_TIMEOUT)
// A presigned GET answers directly. Following a redirect would carry
// us somewhere the signature never vouched for.
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|err| format!("could not build HTTP client: {err}"))?;
let mut response = client.get(parsed).send().await.map_err(|err| describe(&err))?;
// Status before body. An expired presigned URL is a 403 with an XML
// error document, which must never be mistaken for a project file.
let status = response.status();
if !status.is_success() {
let hint = if status == reqwest::StatusCode::FORBIDDEN {
" (the presigned URL has probably expired)"
} else {
""
};
return Err(format!("server answered HTTP {status}{hint}"));
}
let expected = response.content_length();
if let Some(len) = expected.filter(|len| *len > max_bytes) {
return Err(format!("file is {len} bytes, over the {max_bytes} byte limit"));
}
// Stream into a buffer so the cap holds even when the server sends no
// Content-Length or lies about it.
let mut body = Vec::with_capacity(expected.unwrap_or(0).min(max_bytes) as usize);
while let Some(chunk) = response.chunk().await.map_err(|err| describe(&err))? {
if body.len() as u64 + chunk.len() as u64 > max_bytes {
return Err(format!("file exceeds the {max_bytes} byte limit"));
}
body.extend_from_slice(&chunk);
}
// A `.3mf` is a zip: a truncated one has lost its central directory and
// is unreadable, so a short body is an error, not a smaller file.
if let Some(len) = expected.filter(|len| *len != body.len() as u64) {
return Err(format!("received {} bytes but Content-Length said {len}", body.len()));
}
if body.is_empty() {
return Err("server returned an empty body".into());
}
Ok(body)
}
/// reqwest's `Display` for a transport error is just "error sending request";
/// the useful part (timeout, connect, TLS) is in the flags and source chain.
fn describe(err: &reqwest::Error) -> String {
let kind = if err.is_timeout() {
"timed out"
} else if err.is_connect() {
"could not connect"
} else {
"transfer failed"
};
let mut msg = format!("{kind}: {err}");
let mut source = std::error::Error::source(err);
while let Some(cause) = source {
msg.push_str(&format!(": {cause}"));
source = cause.source();
}
// Never echo the URL back: its query string is the credential.
match err.url() {
Some(url) => msg.replace(url.as_str(), "<url>"),
None => msg,
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Checked before any connection is made, so no server is needed.
#[tokio::test]
async fn a_non_https_url_is_refused_before_any_request() {
let err = fetch_job_file("http://example.invalid/plate.3mf?sig=abc").await.unwrap_err();
assert!(err.contains("non-HTTPS"), "got: {err}");
assert!(fetch_job_file("not a url").await.is_err());
}
}
+488 -16
View File
@@ -1,52 +1,219 @@
//! The cloud connection: a WebSocket to `continuum-backend`'s `/ws/edge/v1`.
//!
//! This is intentionally the simplest version of "a client that reconnects
//! when it drops": no backoff curve, no channels routing messages in from
//! other parts of the program yet - just connect, say hello, send a
//! heartbeat on a timer, and print whatever comes back. Once you're
//! comfortable with this, growing it (real backoff, forwarding printer
//! telemetry, dispatching cloud-issued commands) is additive, not a rewrite.
//! This is where the proxy earns its name. Until now it connected, said
//! hello, and sent heartbeats into the void while the printer fleet lived in
//! a completely separate part of the program with nothing routing between
//! them. Both directions are wired up now:
//!
//! - **Up:** every `TELEMETRY_INTERVAL`, one snapshot per printer, batched
//! into a single `Telemetry` message.
//! - **Down:** a `Command` from the cloud is looked up by printer id,
//! executed, and answered with a `CommandResult`.
//!
//! - **Out of band:** anything that needs to reach the cloud from outside
//! this loop goes through an mpsc channel (`outbound`), which is how a
//! command that takes minutes reports its outcome after the loop has
//! already answered for it.
//!
//! Still deliberately the simplest version of a reconnecting client: a fixed
//! retry delay rather than a backoff curve. Telemetry produced while the
//! uplink is down is not buffered, and that is now a considered decision
//! rather than a gap - the cloud stores only each printer's latest state and
//! has no table for a history, and a Bambu's state is level-triggered
//! (`FINISH` persists until the next print starts), so the first snapshot
//! after a reconnect carries the outcome. Replaying a backlog of five-second
//! snapshots into a row that holds one value would churn it with stale
//! readings for no consumer. See PLAN.md for what would actually be worth
//! buffering.
mod download;
mod webrtc;
mod protocol;
pub use protocol::{ClientMessage, ServerMessage};
pub use protocol::{ClientMessage, DiscoveredPrinter, PrinterCommand, PrinterTelemetry, ServerMessage};
use std::sync::Arc;
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use tokio::sync::Mutex;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::handshake::client::{Request, generate_key};
use tracing::{info, warn};
use crate::config::Config;
use crate::printer::{GenericPrinter, PrinterHandle};
/// The websocket, once connected. Named because `connect` has to hand it to
/// `serve` and the full type is three layers of generics.
type WsStream = tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>;
/// First retry delay after a failed connection, doubling up to
/// `RETRY_DELAY_MAX`.
const RETRY_DELAY: Duration = Duration::from_secs(5);
/// Ceiling for the backoff.
///
/// A gateway cannot tell a cloud that is briefly restarting from a token the
/// cloud will never accept - both are a failed connection here - so a flat
/// 5s retry meant a permanently misconfigured gateway knocking twelve times a
/// minute, forever, which is both pointless and the shape of something worth
/// rate-limiting at the other end. A minute is short enough that an outage
/// resolves without anyone touching the gateway, which matters when the
/// gateway is in someone else's workshop.
const RETRY_DELAY_MAX: Duration = Duration::from_secs(60);
/// Depth of the out-of-band outbound queue.
///
/// Bounded, because an unbounded queue facing a cloud that may be down for
/// hours is just a slow memory leak. Only out-of-band messages go through it,
/// one per long-running command, so 64 is far more than a farm generates
/// between reconnects. A sender waits when it is full rather than dropping,
/// which is the right trade for a message whose whole purpose is to explain a
/// failure.
const OUTBOUND_CAPACITY: usize = 64;
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(15);
/// Faster than the heartbeat, slower than the printers push. A Bambu on v2
/// emits a full status roughly every second; forwarding at that rate would be
/// 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);
/// The printer fleet, shared between `main` (which owns it and keeps every
/// connection alive) and this module (which reads status and dispatches
/// commands).
///
/// `Arc` is shared ownership - several places hold the same value and the
/// last one to let go drops it, roughly how every TS object reference
/// already behaves. `Mutex` is what makes mutation across tasks safe: Rust
/// will not let two tasks hold `&mut` to the same data, so a lock is how you
/// take turns. `tokio::sync::Mutex` rather than `std::sync::Mutex`
/// specifically because the code below holds the lock across an `.await`,
/// which the std one is not designed for.
///
/// One lock for the whole `Vec` is coarse: a slow command to one printer
/// stalls telemetry for all of them. Fine at the handful-of-printers scale
/// this runs at now, and the shape to revisit (a lock per printer, or an
/// actor task per printer owning its own handle) before a farm gets large.
pub type SharedFleet = Arc<Mutex<Vec<PrinterHandle>>>;
/// The `Host` header for a websocket URL: authority without the scheme.
///
/// Needed because building the upgrade request by hand (to attach the edge
/// token) means tokio-tungstenite no longer derives the handshake headers
/// from a URL for us. An error rather than a guess if the URL has no
/// authority: a `Host` of the wrong thing fails the handshake with something
/// far less obvious than this does.
fn host_header(url: &str) -> anyhow::Result<String> {
let authority = url
.split_once("://")
.map(|(_, rest)| rest)
.unwrap_or(url)
.split(['/', '?', '#'])
.next()
.unwrap_or("");
if authority.is_empty() {
anyhow::bail!("the uplink URL has no host to connect to: {url}");
}
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) {
pub async fn run(
config: Config,
fleet: SharedFleet,
outbound: tokio::sync::mpsc::Sender<ClientMessage>,
mut inbox: tokio::sync::mpsc::Receiver<ClientMessage>,
) {
let mut delay = RETRY_DELAY;
loop {
info!(url = %config.uplink_url, "connecting to cloud uplink");
if let Err(err) = connect_and_serve(&config).await {
warn!(?err, "uplink connection failed, retrying in {RETRY_DELAY:?}");
match connect(&config).await {
Ok(ws_stream) => {
// Reset on a completed handshake, which is the only
// unambiguous evidence that the cloud is reachable and this
// gateway's token is accepted - and the reason `connect` is
// split out at all. Resetting after `serve` returns instead
// 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 {
warn!(?err, "uplink connection dropped, reconnecting in {delay:?}");
}
}
Err(err) => warn!(?err, "uplink connection failed, retrying in {delay:?}"),
}
tokio::time::sleep(RETRY_DELAY).await;
tokio::time::sleep(delay).await;
delay = (delay * 2).min(RETRY_DELAY_MAX);
}
}
async fn connect_and_serve(config: &Config) -> anyhow::Result<()> {
let (ws_stream, _response) = tokio_tungstenite::connect_async(&config.uplink_url).await?;
/// Opens the websocket. Separate from `serve` so `run` can tell a failed
/// handshake from a connection that opened and later dropped; they want
/// different retry behaviour.
async fn connect(config: &Config) -> anyhow::Result<WsStream> {
// 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 -
// and the credential stays out of the URL, where it would land in every
// access and proxy log along the way. `Authorization` is the header
// those tools already know to redact.
let request = Request::builder()
.uri(&config.uplink_url)
.header("Authorization", format!("Bearer {}", config.gateway_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.
.header("Host", host_header(&config.uplink_url)?)
.header("Connection", "Upgrade")
.header("Upgrade", "websocket")
.header("Sec-WebSocket-Version", "13")
.header("Sec-WebSocket-Key", generate_key())
.body(())?;
let (ws_stream, _response) = tokio_tungstenite::connect_async(request).await?;
Ok(ws_stream)
}
/// Says hello, then pumps the connection until it drops.
async fn serve(
ws_stream: WsStream,
config: &Config,
fleet: &SharedFleet,
outbound: &tokio::sync::mpsc::Sender<ClientMessage>,
inbox: &mut tokio::sync::mpsc::Receiver<ClientMessage>,
) -> anyhow::Result<()> {
let (mut write, mut read) = ws_stream.split();
let hello = ClientMessage::Hello {
gateway_id: config.gateway_id.clone(),
farm_id: config.farm_id.clone(),
token: config.gateway_token.clone(),
stream_base_url: config.stream_base_url.clone(),
};
write.send(Message::Text(serde_json::to_string(&hello)?)).await?;
let mut heartbeat = tokio::time::interval(HEARTBEAT_INTERVAL);
let mut telemetry = tokio::time::interval(TELEMETRY_INTERVAL);
let mut seq: u64 = 0;
loop {
@@ -57,11 +224,95 @@ async fn connect_and_serve(config: &Config) -> anyhow::Result<()> {
write.send(Message::Text(serde_json::to_string(&msg)?)).await?;
}
_ = telemetry.tick() => {
// Read first, then ask for the next one. A v1 Bambu answers
// `refresh_status` over MQTT some time after this returns, so
// collecting first means every tick reports the previous
// tick's refresh - one interval of lag, rather than a
// guaranteed miss from reading before the answer could
// possibly have arrived.
let printers = collect_telemetry(fleet).await;
refresh_fleet(fleet).await;
// Nothing to say is not worth a frame. Happens whenever no
// printer has reported yet - every vendor but Bambu is still
// a stub, so a Prusa-only fleet is silent here by design.
if !printers.is_empty() {
let msg = ClientMessage::Telemetry { printers };
write.send(Message::Text(serde_json::to_string(&msg)?)).await?;
}
}
// Whatever a task elsewhere wants said. Taken from the queue
// before it is written, so a send that fails here loses the
// message - the connection is going down anyway, and the
// alternative (peek, write, then pop) buys little for a message
// that is already best-effort diagnostics.
Some(msg) = inbox.recv() => {
write.send(Message::Text(serde_json::to_string(&msg)?)).await?;
}
frame = read.next() => {
match frame {
Some(Ok(Message::Text(text))) => {
if let Ok(server_msg) = serde_json::from_str::<ServerMessage>(&text) {
info!(?server_msg, "received from cloud");
match serde_json::from_str::<ServerMessage>(&text) {
Ok(ServerMessage::Command { command_id, printer_id, command }) => {
// `start_print` is the one command that cannot
// be answered inline. See `spawn_long_command`.
let msg = if command.runs_for_minutes() {
spawn_long_command(
fleet.clone(),
printer_id.clone(),
command,
command_id.clone(),
outbound.clone(),
);
ClientMessage::CommandResult {
command_id, ok: true, error: None,
}
} else {
match dispatch(fleet, &printer_id, command).await {
Ok(()) => ClientMessage::CommandResult {
command_id, ok: true, error: None,
},
Err(err) => {
warn!(%printer_id, %err, "command failed");
ClientMessage::CommandResult {
command_id, ok: false, error: Some(err),
}
}
}
};
write.send(Message::Text(serde_json::to_string(&msg)?)).await?;
}
// Retrying will not help: the token is wrong, not
// the network. Logged at error level and returned
// so it is obvious which of the two it is.
Ok(ServerMessage::HelloError { message }) => {
tracing::error!(%message, "cloud rejected this gateway's handshake - check CONTINUUM_GATEWAY_TOKEN");
return Ok(());
}
// Spawned, never awaited inline: go2rtc gathers
// ICE candidates before answering, which
// includes a STUN round trip, so this takes
// seconds. Awaiting it here would stall
// telemetry and heartbeats for the whole farm
// while one person opened one camera.
Ok(ServerMessage::WebrtcOffer { session_id, camera_stream, sdp }) => {
spawn_webrtc_negotiation(
config.go2rtc_api_listen.clone(),
session_id,
camera_stream,
sdp,
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"),
}
}
Some(Ok(Message::Close(_))) | None => return Ok(()),
@@ -72,3 +323,224 @@ async fn connect_and_serve(config: &Config) -> anyhow::Result<()> {
}
}
}
/// Asks every printer to report in full, for the next tick to pick up. Failure
/// is logged and otherwise ignored: a printer that cannot be asked right now
/// is a printer whose telemetry goes stale, not a reason to tear down the
/// cloud connection for the whole farm.
async fn refresh_fleet(fleet: &SharedFleet) {
let fleet = fleet.lock().await;
for printer in fleet.iter() {
if let Err(err) = printer.refresh_status().await {
warn!(printer = %printer.base().id, %err, "failed to request a status refresh");
}
}
}
/// One snapshot per printer that has something to report. Translates Bambu's
/// `print.push_status` into the vendor-neutral shape the cloud consumes - see
/// `PrinterTelemetry` for why this isn't just forwarded raw.
async fn collect_telemetry(fleet: &SharedFleet) -> Vec<PrinterTelemetry> {
let fleet = fleet.lock().await;
let mut out = Vec::new();
for printer in fleet.iter() {
let base = printer.base();
let Some(report) = printer.print_status().await else {
continue;
};
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()),
// 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
// there. Asking `camera` rather than keeping a second copy of
// 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()),
)
.map(|source| source.stream_name),
});
}
out
}
/// Runs a command that takes minutes, off this connection's task, and answers
/// the cloud immediately.
///
/// Everything else here is an MQTT publish that returns in milliseconds, so
/// awaiting it inline is fine. `start_print` is not: it downloads tens of
/// megabytes and then transfers them to a printer over FTPS at a measured
/// 0.2 MiB/s, which is about four minutes for a 50 MB project. Awaited
/// inline, that blocks this whole `select!` loop - no heartbeats and, far
/// worse, **no telemetry** for the duration.
///
/// No telemetry is the one that makes this mandatory rather than tidy. The
/// cloud 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 is
/// withholding exactly the information the job depends on, while the backend
/// is also timing the command out after ten seconds and concluding the start
/// failed.
///
/// So the `ok` this produces means "accepted, and working on it" - which is
/// all the cloud ever trusted a start reply to mean anyway. *That* a failure
/// after this point happened is still noticed by the cloud on its own: the
/// job never reaches `printing` and the backend reaps it. What used to be
/// missing was *why*, which lived only in this gateway's log - on a machine
/// in someone else's workshop.
///
/// So a late failure now goes back over the outbound queue as a second
/// `CommandResult` for the same `command_id`. Be clear about what the backend
/// does with it: that command is long since resolved, so it is not awaiting
/// an answer and the result does not change the job's fate. It is logged with
/// its reason, next to the gateway and farm that produced it, which is the
/// difference between "job failed" and "job failed: filament mapping for
/// plate 1 names a slot the AMS does not have". Attaching it to the job row
/// needs a column `print_jobs` does not have yet.
///
/// Nothing is sent on success - the telemetry that follows says so better
/// than a message could, and a result for an already-resolved command is
/// noise unless it carries news.
fn spawn_long_command(
fleet: SharedFleet,
printer_id: String,
command: PrinterCommand,
command_id: String,
outbound: tokio::sync::mpsc::Sender<ClientMessage>,
) {
tokio::spawn(async move {
match dispatch(&fleet, &printer_id, command).await {
Ok(()) => info!(%printer_id, "long-running command finished"),
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
// survives a gateway with no internet.
tracing::error!(
%printer_id, %err,
"long-running command failed after the cloud was already told it was accepted"
);
let late = ClientMessage::CommandResult {
command_id,
ok: false,
error: Some(err),
};
// `send` waits for room rather than dropping, and only fails
// if the receiver is gone - which means `run` has returned,
// i.e. the process is shutting down and there is nobody left
// to tell.
if outbound.send(late).await.is_err() {
warn!(%printer_id, "uplink is shutting down; the failure reason was not reported");
}
}
}
});
}
/// Answers one WebRTC offer, off this connection's task.
///
/// Always answers, success or failure: the cloud is holding a browser's
/// request open waiting for this, and a silent failure there is a viewer
/// watching a video element that never loads until something times out.
fn spawn_webrtc_negotiation(
api_listen: String,
session_id: String,
camera_stream: String,
offer_sdp: String,
outbound: tokio::sync::mpsc::Sender<ClientMessage>,
) {
tokio::spawn(async move {
let reply = match webrtc::negotiate(&api_listen, &camera_stream, &offer_sdp).await {
Ok(sdp) => ClientMessage::WebrtcAnswer { session_id, sdp },
Err(err) => {
// Logged as well as returned. The cloud gets the reason, but
// this is the copy that survives a gateway whose uplink is
// about to drop.
warn!(%camera_stream, %err, "could not negotiate a camera stream");
ClientMessage::WebrtcError { session_id, message: err.to_string() }
}
};
if outbound.send(reply).await.is_err() {
warn!(%camera_stream, "uplink is shutting down; the viewer gets no answer");
}
});
}
/// 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.
async fn dispatch(fleet: &SharedFleet, printer_id: &str, command: PrinterCommand) -> Result<(), String> {
// Fetched before the fleet lock is taken: a download is bounded by
// minutes of network time, and holding the lock across it would stall
// telemetry for every other printer. The transfer to the printer that
// follows has to hold it (it needs `&mut` access), but that is no reason
// to hold it through this part too. A `start_print` for a printer that
// does not exist is therefore found out after the download rather than
// before; the lookup below is cheap, so it is done first for that case.
let downloaded = match &command {
PrinterCommand::StartPrint { url, .. } => {
if !fleet.lock().await.iter().any(|p| p.base().id == printer_id) {
return Err(format!("no printer with id '{printer_id}' in this gateway's fleet"));
}
Some(
download::fetch_job_file(url)
.await
.map_err(|err| format!("download failed (nothing was sent to the printer): {err}"))?,
)
}
_ => None,
};
let mut fleet = fleet.lock().await;
let printer = fleet
.iter_mut()
.find(|p| p.base().id == printer_id)
.ok_or_else(|| format!("no printer with id '{printer_id}' in this gateway's fleet"))?;
info!(%printer_id, ?command, "dispatching cloud command");
match command {
PrinterCommand::SetFanSpeed { percent } => {
printer.set_fan_speed(percent).await.map_err(|err| err.to_string())
}
PrinterCommand::SendGcodeLine { line } => {
printer.send_gcode_line(line).await.map_err(|err| err.to_string())
}
PrinterCommand::ControlPrint { control } => {
printer.control_print(control).await.map_err(|err| err.to_string())
}
PrinterCommand::StartPrint { job_id, file_name, .. } => {
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");
// One call covers deriving the filament mapping, the FTPS
// transfer, the truncation check and the start, and reports them
// 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
.upload_project_file(file_name, bytes)
.await
.map_err(|err| format!("printer upload/start failed (download of {byte_count} bytes succeeded): {err}"))
}
}
}
+298 -1
View File
@@ -1,11 +1,196 @@
use serde::{Deserialize, Serialize};
use crate::printer::PrintControl;
/// What this gateway sends up to `continuum-backend`'s `/ws/edge/v1` route.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientMessage {
Hello { gateway_id: String, farm_id: String, token: String },
/// The handshake, carrying no credential.
///
/// The edge token goes in an `Authorization: Bearer` header on the
/// upgrade request instead - see `connect_and_serve`. It used to be a
/// `token` field right here, which worked but meant the backend had an
/// open socket and a parsed message from a peer that had proved nothing.
///
/// `farm_id` stays, even though the token already carries the farm: the
/// backend checks the two against each other rather than trusting this
/// one, so sending it is how a configuration mismatch gets caught
/// instead of silently working against the wrong farm.
Hello {
gateway_id: String,
farm_id: String,
/// Where a browser on this LAN can reach the gateway's camera
/// streams, if the operator has configured one. See
/// `Config::stream_base_url`.
///
/// Reported in the handshake rather than per-printer in telemetry
/// because it is a property of the gateway, and repeating it in
/// every snapshot of every printer would be the same string sent
/// hundreds of times an hour.
#[serde(skip_serializing_if = "Option::is_none")]
stream_base_url: Option<String>,
},
Heartbeat { seq: u64 },
/// 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
/// wants the lowest possible latency per machine.
Telemetry { printers: Vec<PrinterTelemetry> },
/// The reply to a `ServerMessage::Command`. Correlated by `command_id`,
/// which the *cloud* generates - unlike Bambu's `sequence_id`, we're
/// both ends of this protocol, so the side that needs the correlation
/// gets to choose it.
CommandResult {
command_id: String,
ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
},
/// go2rtc's answer SDP for a `WebrtcOffer`, passed through verbatim.
WebrtcAnswer { session_id: String, sdp: String },
/// No answer was obtainable: no such camera here, go2rtc not running, or
/// it refused the offer.
///
/// Its own variant rather than an empty answer, because the cloud is
/// 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 },
/// 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
/// actually displays - deliberately not Bambu's `print.push_status` shape.
///
/// Two reasons for the translation rather than forwarding the raw report:
/// this has to describe a Prusa or a Klipper printer too, and Bambu's wire
/// format has traps that should not reach the rest of the system (fan
/// speeds are 0-15 gears, temperatures arrive as either integers or floats,
/// and v2 reuses one `sequence_id` forever - see `PrintReport`).
///
/// Every field is optional because an unreachable or just-connected printer
/// genuinely has nothing to report, and "no data yet" is different from
/// "zero".
///
/// Absent fields are *omitted* rather than serialized as `null`, which is
/// what `skip_serializing_if` below is for. Caught by the contract test
/// against real captures: serde's default is to emit `"job_name": null`, and
/// the backend's validator reads a missing key and an explicit null as
/// different things, so an idle printer's entire telemetry batch was being
/// rejected. The backend now accepts either, but this end sends only the one
/// the schema describes.
#[derive(Debug, Clone, Serialize)]
pub struct PrinterTelemetry {
/// Our own id from printers.toml, not the vendor serial - this is what
/// the cloud's `printers` table keys on.
pub printer_id: String,
pub name: String,
/// `"IDLE"`, `"RUNNING"`, `"PAUSE"`, `"FINISH"`, `"FAILED"` - passed
/// through as the printer's own string rather than narrowed to an enum,
/// so an unrecognised state reaches the cloud as itself instead of being
/// dropped on the floor here.
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub percent: Option<i32>,
/// What the printer is doing when it is not laying down plastic - bed
/// levelling, nozzle cleaning, extrusion calibration. Bambu's `stg_cur`,
/// passed through as the printer's own number rather than decoded,
/// because the code-to-name table is not something we have. 0 means
/// printing.
///
/// Here because progress without it is ambiguous in a way that reads as
/// a fault. Confirmed on a P1P (2026-10-11): `percent` reached 14% while
/// `layer` stayed 0 for minutes, the printer walking `stg_cur` 54 -> 14
/// -> 13 -> 0 before starting layer 1.
#[serde(skip_serializing_if = "Option::is_none")]
pub stage: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub layer: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub total_layers: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub remaining_minutes: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub job_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nozzle_temp: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nozzle_target: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bed_temp: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bed_target: Option<f32>,
/// Non-zero means the printer is reporting a fault.
#[serde(skip_serializing_if = "Option::is_none")]
pub print_error: Option<i32>,
/// How many Health-Management-System entries the printer currently has
/// active. Just the count for now - decoding the `{attr, code}` pairs
/// into messages needs Bambu's HMS tables, which we don't have yet.
#[serde(skip_serializing_if = "Option::is_none")]
pub active_alerts: Option<usize>,
/// The go2rtc stream name for this printer's camera, if it has one that
/// can actually be played. Combined with the farm's `stream_base_url`
/// this is everything a browser needs.
///
/// Absent is the common case, not the exception: only the X1/H2 class
/// publishes an RTSP URL. A P1 or A1 reports that it *has* a camera
/// without giving anything to connect to, and this stays `None` for
/// those - a UI should offer a player only when this is present. See
/// `crate::camera`.
#[serde(skip_serializing_if = "Option::is_none")]
pub camera_stream: Option<String>,
}
/// What `continuum-backend` sends back over the same connection.
@@ -13,5 +198,117 @@ pub enum ClientMessage {
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerMessage {
HelloAck { session_id: String },
/// The handshake was rejected - a bad, expired or wrong-farm token. The
/// backend closes the connection right after sending this.
///
/// Worth a variant of its own rather than letting it fall into the
/// unparseable-message branch: retrying a rejected token every few seconds
/// forever looks exactly like a network problem in the logs otherwise, and
/// it is the one uplink failure a human has to go fix by hand.
HelloError { message: String },
HeartbeatAck { seq: u64 },
/// Do something to one printer. `printer_id` is our own id from
/// printers.toml.
Command { command_id: String, printer_id: String, command: PrinterCommand },
/// Relay a browser's WebRTC offer to this gateway's go2rtc and answer
/// with the SDP it returns. **Signalling only - no video on this
/// socket.**
///
/// Media goes browser-to-go2rtc directly over ICE, which is the whole
/// reason only the signalling is relayed: on a workshop LAN the video
/// never leaves the building, and it costs the cloud no bandwidth at
/// all. go2rtc's answer carries a server-reflexive candidate alongside
/// its host ones, so even a remote viewer can often reach the gateway
/// without a relay.
///
/// The browser cannot do this part itself. go2rtc sends no CORS headers
/// 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 },
}
/// The closed set of things the cloud is allowed to ask a printer to do.
///
/// Deliberately small. `StartPrint` is the newest and the only one that
/// reaches outside the gateway (it fetches a URL), so it is the one with
/// bounds on it - see `download.rs`.
#[derive(Clone, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum PrinterCommand {
/// 0-100. Each vendor converts to whatever its own mechanism wants (for
/// Bambu, an `M106` PWM value over MQTT).
SetFanSpeed { percent: u8 },
/// A single raw gcode line. The most powerful variant by far, and the
/// one to be most careful about ever exposing to a UI.
SendGcodeLine { line: String },
/// Pause, resume or stop whatever the printer is currently running.
///
/// One variant carrying an action rather than three variants, matching
/// `PrintControl` and `GenericPrinter::control_print` - the alternative
/// was three near-identical arms here and three near-identical methods
/// across four vendor implementations.
///
/// `Stop` is a cancel and is not undoable. Confirmed live on a P1P: a
/// stop during `PREPARE`/`RUNNING` moves the printer to `FAILED`, which
/// is how Bambu reports an operator-cancelled print.
ControlPrint { control: PrintControl },
/// Download a job's file, put it on the printer and start it.
///
/// A successful result means the gateway *accepted* the job, not that
/// anything is printing: a real run on a P1P was accepted, uploaded
/// byte-perfect, reached RUNNING, then paused itself on a filament
/// fault. Job state must follow the telemetry, never this reply.
///
/// There is no AMS or filament field on purpose. The mapping is read out
/// of the archive's own `Metadata/slice_info.config` by
/// `upload_project_file`, because only the archive knows what each plate
/// was sliced for - guessing it is what heated a P1P to 250C and paused
/// it at 1%.
///
/// `url` is a presigned, expiring GET, minted at dispatch time (a queued
/// job may wait days). It is a URL and not the bytes so the file stays
/// off the JSON control channel and object-store credentials stay out of
/// every gateway. It is a bearer credential, which is why `Debug` below
/// 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 },
}
impl PrinterCommand {
/// Whether this command takes long enough that it must not be awaited on
/// the uplink's own task.
///
/// The threshold is not arbitrary: everything but `StartPrint` is a single
/// MQTT publish, while `StartPrint` downloads a project and then transfers
/// it to a printer at a measured 0.2 MiB/s - minutes, during which the
/// uplink would send no telemetry at all. See `spawn_long_command`.
pub fn runs_for_minutes(&self) -> bool {
// Written as an exhaustive match rather than `matches!`, so a new
// variant has to make this decision rather than defaulting to
// "answer it inline" and quietly reintroducing the stall.
match self {
Self::StartPrint { .. } => true,
Self::SetFanSpeed { .. } | Self::SendGcodeLine { .. } | Self::ControlPrint { .. } => false,
}
}
}
/// Hand-written so the presigned `url` never reaches a log line: `dispatch`
/// logs every command with `{:?}`, and the query string of that URL is
/// enough to download the job file until it expires.
impl std::fmt::Debug for PrinterCommand {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::SetFanSpeed { percent } => f.debug_struct("SetFanSpeed").field("percent", percent).finish(),
Self::SendGcodeLine { line } => f.debug_struct("SendGcodeLine").field("line", line).finish(),
Self::ControlPrint { control } => f.debug_struct("ControlPrint").field("control", control).finish(),
Self::StartPrint { job_id, file_name, .. } => f
.debug_struct("StartPrint")
.field("job_id", job_id)
.field("file_name", file_name)
.field("url", &"<redacted>")
.finish(),
}
}
}
+166
View File
@@ -0,0 +1,166 @@
//! WebRTC signalling, relayed from the cloud to this gateway's own go2rtc.
//!
//! Only the signalling. The video never touches this process or the cloud:
//! once the browser has go2rtc's answer, the two negotiate media directly
//! over ICE. On a workshop LAN that means the stream never leaves the
//! building and costs the cloud nothing, which is the entire reason the
//! relay carries SDP rather than bytes.
//!
//! ## Why the cloud has to be in the middle at all
//!
//! A browser cannot signal to go2rtc itself. Measured against go2rtc 1.9.14:
//! it returns no `Access-Control-Allow-Origin` on any response, and it checks
//! its API password *before* CORS, so the unauthenticated preflight a browser
//! always sends gets a 401. A gateway is also behind a workshop NAT, so the
//! cloud cannot dial in either - the only way through is the connection this
//! gateway already opened outbound.
//!
//! ## Why no credential appears here
//!
//! The request goes to `127.0.0.1`, and go2rtc exempts loopback from its API
//! password. That is a measured property, not an assumption, and it is also
//! why a probe from the gateway itself cannot be used to check that the
//! password is working - test from another host for that.
use std::time::Duration;
/// go2rtc answers an offer after gathering candidates, which includes a STUN
/// round trip to discover its public address, so this is not instant. Still
/// bounded: a viewer waiting on a dead go2rtc should be told, and the cloud
/// is holding a request open for this.
const NEGOTIATION_TIMEOUT: Duration = Duration::from_secs(8);
/// Turns go2rtc's *bind* address into a loopback URL we can call it on.
///
/// `CONTINUUM_GO2RTC_API_LISTEN` is a listen address - `:1984`,
/// `0.0.0.0:1984` - and neither is something to send a request to. Only the
/// port is portable, and loopback is the address we want regardless: it is
/// the one go2rtc exempts from its password.
fn local_api_base(api_listen: &str) -> anyhow::Result<String> {
let port = api_listen
.rsplit_once(':')
.map(|(_, port)| port)
.unwrap_or(api_listen)
.trim();
if port.is_empty() || !port.chars().all(|c| c.is_ascii_digit()) {
anyhow::bail!("cannot tell which port go2rtc's API is on from '{api_listen}'");
}
Ok(format!("http://127.0.0.1:{port}"))
}
/// Rejects a stream name that could not be a go2rtc stream name.
///
/// It goes into a query string, and this is cheaper and clearer than
/// percent-encoding: a real name is a printer id from `printers.toml`, so
/// anything outside this set is a bug or an attempt at one, and refusing it
/// says so instead of forwarding something odd to go2rtc.
fn checked_stream_name(name: &str) -> anyhow::Result<&str> {
if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) {
anyhow::bail!("'{name}' is not a usable go2rtc stream name");
}
Ok(name)
}
/// Flattens an error's `source` chain into one line.
///
/// Needed because reqwest's own `Display` is just "error sending request for
/// url (...)" - the reason lives one or two sources down, and "connection
/// refused" versus "timed out" is the whole content of the message. This
/// error travels to the cloud and then to a viewer, so it is the only
/// explanation anyone gets.
fn with_causes(err: &(dyn std::error::Error + 'static)) -> String {
let mut out = err.to_string();
let mut source = err.source();
while let Some(cause) = source {
let text = cause.to_string();
// Skip a cause that only repeats what we already said, which hyper's
// wrappers do.
if !out.contains(&text) {
out.push_str(": ");
out.push_str(&text);
}
source = cause.source();
}
out
}
/// Posts the browser's offer to go2rtc and returns its answer SDP.
pub async fn negotiate(api_listen: &str, camera_stream: &str, offer_sdp: &str) -> anyhow::Result<String> {
let base = local_api_base(api_listen)?;
let stream = checked_stream_name(camera_stream)?;
let response = reqwest::Client::new()
.post(format!("{base}/api/webrtc?src={stream}"))
.header("Content-Type", "application/sdp")
.body(offer_sdp.to_string())
.timeout(NEGOTIATION_TIMEOUT)
.send()
.await
.map_err(|err| {
// Names go2rtc explicitly. The most likely cause by far is that
// it is not running, and an error mentioning only a port sends
// whoever reads it looking at the network.
anyhow::anyhow!("could not reach go2rtc at {base}: {}", with_causes(&err))
})?;
// go2rtc answers 201 rather than 200 here, so this checks the class and
// not a single code.
let status = response.status();
if !status.is_success() {
// Its body is the useful half - "streams: wrong url" for a camera it
// does not have, say - and a bare status would send whoever is
// reading the logs looking at the network instead.
let body = response.text().await.unwrap_or_default();
let detail = body.trim();
anyhow::bail!(
"go2rtc refused the offer for '{stream}' (HTTP {}){}",
status.as_u16(),
if detail.is_empty() { String::new() } else { format!(": {detail}") }
);
}
let answer = response.text().await?;
if answer.trim().is_empty() {
anyhow::bail!("go2rtc accepted the offer for '{stream}' but returned an empty answer");
}
Ok(answer)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_bind_address_becomes_a_loopback_url() {
// The three shapes an operator actually writes, all of which have to
// end up at loopback - that is the address go2rtc does not demand a
// password on.
for listen in [":1984", "0.0.0.0:1984", "127.0.0.1:1984"] {
assert_eq!(local_api_base(listen).unwrap(), "http://127.0.0.1:1984");
}
}
#[test]
fn a_bind_address_with_no_port_is_refused_rather_than_guessed() {
// Guessing 1984 would produce a gateway that silently signals to the
// wrong place when someone has moved the port.
for listen in ["", "localhost", ":", "1984a", ":http"] {
assert!(local_api_base(listen).is_err(), "{listen:?} should be refused");
}
}
#[test]
fn a_stream_name_that_could_break_out_of_the_query_is_refused() {
for name in ["", "h2c&src=other", "h2c?x", "h2c/../y", "h2c one"] {
assert!(checked_stream_name(name).is_err(), "{name:?} should be refused");
}
}
#[test]
fn a_real_printer_id_is_accepted() {
for name in ["h2c", "p1p", "printer-2", "bay_3", "x1.c"] {
assert_eq!(checked_stream_name(name).unwrap(), name);
}
}
}
+257
View File
@@ -0,0 +1,257 @@
//! Proves the blocks `discovery` prints are blocks `fleet` can load.
//!
//! The two are coupled by nothing but a string - `Vendor::toml_value` has to
//! match what `fleet::build_printer` matches on, and the fields rendered have
//! to be the fields it requires. Unit tests on either side cannot catch a
//! drift between them, and the symptom of a drift is the worst kind: an
//! operator pastes in exactly what they were told to and the gateway refuses
//! to start.
//!
//! So this renders a candidate for every vendor, fills in the credential the
//! way a human would, and loads the result for real.
use std::io::Write;
use continuum_proxy::discovery::{Candidate, Source, Vendor};
use continuum_proxy::fleet;
use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
/// Fills in the credential a human is told to supply, so the entry loads.
fn with_credentials(entry: &str) -> String {
entry
.replace("access_code = \"\"", "access_code = \"12345678\"")
.replace("api_key = \"\"", "api_key = \"an-api-key\"")
}
fn load(toml: &str) -> anyhow::Result<Vec<PrinterHandle>> {
let mut file = tempfile();
file.write_all(toml.as_bytes()).expect("write the fleet file");
fleet::load(&file.path)
}
/// A self-deleting temporary file, rather than a dependency for four lines.
struct TempToml {
path: std::path::PathBuf,
handle: Option<std::fs::File>,
}
impl TempToml {
fn write_all(&mut self, bytes: &[u8]) -> std::io::Result<()> {
self.handle.as_mut().expect("still open").write_all(bytes)?;
self.handle.as_mut().expect("still open").flush()
}
}
impl Drop for TempToml {
fn drop(&mut self) {
self.handle.take();
let _ = std::fs::remove_file(&self.path);
}
}
fn tempfile() -> TempToml {
// The pid and a counter, so two tests in one binary cannot collide.
use std::sync::atomic::{AtomicUsize, Ordering};
static NEXT: AtomicUsize = AtomicUsize::new(0);
let path =
std::env::temp_dir().join(format!("continuum-fleet-{}-{}.toml", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
let handle = std::fs::File::create(&path).expect("create a temp fleet file");
TempToml { path, handle: Some(handle) }
}
fn candidate(vendor: Vendor, host: &str) -> Candidate {
let mut candidate = Candidate::new(host, vendor, Source::HttpApi);
// Bambu needs one, and for every other vendor it is ignored.
candidate.serial = Some("01S09B441900143".to_string());
candidate
}
#[test]
fn every_rendered_entry_loads_as_the_printer_it_claims_to_be() {
let cases = [
(Vendor::Bambu, "192.168.0.20"),
(Vendor::Klipper, "192.168.0.50"),
(Vendor::PrusaLink, "192.168.0.51"),
(Vendor::Octoprint, "192.168.0.60"),
(Vendor::PrusaSerial, "/dev/ttyACM0"),
];
for (vendor, host) in cases {
let entry = candidate(vendor, host).to_toml_entry("found");
let fleet = load(&with_credentials(&entry))
.unwrap_or_else(|err| panic!("{vendor:?} rendered an entry fleet::load rejects: {err}\n{entry}"));
assert_eq!(fleet.len(), 1, "{vendor:?}");
assert_eq!(fleet[0].base().id, "found", "{vendor:?}");
// The vendor string has to resolve to the matching handle, not just
// to *a* handle - `octoprint` and `prusa_link` share a client, so a
// mapping that collapsed them would still load and still be wrong.
let matched = matches!(
(vendor, &fleet[0]),
(Vendor::Bambu, PrinterHandle::Bambu(_))
| (Vendor::Klipper, PrinterHandle::Klipper(_))
| (Vendor::PrusaLink | Vendor::Octoprint, PrinterHandle::OctoPrint(_))
| (Vendor::PrusaSerial, PrinterHandle::PrusaSerial(_))
);
assert!(matched, "{vendor:?} loaded as the wrong handle");
}
}
/// The flavour is the operator's answer to a question no probe can settle, so
/// it has to survive the trip from the file into the printer.
#[test]
fn the_octoprint_flavour_an_operator_chose_reaches_the_client() {
use continuum_proxy::printer::OctoPrintFlavour;
for (vendor, expected) in
[(Vendor::Octoprint, OctoPrintFlavour::Generic), (Vendor::PrusaLink, OctoPrintFlavour::PrusaLink)]
{
let entry = with_credentials(&candidate(vendor, "192.168.0.51").to_toml_entry("found"));
let fleet = load(&entry).expect("loads");
let PrinterHandle::OctoPrint(printer) = &fleet[0] else {
panic!("{vendor:?} should load as an OctoPrint-family client");
};
assert_eq!(printer.flavour, expected, "{vendor:?}");
}
}
/// An entry straight out of discovery, credential still blank, must be
/// refused - and refused in a way that names the printer and the field.
#[test]
fn an_entry_with_the_credential_left_blank_is_refused_by_name() {
for vendor in [Vendor::Bambu, Vendor::PrusaLink, Vendor::Octoprint] {
let entry = candidate(vendor, "192.168.0.20").to_toml_entry("unfinished");
let Err(err) = load(&entry) else {
panic!("{vendor:?}: a blank credential must not load:\n{entry}");
};
let message = err.to_string();
// This is the whole reason discovery prints instead of writing the
// file: the failure is loud, and it says which printer and which
// field.
assert!(message.contains("unfinished"), "{vendor:?}: {message}");
assert!(
message.contains("access_code") || message.contains("api_key"),
"{vendor:?} must name the missing field: {message}"
);
}
}
/// 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}");
}
Binary file not shown.
+220
View File
@@ -0,0 +1,220 @@
{
"upgrade_state": {
"sequence_id": 0,
"progress": "",
"status": "IDLE",
"consistency_request": false,
"dis_state": 0,
"err_code": 0,
"force_upgrade": false,
"message": "0%, 0B/s",
"module": "",
"new_version_state": 0,
"cur_state_code": 0,
"idx2": 4275297388,
"new_ver_list": []
},
"ipcam": {
"ipcam_dev": "1",
"ipcam_record": "enable",
"timelapse": "disable",
"resolution": "",
"tutk_server": "disable",
"mode_bits": 3
},
"upload": {
"status": "idle",
"progress": 0,
"message": ""
},
"net": {
"conf": 0,
"info": [
{
"ip": 0,
"mask": 0
}
]
},
"nozzle_temper": 22.125,
"nozzle_target_temper": 0,
"bed_temper": 21.34375,
"bed_target_temper": 0,
"chamber_temper": 5,
"mc_print_stage": "1",
"heatbreak_fan_speed": "15",
"cooling_fan_speed": "0",
"big_fan1_speed": "0",
"big_fan2_speed": "0",
"mc_percent": 0,
"mc_remaining_time": 0,
"ams_status": 0,
"ams_rfid_status": 0,
"hw_switch_state": 0,
"spd_mag": 100,
"spd_lvl": 2,
"print_error": 0,
"lifecycle": "product",
"wifi_signal": "-64dBm",
"gcode_state": "IDLE",
"gcode_file_prepare_percent": "0",
"queue_number": 0,
"queue_total": 0,
"queue_est": 0,
"queue_sts": 0,
"project_id": "0",
"profile_id": "0",
"task_id": "0",
"subtask_id": "0",
"subtask_name": "",
"gcode_file": "",
"stg": [],
"stg_cur": 0,
"print_type": "idle",
"home_flag": 158549400,
"mc_print_line_number": "0",
"mc_print_sub_stage": 0,
"sdcard": true,
"force_upgrade": false,
"mess_production_state": "active",
"layer_num": 0,
"total_layer_num": 0,
"s_obj": [],
"filam_bak": [],
"fan_gear": 0,
"nozzle_diameter": "0.4",
"nozzle_type": "stainless_steel",
"cali_version": 0,
"k": "0.0000",
"flag3": 655,
"hms": [],
"online": {
"ahb": false,
"rfid": false,
"version": 1849554829
},
"ams": {
"ams": [
{
"chip_id": "<scrubbed>",
"ams_id": "<scrubbed>",
"check": 1,
"id": "0",
"humidity": "2",
"humidity_raw": "41",
"temp": "23.0",
"dry_time": 0,
"info": "1001",
"tray": [
{
"id": "0"
},
{
"id": "1"
},
{
"id": "2",
"state": 3,
"remain": -1,
"k": 0.03999999910593033,
"n": 1,
"cali_idx": -1,
"total_len": 330000,
"tag_uid": "<scrubbed>",
"tray_id_name": "",
"tray_info_idx": "GFG99",
"tray_type": "PETG",
"tray_sub_brands": "",
"tray_color": "161616FF",
"tray_weight": "0",
"tray_diameter": "0.00",
"tray_temp": "0",
"tray_time": "0",
"bed_temp_type": "0",
"bed_temp": "0",
"nozzle_temp_max": "270",
"nozzle_temp_min": "220",
"xcam_info": "000000000000000000000000",
"tray_uuid": "<scrubbed>",
"ctype": 0,
"cols": [
"161616FF"
]
},
{
"id": "3",
"state": 3,
"remain": -1,
"k": 0.03999999910593033,
"n": 1,
"cali_idx": -1,
"total_len": 330000,
"tag_uid": "<scrubbed>",
"tray_id_name": "",
"tray_info_idx": "GFG99",
"tray_type": "PETG",
"tray_sub_brands": "",
"tray_color": "2850E0FF",
"tray_weight": "0",
"tray_diameter": "0.00",
"tray_temp": "0",
"tray_time": "0",
"bed_temp_type": "0",
"bed_temp": "0",
"nozzle_temp_max": "270",
"nozzle_temp_min": "220",
"xcam_info": "000000000000000000000000",
"tray_uuid": "<scrubbed>",
"ctype": 0,
"cols": [
"2850E0FF"
]
}
]
}
],
"ams_exist_bits": "1",
"tray_exist_bits": "c",
"tray_is_bbl_bits": "c",
"tray_tar": "255",
"tray_now": "255",
"tray_pre": "255",
"tray_read_done_bits": "c",
"tray_reading_bits": "0",
"version": 2,
"insert_flag": false,
"power_on_flag": false
},
"vt_tray": {
"id": "254",
"tag_uid": "<scrubbed>",
"tray_id_name": "",
"tray_info_idx": "P9599a25",
"tray_type": "PETG",
"tray_sub_brands": "",
"tray_color": "D3C5A3FF",
"tray_weight": "0",
"tray_diameter": "0.00",
"tray_temp": "0",
"tray_time": "0",
"bed_temp_type": "0",
"bed_temp": "0",
"nozzle_temp_max": "240",
"nozzle_temp_min": "225",
"xcam_info": "000000000000000000000000",
"tray_uuid": "<scrubbed>",
"remain": 0,
"k": 0.019999999552965164,
"n": 1,
"cali_idx": -1
},
"lights_report": [
{
"node": "chamber_light",
"mode": "off"
}
],
"command": "push_status",
"msg": 0,
"sequence_id": "53"
}
+846
View File
@@ -0,0 +1,846 @@
{
"2D": {
"bs": {
"bi": [
{
"clock_in": false,
"est_time": 0,
"idx": 0,
"print_then": false,
"proc_list": [],
"step_type": 1,
"tool_info": {
"color": "",
"diameter": 0.4000000059604645,
"id": 8978432
},
"type": 137
}
],
"total_time": 0
},
"cond": 14,
"cur_stage": {
"clock_in_time": 0,
"idx": 0,
"left_time": 0,
"process": 0,
"state": 0
},
"first_confirm": false,
"makeable": false,
"material": {
"cur_id_list": [],
"state": 0,
"tar_id": "",
"tar_name": ""
},
"pm": 0
},
"3D": {
"layer_num": 2,
"print_cali_option": 2,
"total_layer_num": 50
},
"ams": {
"ams": [
{
"dry_time": 0,
"humidity": "2",
"humidity_raw": "34",
"id": "0",
"info": "1003",
"temp": "30.6",
"tray": [
{
"bed_temp": "0",
"bed_temp_type": "0",
"cali_idx": -1,
"cols": [
"2850E0FF"
],
"ctype": 0,
"drying_temp": "0",
"drying_time": "0",
"id": "0",
"nozzle_temp_max": "280",
"nozzle_temp_min": "240",
"remain": 0,
"state": 11,
"tag_uid": "<scrubbed>",
"total_len": 330000,
"tray_color": "2850E0FF",
"tray_diameter": "1.75",
"tray_id_name": "",
"tray_info_idx": "GFN99",
"tray_sub_brands": "",
"tray_type": "PA",
"tray_uuid": "<scrubbed>",
"tray_weight": "0",
"xcam_info": "000000000000000000000000"
},
{
"bed_temp": "0",
"bed_temp_type": "0",
"cali_idx": -1,
"cols": [
"7C4B00FF"
],
"ctype": 0,
"drying_temp": "0",
"drying_time": "0",
"id": "1",
"nozzle_temp_max": "300",
"nozzle_temp_min": "260",
"remain": 0,
"state": 11,
"tag_uid": "<scrubbed>",
"total_len": 330000,
"tray_color": "7C4B00FF",
"tray_diameter": "1.75",
"tray_id_name": "",
"tray_info_idx": "GFN08",
"tray_sub_brands": "",
"tray_type": "PA-GF",
"tray_uuid": "<scrubbed>",
"tray_weight": "0",
"xcam_info": "000000000000000000000000"
},
{
"bed_temp": "0",
"bed_temp_type": "0",
"cali_idx": -1,
"cols": [
"898989FF"
],
"ctype": 0,
"drying_temp": "0",
"drying_time": "0",
"id": "2",
"nozzle_temp_max": "290",
"nozzle_temp_min": "260",
"remain": 0,
"state": 11,
"tag_uid": "<scrubbed>",
"total_len": 330000,
"tray_color": "898989FF",
"tray_diameter": "1.75",
"tray_id_name": "",
"tray_info_idx": "GFC99",
"tray_sub_brands": "",
"tray_type": "PC",
"tray_uuid": "<scrubbed>",
"tray_weight": "0",
"xcam_info": "000000000000000000000000"
},
{
"bed_temp": "0",
"bed_temp_type": "0",
"cali_idx": -1,
"cols": [
"161616FF"
],
"ctype": 0,
"drying_temp": "0",
"drying_time": "0",
"id": "3",
"nozzle_temp_max": "290",
"nozzle_temp_min": "260",
"remain": 0,
"state": 11,
"tag_uid": "<scrubbed>",
"total_len": 330000,
"tray_color": "161616FF",
"tray_diameter": "1.75",
"tray_id_name": "",
"tray_info_idx": "GFC99",
"tray_sub_brands": "",
"tray_type": "PC",
"tray_uuid": "<scrubbed>",
"tray_weight": "0",
"xcam_info": "000000000000000000000000"
}
]
},
{
"dry_time": 0,
"humidity": "1",
"humidity_raw": "47",
"id": "1",
"info": "1003",
"temp": "27.1",
"tray": [
{
"bed_temp": "0",
"bed_temp_type": "0",
"cali_idx": -1,
"cols": [
"FFFFFF00"
],
"ctype": 0,
"drying_temp": "0",
"drying_time": "0",
"id": "0",
"nozzle_temp_max": "280",
"nozzle_temp_min": "240",
"remain": 0,
"state": 11,
"tag_uid": "<scrubbed>",
"total_len": 330000,
"tray_color": "FFFFFF00",
"tray_diameter": "1.75",
"tray_id_name": "",
"tray_info_idx": "GFB00",
"tray_sub_brands": "",
"tray_type": "ABS",
"tray_uuid": "<scrubbed>",
"tray_weight": "0",
"xcam_info": "000000000000000000000000"
},
{
"bed_temp": "0",
"bed_temp_type": "0",
"cali_idx": -1,
"cols": [
"FFF144FF"
],
"ctype": 0,
"drying_temp": "0",
"drying_time": "0",
"id": "1",
"nozzle_temp_max": "270",
"nozzle_temp_min": "220",
"remain": 0,
"state": 11,
"tag_uid": "<scrubbed>",
"total_len": 330000,
"tray_color": "FFF144FF",
"tray_diameter": "1.75",
"tray_id_name": "",
"tray_info_idx": "GFG99",
"tray_sub_brands": "",
"tray_type": "PETG",
"tray_uuid": "<scrubbed>",
"tray_weight": "0",
"xcam_info": "000000000000000000000000"
},
{
"id": "2",
"state": 10
},
{
"bed_temp": "0",
"bed_temp_type": "0",
"cali_idx": -1,
"cols": [
"898989FF"
],
"ctype": 0,
"drying_temp": "0",
"drying_time": "0",
"id": "3",
"nozzle_temp_max": "240",
"nozzle_temp_min": "190",
"remain": 0,
"state": 11,
"tag_uid": "<scrubbed>",
"total_len": 330000,
"tray_color": "898989FF",
"tray_diameter": "1.75",
"tray_id_name": "",
"tray_info_idx": "GFL95",
"tray_sub_brands": "",
"tray_type": "PLA",
"tray_uuid": "<scrubbed>",
"tray_weight": "0",
"xcam_info": "000000000000000000000000"
}
]
},
{
"dry_time": 0,
"humidity": "0",
"humidity_raw": "16",
"id": "128",
"info": "2104",
"temp": "41.5",
"tray": [
{
"id": "0",
"state": 10
}
]
}
],
"ams_exist_bits": "13",
"ams_exist_bits_raw": "13",
"cali_id": 255,
"cali_stat": 0,
"insert_flag": false,
"power_on_flag": false,
"tray_exist_bits": "bf",
"tray_is_bbl_bits": "bf",
"tray_now": "0",
"tray_pre": "0",
"tray_read_done_bits": "bf",
"tray_reading_bits": "0",
"tray_tar": "0",
"unbind_ams_stat": 0,
"version": 169763
},
"ams_rfid_status": 0,
"ams_status": 768,
"ap_err": 0,
"aux": "2001004",
"aux_part_fan": false,
"batch_id": 0,
"bed_target_temper": 35.0,
"bed_temper": 35.0,
"big_fan1_speed": "14",
"big_fan2_speed": "4",
"cali_version": 0,
"canvas_id": 0,
"care": [
{
"id": "ls",
"info": "1A00"
},
{
"id": "ld",
"info": "641864"
},
{
"id": "lr",
"info": "1A00"
},
{
"id": "hr",
"info": "5E185E"
}
],
"cfg": "685DDAB8",
"command": "push_status",
"cooling_fan_speed": "14",
"design_id": "",
"device": {
"airduct": {
"modeCur": 0,
"modeFunc": 0,
"modeList": [
{
"ctrl": [
16,
32,
48
],
"modeId": 0,
"off": [
96
]
},
{
"ctrl": [
16
],
"modeId": 1,
"off": [
32,
48
]
},
{
"ctrl": [
48
],
"modeId": 2,
"off": []
}
],
"modeVisable": 7,
"parts": [
{
"func": 0,
"id": 16,
"range": 6553600,
"state": 100,
"tar_state": 100
},
{
"func": 1,
"id": 32,
"range": 6553600,
"state": 100,
"tar_state": 100
},
{
"func": 2,
"id": 48,
"range": 6553600,
"state": 30,
"tar_state": 30
},
{
"func": 4,
"id": 96,
"range": 6553600,
"state": 0,
"tar_state": 0
}
],
"subFunc": 0,
"subMode": 0,
"subVisable": 7,
"version": 1
},
"bed": {
"info": {
"temp": 2293795
},
"state": 2
},
"bed_temp": 2293795,
"cam": {
"laser": {
"cond": 252,
"state": 0
},
"timelapse_path": ""
},
"ctc": {
"info": {
"temp": 27
},
"state": 0
},
"ext_tool": {
"calib": 2,
"low_prec": true,
"mount": 0,
"mount_3d": 1,
"th_temp": 0,
"type": "F000"
},
"extruder": {
"info": [
{
"filam_bak": [],
"hnow": 0,
"hpre": 0,
"htar": 0,
"id": 0,
"info": 1611,
"snow": 65280,
"spre": 65280,
"star": 65280,
"stat": 197376,
"temp": 15073510
},
{
"filam_bak": [],
"hnow": 1,
"hpre": 1,
"htar": 1,
"id": 1,
"info": 1048,
"snow": 65279,
"spre": 65279,
"star": 65279,
"stat": 0,
"temp": 34
}
],
"state": 2
},
"fan": 419300,
"fire_ext": {
"bot_err": 0,
"cd": 0,
"cd_init": 15,
"connect_flag": 0,
"drill_flag": 0,
"history": [],
"remain_time": 0,
"rm_init": 33,
"state": 0
},
"holder": {
"info": 1,
"job": 0,
"pos": 3,
"stat": 0,
"step": [
0
]
},
"laser": {
"power": 0
},
"nozzle": {
"exist": 3604483,
"info": [
{
"color_m": "00000000",
"diameter": 0.4,
"fila_id": "GFB99",
"id": 18,
"sn": "20D06A5A1512964",
"stat": 0,
"tm": 350,
"type": "HS01",
"wear": 0.0
},
{
"color_m": "00000000",
"diameter": 0.4,
"fila_id": "",
"id": 1,
"sn": "N/A",
"stat": 0,
"tm": 0,
"type": "HS01",
"wear": 0.0
},
{
"color_m": "F95D73FF",
"diameter": 0.4,
"fila_id": "Pf3dbd0c",
"id": 0,
"sn": "20D06A5A1507129",
"stat": 0,
"tm": 350,
"type": "HS01",
"wear": 0.0
},
{
"color_m": "7C4B00FF",
"diameter": 0.4,
"fila_id": "GFN08",
"id": 16,
"sn": "20D06A5A1507166",
"stat": 0,
"tm": 350,
"type": "HS01",
"wear": 0.0
},
{
"color_m": "FFF144FF",
"diameter": 0.6,
"fila_id": "GFG99",
"id": 21,
"sn": "20D06A592727617",
"stat": 0,
"tm": 350,
"type": "HS01",
"wear": 0.0
},
{
"color_m": "2850E0FF",
"diameter": 0.4,
"fila_id": "GFN99",
"id": 17,
"sn": "20D06A5A1215962",
"stat": 0,
"tm": 350,
"type": "HS01",
"wear": 0.0
},
{
"color_m": "161616FF",
"diameter": 0.4,
"fila_id": "GFC99",
"id": 20,
"sn": "20D06A592725573",
"stat": 0,
"tm": 350,
"type": "HS01",
"wear": 0.0
}
],
"src_id": 19,
"state": 0,
"tar_id": 19
},
"plate": {
"base": 6,
"cali2d_id": "",
"cur_id": "P0102",
"mat": 1,
"tar_id": ""
},
"type": 1
},
"err": "0",
"fail_reason": "0",
"fan_gear": 5046271,
"file": "/data/Metadata/plate_1.gcode",
"force_upgrade": false,
"fun": "10023FF18FFF9CB7",
"fun2": "E",
"gcode_file": "/data/Metadata/plate_1.gcode",
"gcode_file_prepare_percent": "100",
"gcode_state": "RUNNING",
"heatbreak_fan_speed": "14",
"hms": [
{
"attr": 83952640,
"code": 196610
},
{
"attr": 83952640,
"code": 196612
},
{
"attr": 83887616,
"code": 131156
}
],
"home_flag": -1066967745,
"hw_switch_state": 1,
"info": {
"temp": 27
},
"ipcam": {
"agora_service": "disable",
"brtc_service": "enable",
"bs_state": 0,
"cap_pic_enable": "invalid",
"ipcam_dev": "1",
"ipcam_record": "enable",
"laser_preview_res": 6,
"mode_bits": 2,
"resolution": "1080p",
"rtsp_url": "rtsps://192.168.0.21:322/streaming/live/1",
"timelapse": "enable",
"tl_store_hpd_type": 2,
"tl_store_path_type": 2,
"tutk_server": "disable"
},
"job": {
"cur_stage": {
"idx": 0,
"state": 2
},
"stage": [
{
"clock_in": false,
"color": [
"",
""
],
"diameter": [
0.4000000059604645,
0.4000000059604645
],
"est_time": 0,
"heigh": 0.0,
"idx": 0,
"platform": "",
"print_then": false,
"proc_list": [],
"tool": [
"HS01",
"HS01"
],
"type": 2
}
]
},
"job_attr": 18,
"job_id": "0",
"lan_task_id": "0",
"layer_num": 2,
"lights_report": [
{
"mode": "on",
"node": "chamber_light"
},
{
"mode": "flashing",
"node": "work_light"
},
{
"mode": "on",
"node": "chamber_light2"
}
],
"mapping": [
65280
],
"mc_action": 0,
"mc_err": 0,
"mc_percent": 13,
"mc_print_error_code": "0",
"mc_print_stage": "2",
"mc_print_sub_stage": 0,
"mc_remaining_time": 102,
"mc_stage": 2,
"model_id": "",
"msg": 0,
"net": {
"conf": 16,
"info": [
{
"ip": 352364736,
"mask": 16777215
},
{
"ip": 0,
"mask": 0
}
]
},
"nozzle_diameter": "0.4",
"nozzle_target_temper": 230.0,
"nozzle_temper": 230.0,
"nozzle_type": "HS01-0.4",
"online": {
"ahb": true,
"ext": true,
"version": 16
},
"percent": 13,
"plate_cnt": 1,
"plate_id": 1,
"plate_idx": 1,
"prepare_per": 100,
"print_error": 0,
"print_gcode_action": 0,
"print_real_action": 0,
"print_type": "local",
"profile_id": "0",
"project_id": "0",
"queue": 0,
"queue_est": 0,
"queue_number": 0,
"queue_sts": 0,
"queue_total": 0,
"remain_time": 102,
"s_obj": [],
"sdcard": true,
"sequence_id": "2021",
"spd_lvl": 2,
"spd_mag": 100,
"stat": "16782180F0",
"state": 4,
"stg": [
64,
29,
13,
74,
72,
4,
54,
39,
8,
14,
1,
3
],
"stg_cur": 0,
"subtask_id": "0",
"subtask_name": "świeczka-24.stl",
"task_id": "5101",
"total_layer_num": 50,
"upgrade_state": {
"ahb_new_version_number": "",
"ams_new_version_number": "",
"consistency_request": false,
"dis_state": 0,
"err_code": 0,
"ext_new_version_number": "",
"force_upgrade": false,
"idx": 16,
"idx2": 1393374092,
"lower_limit": "00.00.00.00",
"message": "",
"module": "",
"new_version_state": 0,
"ota_new_version_number": "",
"progress": "0",
"sequence_id": 0,
"sn": "31B8BP5A1800585",
"status": "IDLE",
"upgrade_fail_list": [],
"upgrade_type": "unknown"
},
"upload": {
"file_size": 0,
"finish_size": 0,
"message": "Good",
"oss_url": "",
"progress": 0,
"sequence_id": "0903",
"speed": 0,
"status": "idle",
"task_id": "",
"time_remaining": 0,
"trouble_id": ""
},
"ver": "20000",
"vir_slot": [
{
"bed_temp": "0",
"bed_temp_type": "0",
"cali_idx": -1,
"cols": [
"0ACC38FF"
],
"ctype": 0,
"drying_temp": "0",
"drying_time": "0",
"id": "254",
"nozzle_temp_max": "250",
"nozzle_temp_min": "200",
"remain": 0,
"tag_uid": "<scrubbed>",
"total_len": 330000,
"tray_color": "0ACC38FF",
"tray_diameter": "1.75",
"tray_id_name": "",
"tray_info_idx": "GFU98",
"tray_sub_brands": "",
"tray_type": "TPU-AMS",
"tray_uuid": "<scrubbed>",
"tray_weight": "0",
"xcam_info": "000000000000000000000000"
},
{
"bed_temp": "0",
"bed_temp_type": "0",
"cali_idx": -1,
"cols": [
"F95D73FF"
],
"ctype": 0,
"drying_temp": "0",
"drying_time": "0",
"id": "255",
"nozzle_temp_max": "240",
"nozzle_temp_min": "230",
"remain": 0,
"tag_uid": "<scrubbed>",
"total_len": 330000,
"tray_color": "F95D73FF",
"tray_diameter": "1.75",
"tray_id_name": "",
"tray_info_idx": "Pf3dbd0c",
"tray_sub_brands": "",
"tray_type": "TPU",
"tray_uuid": "<scrubbed>",
"tray_weight": "0",
"xcam_info": "000000000000000000000000"
}
],
"wifi_signal": "-66dBm",
"xcam": {
"allow_skip_parts": false,
"buildplate_marker_detector": true,
"cfg": 1797559,
"first_layer_inspector": true,
"halt_print_sensitivity": "medium",
"print_halt": true,
"printing_monitor": true,
"spaghetti_detector": true
},
"xcam_status": "0"
}
+146
View File
@@ -0,0 +1,146 @@
//! Does `PrintReport` actually parse what real printers really send?
//!
//! The fixtures in `tests/fixtures/` are verbatim `print.push_status`
//! payloads captured off real hardware on 2026-10-03 via
//! `cargo run --example live_reports` (hardware identifiers scrubbed):
//!
//! - `push_status_v1_p1p_idle.json` - a P1P on BambuMQTT v1, idle.
//! - `push_status_v2_h2c_printing.json` - an H2C on BambuMQTT v2, 13% into
//! a real job.
//!
//! These exist because the struct was originally built from a *truncated*
//! example payload, and the gap between that and reality turned out to be
//! the cause of two live misbehaviours (silently empty `print_status()`, and
//! bogus "sequence gap" warnings). A fixture is the only way to keep that
//! from regressing without a printer plugged in.
use continuum_proxy::printer::PrintReport;
fn load(name: &str) -> serde_json::Value {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/");
let text = std::fs::read_to_string(format!("{path}{name}")).expect("fixture should exist");
serde_json::from_str(&text).expect("fixture should be valid JSON")
}
/// The failure mode that mattered: `handle_incoming_report` does
/// `if let Ok(report) = serde_json::from_value::<PrintReport>(..)`, so a
/// parse error doesn't just lose that one update - it's invisible. Assert on
/// the error text, not just `is_ok()`, so a regression says *what* broke.
fn assert_parses(name: &str) {
let value = load(name);
match serde_json::from_value::<PrintReport>(value) {
Ok(_) => {}
Err(err) => panic!("{name} failed to parse as PrintReport: {err}"),
}
}
#[test]
fn parses_v1_idle_push_status() {
assert_parses("push_status_v1_p1p_idle.json");
}
#[test]
fn parses_v2_printing_push_status() {
assert_parses("push_status_v2_h2c_printing.json");
}
/// The whole point of consuming these at all: a print farm needs progress
/// and temperatures. If the struct parses but drops these, it's no more
/// useful than not parsing.
#[test]
fn v2_printing_report_carries_progress_and_temperatures() {
let value = load("push_status_v2_h2c_printing.json");
let report: PrintReport = serde_json::from_value(value).expect("should parse");
assert_eq!(report.gcode_state.as_deref(), Some("RUNNING"));
assert_eq!(report.mc_percent, Some(13));
assert_eq!(report.layer_num, Some(2));
assert_eq!(report.total_layer_num, Some(50));
assert_eq!(report.mc_remaining_time, Some(102));
assert_eq!(report.nozzle_temper, Some(230.0));
assert_eq!(report.subtask_name.as_deref(), Some("\u{15b}wieczka-24.stl"));
}
/// An idle printer still has to produce a coherent report - just with
/// nothing printing.
#[test]
fn v1_idle_report_reads_as_idle() {
let value = load("push_status_v1_p1p_idle.json");
let report: PrintReport = serde_json::from_value(value).expect("should parse");
assert_eq!(report.gcode_state.as_deref(), Some("IDLE"));
assert_eq!(report.mc_percent, Some(0));
}
/// v1 and v2 disagree about tray field names (`tray_temp`/`tray_time` vs
/// `drying_temp`/`drying_time`) - this is the specific mismatch that made
/// every v1 push carrying AMS data fail. Both spellings have to work.
#[test]
fn both_protocol_generations_parse_their_ams_trays() {
for name in ["push_status_v1_p1p_idle.json", "push_status_v2_h2c_printing.json"] {
let value = load(name);
let report: PrintReport = serde_json::from_value(value).unwrap_or_else(|err| {
panic!("{name}: {err}");
});
let ams = report.ams.expect("fixture should carry AMS state");
assert!(!ams.ams.is_empty(), "{name}: expected at least one AMS unit");
}
}
fn parse(name: &str) -> PrintReport {
serde_json::from_value(load(name)).unwrap_or_else(|err| panic!("{name}: {err}"))
}
/// The P1P sends an external spool; pin the fields a farm would show.
#[test]
fn v1_report_carries_the_external_spool() {
let report = parse("push_status_v1_p1p_idle.json");
let vt = report.vt_tray.expect("v1 fixture carries vt_tray");
assert_eq!(vt.id, "254");
assert_eq!(vt.tray_info_idx.as_deref(), Some("P9599a25"));
assert_eq!(vt.tray_type.as_deref(), Some("PETG"));
assert_eq!(vt.tray_color.as_deref(), Some("D3C5A3FF"));
assert_eq!(vt.cali_idx, Some(-1));
assert_eq!(vt.n, Some(1));
}
/// The H2C has no `vt_tray` key at all. That is the observed v1/v2
/// divergence, NOT a parse failure: if a change makes `vt_tray` required,
/// `parse` panics here instead of every v2 report silently vanishing in
/// `handle_incoming_report`.
#[test]
fn v2_report_has_no_external_spool_and_still_parses() {
let report = parse("push_status_v2_h2c_printing.json");
assert!(report.vt_tray.is_none());
}
/// `stg_cur` is what tells "preparing" from "stalled" while `layer_num` is 0.
#[test]
fn stage_fields_parse_on_both_generations() {
let v1 = parse("push_status_v1_p1p_idle.json");
assert_eq!(v1.stg_cur, Some(0));
assert_eq!(v1.stg, Some(vec![]));
let v2 = parse("push_status_v2_h2c_printing.json");
assert_eq!(v2.stg_cur, Some(0));
assert_eq!(v2.stg, Some(vec![64, 29, 13, 74, 72, 4, 54, 39, 8, 14, 1, 3]));
}
/// A v1 partial push without these must not erase what a full one set.
#[test]
fn merge_keeps_stage_and_spool_when_a_partial_update_omits_them() {
let mut base = parse("push_status_v1_p1p_idle.json");
let mut partial = parse("push_status_v1_p1p_idle.json");
partial.stg_cur = None;
partial.stg = None;
partial.vt_tray = None;
base.stg_cur = Some(54);
base.stg = Some(vec![54, 14, 13]);
base.merge_from(partial);
assert_eq!(base.stg_cur, Some(54));
assert_eq!(base.stg, Some(vec![54, 14, 13]));
assert!(base.vt_tray.is_some());
}
+133
View File
@@ -0,0 +1,133 @@
//! Does a command that outlives the answer already given for it report back?
//!
//! A `start_print` is acknowledged the moment it is accepted, because the
//! transfer takes minutes and the cloud gives up after ten seconds. So its
//! real outcome can only ever arrive late, and before the outbound queue
//! existed there was nowhere for it to arrive from: the uplink's `select!`
//! loop was the only writer and a spawned task could not reach the socket.
//! A failed start therefore reported success and the reason stayed in this
//! gateway's log, on a machine in someone else's workshop.
//!
//! This stands up a real websocket server and runs the real `uplink::run`
//! against it. **It touches no printer and makes no outbound request**: the
//! fleet is empty, and `dispatch` checks the fleet for the printer id
//! *before* downloading anything, so the command fails on a lookup.
use std::sync::Arc;
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use serde_json::{json, Value};
use tokio::net::TcpListener;
use tokio::sync::Mutex;
use tokio_tungstenite::WebSocketStream;
use tokio_tungstenite::tungstenite::Message;
use continuum_proxy::config::Config;
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(),
// 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");
}
}
}
#[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()));
let (outbound, inbox) = uplink::channel();
tokio::spawn(uplink::run(config(port), fleet, outbound, inbox));
let (stream, _) = listener.accept().await.unwrap();
let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
let hello = next_json(&mut ws).await;
assert_eq!(hello["type"], "hello", "a gateway introduces itself first");
ws.send(Message::Text(
json!({
"type": "command",
"command_id": "cmd-1",
"printer_id": "not-in-this-fleet",
"command": {
"action": "start_print",
"job_id": "7c9e6679-0000-4000-8000-000000000000",
"file_name": "cube.gcode.3mf",
// Never fetched: the fleet lookup fails first. Port 1 so a
// regression that *did* reach the network fails fast and
// loudly rather than hanging on a real host.
"url": "http://127.0.0.1:1/never-fetched",
}
})
.to_string(),
))
.await
.unwrap();
let mut results = Vec::new();
while results.len() < 2 {
let msg = next_json(&mut ws).await;
if msg["type"] == "command_result" {
results.push(msg);
}
}
// Two results for one command, in this order, and that is the contract -
// not a bug to be deduplicated away. The first is "accepted, working on
// it", which is all the cloud ever trusted a start reply to mean; the
// second is what actually happened.
assert_eq!(results[0]["command_id"], "cmd-1");
assert_eq!(results[0]["ok"], true, "a start is acknowledged on acceptance");
assert!(results[0]["error"].is_null());
assert_eq!(results[1]["command_id"], "cmd-1", "the late result reuses the id");
assert_eq!(results[1]["ok"], false);
// The reason is the entire point of the queue: without it the cloud sees
// a job that never started printing and can say nothing about why.
let reason = results[1]["error"].as_str().expect("a failure carries its reason");
assert!(
reason.contains("not-in-this-fleet"),
"the reason should name the printer that could not be found, got: {reason}"
);
}
+301
View File
@@ -0,0 +1,301 @@
//! The other half of the `/ws/edge/v1` contract test.
//!
//! `continuum-backend/tests/edge_protocol.test.ts` checks that what this
//! gateway sends is what the backend accepts. This checks the reverse: that
//! every message the backend sends is one this gateway can actually read, and
//! that what this gateway writes matches the schema it claims to follow.
//!
//! Worth having on both sides because the two ends had drifted into entirely
//! different protocols - a handshake the gateway never sent, telemetry shaped
//! one printer per message instead of a batch, printer ids a gateway has no
//! way to know - and nothing failed anywhere. Both test files are hand-kept
//! mirrors; the canonical shape is
//! `continuum-schemas/schemas/continuum/v1/edge/`.
use continuum_proxy::printer::PrintControl;
use continuum_proxy::uplink::{ClientMessage, PrinterCommand, PrinterTelemetry, ServerMessage};
/// Verbatim from the backend's `src/routes/ws/edge.ts`. If the backend
/// changes one of these, this test is where it should break.
const BACKEND_MESSAGES: &[&str] = &[
r#"{"type":"hello_ack","session_id":"3f2b1c5e-0000-4000-8000-000000000000"}"#,
r#"{"type":"hello_error","message":"invalid edge token"}"#,
r#"{"type":"heartbeat_ack","seq":7}"#,
r#"{"type":"command","command_id":"9a7d2f10-0000-4000-8000-000000000000","printer_id":"p1p","command":{"action":"set_fan_speed","percent":40}}"#,
r#"{"type":"command","command_id":"9a7d2f11-0000-4000-8000-000000000000","printer_id":"p1p","command":{"action":"send_gcode_line","line":"M106 S0"}}"#,
r#"{"type":"command","command_id":"9a7d2f12-0000-4000-8000-000000000000","printer_id":"p1p","command":{"action":"control_print","control":"pause"}}"#,
r#"{"type":"command","command_id":"9a7d2f13-0000-4000-8000-000000000000","printer_id":"p1p","command":{"action":"control_print","control":"resume"}}"#,
r#"{"type":"command","command_id":"9a7d2f14-0000-4000-8000-000000000000","printer_id":"p1p","command":{"action":"control_print","control":"stop"}}"#,
];
#[test]
fn every_backend_message_deserializes() {
for raw in BACKEND_MESSAGES {
let parsed: Result<ServerMessage, _> = serde_json::from_str(raw);
assert!(parsed.is_ok(), "backend sends this and the gateway cannot read it:\n {raw}\n {:?}", parsed.err());
}
}
#[test]
fn a_command_arrives_with_its_payload_intact() {
let raw = r#"{"type":"command","command_id":"cmd-1","printer_id":"p1p","command":{"action":"set_fan_speed","percent":40}}"#;
match serde_json::from_str::<ServerMessage>(raw).expect("should parse") {
ServerMessage::Command { command_id, printer_id, command } => {
assert_eq!(command_id, "cmd-1");
// The gateway's own id from printers.toml, not a cloud UUID - the
// backend translates on the way out.
assert_eq!(printer_id, "p1p");
match command {
PrinterCommand::SetFanSpeed { percent } => assert_eq!(percent, 40),
other => panic!("wrong command variant: {other:?}"),
}
}
other => panic!("wrong message variant: {other:?}"),
}
}
/// Each control verb has to arrive as the right `PrintControl`, not merely
/// parse. A silent mis-mapping here would send a stop where a pause was
/// asked for, which on a real machine is the difference between a print that
/// can be resumed and one that is over.
#[test]
fn each_print_control_verb_maps_to_its_own_action() {
for (verb, expected) in [
("pause", PrintControl::Pause),
("resume", PrintControl::Resume),
("stop", PrintControl::Stop),
] {
let raw = format!(
r#"{{"type":"command","command_id":"c","printer_id":"p1p","command":{{"action":"control_print","control":"{verb}"}}}}"#
);
match serde_json::from_str::<ServerMessage>(&raw).expect("should parse") {
ServerMessage::Command { command: PrinterCommand::ControlPrint { control }, .. } => {
assert_eq!(control, expected, "'{verb}' mapped to the wrong action");
// And the wire word the vendor layer sends has to match the
// wire word that arrived - `as_str` is what builds the Bambu
// command name.
assert_eq!(control.as_str(), verb);
}
other => panic!("wrong variant for '{verb}': {other:?}"),
}
}
}
/// "cancel" is the trap: it is Moonraker's word, and the Klipper
/// implementation behind `control_print` really does send a cancel - but the
/// wire word is "stop". A verb only one vendor recognises must be rejected
/// here rather than reaching a gateway that cannot map it.
#[test]
fn a_vendor_specific_control_verb_is_rejected() {
for verb in ["cancel", "abort", "Pause", ""] {
let raw = format!(
r#"{{"type":"command","command_id":"c","printer_id":"p1p","command":{{"action":"control_print","control":"{verb}"}}}}"#
);
assert!(
serde_json::from_str::<ServerMessage>(&raw).is_err(),
"'{verb}' parsed as a print control verb and should not have"
);
}
}
/// The schema's `StartPrint` variant, in the shape the backend dispatches.
/// Every field has to arrive intact: `url` is what gets downloaded and
/// `file_name` is what the printer is told to call it, so a swapped or
/// dropped one prints the wrong thing or nothing.
#[test]
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 }, .. } => {
assert_eq!(job_id, "5b0e7c1a-0000-4000-8000-000000000000");
assert_eq!(file_name, "plate_1.3mf");
assert_eq!(
url,
"https://objects.example.com/jobs/abc.3mf?X-Amz-Signature=deadbeef&X-Amz-Expires=300"
);
}
other => panic!("wrong variant: {other:?}"),
}
}
/// The URL is a bearer credential until it expires, and `dispatch` logs
/// every command with `{:?}`.
#[test]
fn a_start_print_url_does_not_appear_in_debug_output() {
let raw = r#"{"type":"command","command_id":"c","printer_id":"p1p","command":{"action":"start_print","job_id":"j","file_name":"plate_1.3mf","url":"https://objects.example.com/x?X-Amz-Signature=deadbeef"}}"#;
let parsed = serde_json::from_str::<ServerMessage>(raw).expect("should parse");
let shown = format!("{parsed:?}");
assert!(!shown.contains("deadbeef"), "signature leaked into a log line: {shown}");
assert!(shown.contains("plate_1.3mf"), "the file name should still be logged: {shown}");
}
/// Each field is load-bearing and none has a sensible default: a command
/// missing its URL must be rejected here, not discovered at the printer.
#[test]
fn a_start_print_missing_any_required_field_is_rejected() {
let full = [
("job_id", r#""job_id":"5b0e7c1a-0000-4000-8000-000000000000""#),
("file_name", r#""file_name":"plate_1.3mf""#),
("url", r#""url":"https://objects.example.com/jobs/abc.3mf""#),
];
for missing in 0..full.len() {
let fields: Vec<&str> =
full.iter().enumerate().filter(|(i, _)| *i != missing).map(|(_, (_, f))| *f).collect();
let raw = format!(
r#"{{"type":"command","command_id":"c","printer_id":"p1p","command":{{"action":"start_print",{}}}}}"#,
fields.join(",")
);
assert!(
serde_json::from_str::<ServerMessage>(&raw).is_err(),
"start_print without '{}' parsed and should not have:\n {raw}",
full[missing].0
);
}
}
#[test]
fn unknown_messages_are_rejected_rather_than_silently_accepted() {
// The old protocol. A gateway that accepted this would ack a handshake
// that never happened.
assert!(serde_json::from_str::<ServerMessage>(r#"{"type":"auth_ok","farmId":"f"}"#).is_err());
}
/// The bug this test exists for: serde emits `"job_name": null` for an empty
/// `Option` unless told not to, and the backend's validator reads a missing
/// key and an explicit null as different things. An idle printer has no job
/// name, so every batch containing one was rejected whole.
#[test]
fn empty_telemetry_fields_are_omitted_not_null() {
let msg = ClientMessage::Telemetry {
printers: vec![PrinterTelemetry {
printer_id: "p1p".into(),
name: "P1P".into(),
state: Some("IDLE".into()),
percent: Some(0),
// An idle printer is in no stage, so this is the second field
// after `job_name` that must be omitted rather than nulled.
stage: None,
layer: Some(0),
total_layers: Some(0),
remaining_minutes: Some(0),
job_name: None,
nozzle_temp: Some(23.375),
nozzle_target: Some(0.0),
bed_temp: Some(22.40625),
bed_target: Some(0.0),
print_error: Some(0),
active_alerts: Some(0),
// 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,
}],
};
let json = serde_json::to_string(&msg).expect("should serialize");
assert!(!json.contains("null"), "telemetry should omit empty fields, got:\n {json}");
assert!(!json.contains("job_name"), "an absent job name should not appear at all");
// The fields that *do* have values must still be there - an assertion on
// the absence of "null" alone would pass on an empty object.
assert!(json.contains(r#""state":"IDLE""#));
assert!(json.contains(r#""bed_temp":22.40625"#));
assert!(!json.contains("stage"), "an absent stage should not appear at all");
}
/// A stage of 0 means "printing" and must survive the wire, because
/// `skip_serializing_if = "Option::is_none"` on an `Option<i32>` is one
/// `unwrap_or_default` away from turning "printing" into "no stage". The
/// distinction matters: a printer at 14% and layer 0 is mid-bed-levelling if
/// the stage is 54 and genuinely stuck if it is 0.
#[test]
fn a_zero_stage_is_sent_rather_than_treated_as_absent() {
let msg = ClientMessage::Telemetry {
printers: vec![PrinterTelemetry {
printer_id: "p1p".into(),
name: "P1P".into(),
state: Some("RUNNING".into()),
percent: Some(14),
stage: Some(0),
layer: Some(1),
total_layers: Some(150),
remaining_minutes: Some(39),
job_name: Some("cube-test".into()),
nozzle_temp: Some(255.2),
nozzle_target: Some(255.0),
bed_temp: Some(79.3),
bed_target: Some(80.0),
print_error: Some(0),
active_alerts: Some(0),
camera_stream: Some("p1p".into()),
}],
};
let json = serde_json::to_string(&msg).expect("should serialize");
assert!(json.contains(r#""stage":0"#), "a zero stage must be on the wire, got:\n {json}");
}
#[test]
fn a_successful_command_result_omits_the_error() {
let json = serde_json::to_string(&ClientMessage::CommandResult {
command_id: "cmd-1".into(),
ok: true,
error: None,
})
.expect("should serialize");
assert_eq!(json, r#"{"type":"command_result","command_id":"cmd-1","ok":true}"#);
}
#[test]
fn a_failed_command_result_carries_the_reason() {
let json = serde_json::to_string(&ClientMessage::CommandResult {
command_id: "cmd-1".into(),
ok: false,
error: Some("no printer with id 'nope' in this gateway's fleet".into()),
})
.expect("should serialize");
assert!(json.contains(r#""ok":false"#));
assert!(json.contains("no printer with id 'nope'"));
}
/// A gateway with no configured stream base URL must omit the field, for the
/// same reason the telemetry fields are omitted: the backend reads a missing
/// key and an explicit null differently, and an unconfigured gateway is the
/// default case, not an edge case.
#[test]
fn a_hello_without_a_stream_base_url_omits_it() {
let json = serde_json::to_string(&ClientMessage::Hello {
gateway_id: "gw-1".into(),
farm_id: "farm-1".into(),
stream_base_url: None,
})
.expect("should serialize");
assert!(!json.contains("stream_base_url"), "got:\n {json}");
// Without this the assertion above would also pass on an empty object.
assert!(json.contains(r#""gateway_id":"gw-1""#));
}
#[test]
fn a_hello_carries_the_stream_base_url_when_one_is_configured() {
let json = serde_json::to_string(&ClientMessage::Hello {
gateway_id: "gw-1".into(),
farm_id: "farm-1".into(),
stream_base_url: Some("http://gateway.local:1984".into()),
})
.expect("should serialize");
assert!(json.contains(r#""stream_base_url":"http://gateway.local:1984""#), "got:\n {json}");
}
/// The credential must not be in the handshake message. It travels in an
/// `Authorization` header on the upgrade instead, and a `token` field here
/// would mean the backend parsing a message from a peer that has proved
/// nothing - which is what this used to do.
#[test]
fn a_hello_carries_no_credential() {
let json = serde_json::to_string(&ClientMessage::Hello {
gateway_id: "gw-1".into(),
farm_id: "farm-1".into(),
stream_base_url: None,
})
.expect("should serialize");
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"}"#);
}