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>
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>
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>
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>
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>
New:
- printers.example.toml (committed template) / printers.toml (gitignored,
real hosts + access codes don't belong in git) — a list of real printers
with vendor, host, access_code/api_key/com_port, and an optional
per-printer CA override.
- src/fleet.rs loads that file into ready-to-use PrinterHandles, matching
vendor strings to the right constructor.
- BambuPrinter::test_tls_handshake() does a real (blocking, one-shot) TLS
handshake to port 8883 using that printer's configured BambuTls trust
mode — no MQTT protocol, just 'does the certificate verify'. Added
native-tls and toml as direct dependencies for this.
- examples/test_bambu_certs.rs loads printers.toml and runs the handshake
test against every Bambu entry.
Also fixes a real bug found while testing against unreachable IPs: plain
TcpStream::connect has no timeout and hung indefinitely on an offline
printer — switched to connect_timeout (5s).
Verified with cargo check --all-targets (0 errors) and by actually running
test_bambu_certs against a local printers.toml (correctly errored on a
missing cert file, then correctly timed out against unreachable test IPs
instead of hanging).
Removed adapters/ (rumqttc MQTT, suppaftp FTPS, reqwest HTTP), plate_changer/
(tokio-serial), discovery/, and cache.rs (rusqlite) — src/printer/ already
covers 'talk to a printer' as simple stubs, so keeping a second, more complex
version alongside it was redundant. main.rs goes from 6 concurrent tasks to
2 (uplink + go2rtc). uplink/ drops channels, jitter, and Send-bound futures.
Verified with cargo build + cargo run --example printer_polymorphism.