Commit Graph
15 Commits
Author SHA1 Message Date
octoturge af8bac4bf3 Stop orphaning go2rtc, serve cameras sooner, keep its config private
Three defects found by running a real WebRTC viewer through the whole
stack for the first time (continuum-backend/scripts/e2e-camera.sh).

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

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

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

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

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

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

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

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

Two things that took more care than expected:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Not done, and not claimed: no signature, so no authenticity. There is no real
pendrive here, so the removable-media path was tested with ordinary
directories standing in for mount points - real udisks/udev mounts, FAT
behaviour, a genuinely yanked stick and how promptly the 5s poll notices a
mount are all unproven. The watcher's install-then-exit flow, which relies
on a supervisor restarting the process, has no test and no supervisor exists
in this environment; a stick left permanently inserted reports Unchanged
rather than looping, because the hash matches. Env still overrides on a box,
so a box is only pendrive-only to the extent its environment is trusted -
bootstrap.rs says so.
2026-10-19 07:49:46 +00:00
octoturge 70763108f8 Require a password on go2rtc's API
The API listener is what serves WebRTC, and it defaults to :1984 on every
interface, so anyone who could reach the gateway could watch any printer
with a camera. The RTSP server was closed on 2026-10-18; this was the
other half and the one that actually carries video.

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

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

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

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

Note this does NOT let a browser play a stream directly - see the next
commit's notes and PLAN.md. go2rtc sends no CORS headers and 401s the
unauthenticated preflight, so the relay is required, not optional.
2026-10-18 22:00:51 +00:00
octoturge 6a0d80a381 Generate go2rtc's config from what the printers report
go2rtc has been supervised since before this commit, with nothing to
serve: `CONTINUUM_GO2RTC_CONFIG` pointed at `./go2rtc.yaml`, and that
file did not exist in the repository or anywhere else. A watchdog was
restarting a process that had no cameras configured.

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

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

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

Details worth keeping:

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

63 tests, clippy unchanged at the two deliberate variant-size warnings,
`cargo deny check licenses` ok. go2rtc itself stays operator-installed and
is not distributed.
2026-10-15 23:03:02 +00:00
octoturge c22ccf5d22 Derive the AMS mapping from the project, and blink on completion
Two changes, both following from the print that paused at 1% because
`use_ams: false` pointed an AMS-sliced project at the external spool.

## upload_project_file no longer guesses

It now reads the `.3mf` it already holds and works out where each filament
should come from. `Metadata/slice_info.config` names every filament's
`tray_info_idx`, type and colour; the AMS report names the same three per
loaded tray; matching them is the whole job. `src/printer/project_file.rs`
does the reading and matching and is free of printer types, so it is
testable without a printer - which is the only way this gets tested at all,
the alternative being to notice a mapping bug by watching a machine print in
the wrong colour.

The mapping is derived *before* the upload: a project that cannot be matched
should not result in a file being moved onto the machine that cannot then be
printed.

It never falls back to the external spool to make an unmatched project
"work". The one case it answers `ExternalSpool` for is a printer reporting no
AMS trays at all. A printer *with* an AMS whose project wants the spool is an
error, not a fallback, because `vt_tray` is not modelled on `PrintReport` -
there is no way to check the spool holds the right material, and claiming a
match without checking is how this went wrong the first time.

Two fixtures carry the real cases: `cube_slice_info.3mf` is the project that
cost the print (deflated on purpose - every other test archive is Stored, so
the decompression path and the flate2 backend behind zip's `deflate-flate2`
would otherwise never run), and the existing `push_status_v1_p1p_idle.json`
supplies the AMS side. The end-to-end test asserts they agree on
`[3,-1,-1,-1,-1]`: both slots hold PETG GFG99 and only the colour tells them
apart, so a matcher stopping at `tray_info_idx` would print the cube in
black.

A malformed `<filament>` element is an error rather than a skip. A skipped
filament leaves a hole at its index, the hole becomes `-1`, and the printer
is told that material is unused - so the plate prints in whatever remains
mapped.

New dependency: `zip` 9.0.3 (MIT) with `deflate-flate2`, plus `flate2` for a
backend, which zip's feature does not select on its own. All six new crates
are MIT, MIT/Apache-2.0, 0BSD/MIT/Apache-2.0, MIT/Zlib/Apache-2.0 or Zlib,
and `cargo deny check licenses` passes. No bzip2, lzma, xz or zstd entered
the graph.

## Lights on completion

`completion::run` is a third daemon task that notices a print finishing and
blinks that printer's chamber light for ten seconds, then leaves it off:
500ms on, 500ms off, twenty commands, ending dark.

Its own task rather than a branch in the uplink's telemetry loop, because
that loop only ticks while the cloud connection is up - and a light that only
works when the internet does is not much of a notification for someone
standing next to the printer.

Three rules, each a false celebration avoided and each tested: only a
*transition* into `FINISH` fires, because `FINISH` persists until the next
print starts; a printer seen for the first time never fires, so restarting
the gateway does not announce prints that finished yesterday; and `FAILED`
never fires, because that is what a Bambu reports for a cancellation,
including every `print.stop` this software sends.

The blink is driven from `On`/`Off` rather than the firmware's `flashing`
mode. `flashing` is in the schema but the meaning of `loop_times` versus
`interval_time` is unconfirmed, so "blink for ten seconds" could not be
predicted; two obvious commands give an exact duration. The light is left off
even when a blink fails part-way, since a machine abandoned with its light on
looks like one still working.

`set_light` is **unverified against hardware** - the P1P was powered off when
this was written, and the doc comment says so. The shape is built from
`schemas/bambulab/{v1,v2}/request/system/ledctrl.schema.json` rather than by
hand, and it goes out as a correlated request so a rejection is visible.
2026-10-15 21:27:24 +00:00
octoturge 588ca85842 Compile the modules once, and clear the warning backlog
`cargo check --all-targets` emitted 32 warnings; it now emits none. Almost all
of them came from one cause: main.rs declared `mod config/fleet/printer/uplink`
while lib.rs declared the same modules, so every file was compiled twice, once
per crate. In the binary copy nothing is externally reachable, so every
`pub use` re-export and every method that only an example or a test calls was
genuinely dead - and the warnings were real about the binary while being wrong
about the library's actual public API. main.rs now uses the library crate and
keeps only `mod go2rtc`, which nothing else needs.

The rest, individually:

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

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

No behaviour change. Tests still pass and the daemon was re-verified against
the live P1P and H2C through the full backend path.
2026-10-03 12:16:48 +00:00
octoturge 91fbcaf665 Route telemetry up and commands down over the cloud uplink
The proxy did not actually proxy. `fleet` was loaded and connected in
main.rs and then never touched again, while the uplink said hello and sent
heartbeats into the void - two halves of the program running side by side
with nothing between them. Both directions are wired now.

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

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

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

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

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

Verified end to end against both printers with a throwaway WebSocket
server: H2C `RUNNING 88% layer 48/50 13min nozzle 230/230 bed 35/35`, P1P
`IDLE 0% nozzle 23.5/0 bed 22.5/0`, both fan commands `ok=true`, zero
warnings over 24s.
2026-10-03 10:22:22 +00:00
octoturge 4add7c1248 Wire fleet::load into main.rs; fix request() reply unwrapping
main.rs never actually loaded printers.toml or connected anything -
fleet/printer were only ever exercised by standalone examples. Now
main() loads the fleet (path configurable via CONTINUUM_PRINTERS_PATH,
default "printers.toml") and connects every printer concurrently at
startup, logging success/failure per printer rather than failing
startup over one bad printer.

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

Verified live against a real H2C (MQTT v5/BambuMQTT v2): connect,
get_version, and the full pending-request correlation all worked
end-to-end, returning real firmware versions for every module (main
board 01.01.05.00, both AMS units, AMS HT, etc). The other configured
printer (P1P, forced v1) failed with a real, expected TLS error - it
doesn't chain to the bundled CA, matching the already-documented P1P
quirk; needs the ca_cert_path/fetch_bambu_cert workflow, not a code fix.
2026-09-05 21:39:26 +00:00
octoturge a2d4e578ec Replace em dashes with hyphens across docs and comments 2026-08-29 08:13:54 +00:00
octoturge d0a17ee50e Simplify boilerplate for learning: drop real protocol code, minimal main.rs
Removed adapters/ (rumqttc MQTT, suppaftp FTPS, reqwest HTTP), plate_changer/
(tokio-serial), discovery/, and cache.rs (rusqlite) — src/printer/ already
covers 'talk to a printer' as simple stubs, so keeping a second, more complex
version alongside it was redundant. main.rs goes from 6 concurrent tasks to
2 (uplink + go2rtc). uplink/ drops channels, jitter, and Send-bound futures.

Verified with cargo build + cargo run --example printer_polymorphism.
2026-08-28 18:22:38 +00:00
octoturge 9b3815fd25 Fix build errors: FTPS AsyncRead bridge, TLS connector type, unsafe env::set_var, dead code
Verified with cargo check + cargo run --example printer_polymorphism.
2026-08-28 17:09:11 +00:00
octoturge 2807b2b067 Initial boilerplate scaffold for continuum-proxy 2026-08-28 16:25:26 +00:00