88bf1d1c2f7371617dbab4bc02b28d285cae45b2
11
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|