Commit Graph
23 Commits
Author SHA1 Message Date
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 7d4cf41999 Confirm the light and the upload on a real P1P
Four things were shipped unverified because the printer was off when they
were written. Three are now confirmed first-hand (P1P, 2026-10-15), and one
is settled in the opposite direction from what was assumed.

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

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

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

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

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

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

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

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

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

51 tests still pass.
2026-10-15 21:43:06 +00:00
octoturge 254798e8cb Model vt_tray and the stage fields, and use them
## vt_tray closes a hole in the filament decision

`filament_source_for` used to refuse a project that wanted the external
spool, on a printer that had an AMS, purely because there was no way to see
what the spool held - and claiming a match without looking is how the
`use_ams: false` bug happened. Now that `vt_tray` is on `PrintReport`, the
question can be answered: `external_spool_satisfies` reuses `map_to_trays`
with the spool as the single candidate tray, so an `Ok` means every filament
matched it.

The decision is now: the AMS first when there is one, because a project
sliced for the AMS should use the AMS even if the spool happens to hold the
same filament; the spool if nothing in the AMS fits and the spool does; and
an error otherwise, reporting the AMS mismatch rather than the spool one
since that names what was wanted against what was loaded. A reported spool
that does not match is a real negative now and is refused.

One case stays unverifiable: a v2 printer reporting neither AMS trays nor
`vt_tray`. v2 omits the key entirely, so absence means "not reported", not
"empty". That one is logged as an unverified assumption instead of being
passed off as a match - and it is the same reason `EXTERNAL_SPOOLS` is still
a constant. Counting reported spools would give 0 on exactly the printer the
ams_mapping schema says has two.

Tests use the real P1P capture's own spool (PETG P9599a25 in D3C5A3FF): it
satisfies a project asking for that filament, and does not satisfy one asking
for the AMS's blue GFG99. Those two were indistinguishable before.

## stage reaches the cloud

`stg_cur`/`stg` are modelled, and `stage` is now on `PrinterTelemetry`,
through `continuum-schemas` and the ts-types mirror. Without it progress is
ambiguous in a way that reads as a fault: on the confirmed P1P run `percent`
climbed to 14% while `layer` stayed 0 for minutes, the printer walking
`stg_cur` 54 -> 14 -> 13 -> 0 before laying the first layer. A console
showing 14% at layer 0 with no stage looks stuck.

A zero stage means "printing" and has its own test on both sides, because
`skip_serializing_if = "Option::is_none"` plus one `unwrap_or_default` is all
it takes to turn "printing" into "no stage".

The backend test asserts a *wrongly typed* stage is rejected, not just that a
valid one is accepted: `PrinterTelemetrySchema` permits unknown properties -
verified - so `{ stage: 54 }` validates whether or not the field is declared.
The negative is what fails if someone removes it.

The live capture fixture is left alone. It predates the field, the field is
optional, and editing a capture by hand would defeat the point of having one.
2026-10-15 21:37:20 +00:00
octoturge c22ccf5d22 Derive the AMS mapping from the project, and blink on completion
Two changes, both following from the print that paused at 1% because
`use_ams: false` pointed an AMS-sliced project at the external spool.

## upload_project_file no longer guesses

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

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

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

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

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

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

## Lights on completion

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

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

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

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

`set_light` is **unverified against hardware** - the P1P was powered off when
this was written, and the doc comment says so. The shape is built from
`schemas/bambulab/{v1,v2}/request/system/ledctrl.schema.json` rather than by
hand, and it goes out as a correlated request so a rejection is visible.
2026-10-15 21:27:24 +00:00
octoturge 431dd849da Make the filament source explicit, and print from the AMS
`start_uploaded_file` hardcoded `use_ams: false` / `ams_mapping: ""`, which
silently overrode whatever the slicer had chosen. On a P1P with an AMS and a
project sliced for it, that is not a missing feature - it points the print at
the external spool. Confirmed on 2026-10-11: the print started, heated to
250C, reached 1%, then the printer paused itself with `print_error: 50364420`
and `tray_now: 255`. Nothing in the command's reply said so; `result` was
`success`, because the command had been accepted.

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

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

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

`upload_project_file` is left on the external spool and is the one remaining
place that can pick wrong, because it is vendor-generic and an AMS mapping is
a Bambu concept. It is unreachable - there is no start-print command on the
uplink - and the fix is for it to derive the mapping from the `.3mf` it
already holds, which needs a zip reader and so a dependency decision.
2026-10-11 22:35:51 +00:00
octoturge 939c35fb91 Confirm the print path on a real P1P, and stop discarding its answer
Print control (pause/resume/stop) and print start are now both confirmed
against the P1P on the LAN, and doing that turned up three real defects.

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

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

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

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

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

The rest, individually:

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

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

No behaviour change. Tests still pass and the daemon was re-verified against
the live P1P and H2C through the full backend path.
2026-10-03 12:16:48 +00:00
octoturge 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 0ab602ed79 Add real FTPS upload to Bambu printers
`upload_project_file` was a `println!`. MQTT can start a print but can
only name a file the printer already has, so moving the bytes there is a
separate protocol: implicit FTPS on port 990, user `bblp`, password the
same access code MQTT uses, into the printer's own `cache/` directory.

Confirmed live against a P1P: 102 bytes uploaded, byte count matched
exactly, first attempt.

Deliberately suppaftp's *blocking* client on `tokio::task::spawn_blocking`
rather than its async one. The blocking client takes a plain
`native_tls::TlsConnector`, so this reuses `BambuTls::build_connector()`
unchanged and every TLS trust decision for a printer stays in that one
method; the async client needs a second TLS stack (`async-native-tls`)
whose futures-vs-tokio `AsyncRead` mismatch has already cost this project
a debugging session. `connect()`'s doc comment already pointed at
`spawn_blocking` as the eventual answer for blocking work, and a
multi-megabyte upload earns it more obviously than a handshake does.

`connect_secure_implicit` sits behind suppaftp's `deprecated` feature
because implicit FTPS is what the RFCs deprecated in favour of explicit
AUTH TLS. The printers only speak implicit, so the feature is on.

Split into `upload_file` (FTPS only) and `start_uploaded_file` (the MQTT
command that begins printing), because the two halves have very different
consequences: moving a file onto a printer is inert and reversible, while
starting a print commits the machine to hours of physical work. That split
is what made the transfer testable against real hardware at all.

`start_uploaded_file` dispatches on file type - a `.3mf` is a container so
`print.project_file` needs both a `url` and a `param` naming the gcode
inside it, while a bare `.gcode` has nothing to select and
`print.gcode_file` takes just a path. It has deliberately never been
fired; its doc comment lists the three guesses to confirm first.

suppaftp is MIT OR Apache-2.0, checked against docs/LICENSES.md before
being added, along with all 22 crates it pulled in.
2026-10-03 10:11:36 +00:00
octoturge 305c41993d Update docs and prune examples superseded by real functionality
README/certs/README/.env.example/printers.example.toml were all still
describing this as a learning-stage stub with no real printer protocol
clients - rewritten to reflect the real Bambu MQTT client, cert
auto-pinning, and marlin serial connection that now exist.

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

No behavior change in the daemon itself - verified with cargo build
--examples and a live run of printer_polymorphism and auto_connect_bambu
against the real P1P/H2C printers.
2026-09-07 16:17:15 +00:00
octoturge 9643c8b685 Real MQTT wiring for Bambu: connect, auto-detect v5/v3.1.1, request/reply
Removes GenericPrinter::receive (two disconnected fire-and-forget stubs
that didn't actually give request/response) in favor of a real mechanism
in the new src/printer/bambu_mqtt.rs:

- BambuMqttSession wraps whichever rumqttc client a connection ended up
  using (v3.1.1's AsyncClient/EventLoop for BambuMQTT v1, v5's for
  BambuMQTT v2 - different types, different event/error shapes) behind
  one interface. `open()` respects ForceV1/ForceV2, and for Auto tries
  v5 first, falling back to v3.1.1 on any failure (see the module doc
  comment for why "any failure", not a precisely-matched version-
  rejection code - unverified against a live printer, no broker here to
  test against).
- Reuses BambuTls::build_connector (now pub(crate)) for rumqttc's TLS
  transport via TlsConfiguration::NativeConnector, instead of a second,
  separate TLS trust story - hence rumqttc now pulls in use-native-tls
  instead of its default rustls stack, so there's one TLS implementation
  in the binary, not two.
- request() publishes and waits (with a timeout) for a report whose
  sequence_id matches, correlated via a shared pending-request table
  that a spawned background task completes as reports arrive - the
  first real tokio::spawn in this crate, flagged as deferred since
  printer/mod.rs's very first doc comment; this is why it was deferred
  until now, and why it's needed now (polling the eventloop has to run
  continuously, independent of whatever call site is waiting).
- publish()/publish_raw() are the fire-and-forget half, for commands
  like gcode_line that only get acknowledged implicitly.

BambuPrinter::connect/send_gcode_line/publish now call the real session
instead of printing what they would do. Verified against 192.0.2.1 (a
reserved, never-routable test address): both AsyncClient/EventLoop pairs
actually attempt a real TCP connection and fail gracefully (network
timeout, not a panic) when nothing answers, and Auto genuinely attempts
both while ForceV1 skips straight to v3.1.1.
2026-09-05 21:04:39 +00:00
octoturge b352fd618d Add upload_project_file/send_gcode_line, real Marlin serial, Bambu protocol auto-detect
- GenericPrinter grows upload_project_file, send_gcode_line, and a
  generic upload_gcode<P> driver that streams a whole gcode file line by
  line through any vendor's send_gcode_line.
- PrusaSerialPrinter's connect/send_gcode_line are real (not stubs): a
  held-open MarlinSerialConnection (src/printer/marlin_serial.rs) writes
  each line and waits for Marlin's "ok" before sending the next. Config
  changed from com_port: i16 to port: String + baud_rate: u32 to hold a
  real device path.
- Bambu: BambuGenericPrinter + BambuV1Printer/BambuV2Printer collapsed
  into one BambuPrinter, since which MQTT/BambuMQTT version (v3.1.1/v1
  vs v5/v2) a printer speaks is discovered at connect() time (or forced),
  not something config should pick a type for - the same physical
  printer can change after a firmware update. printers.toml gets a new
  "bambu" vendor (auto-detect, the default) alongside bambu_v1/bambu_v2
  (force a specific one).
- Bambu's gcode_line request is now built for real via
  continuum-schema-instance against continuum-schemas' JSON Schema,
  picking the v1 or v2 schema directory based on the detected protocol
  version, instead of a hand-written serde struct.
- Added rumqttc as a dependency (not wired up yet - the real MQTT
  v5-then-v3.1.1 fallback connect and the publish/receive plumbing are
  still stubs).
2026-09-05 20:40:21 +00:00
octoturge a2d4e578ec Replace em dashes with hyphens across docs and comments 2026-08-29 08:13:54 +00:00
octoturge 1583f531fa Add print.rs (AmsState/AmsUnit/AmsTray/XcamSettings), fix print vs pushing
Mirrors continuum-schemas' schemas/bambulab/ restructuring: PushallResponse
removed (it never existed on the wire — pushall's request goes to
"pushing", but the state-push stream it triggers arrives under "print",
a different root key, not a direct reply). BambuReport::Pushing(...) ->
BambuReport::Print(PrintReport).

AmsTray is an untagged enum (Empty/Loaded) rather than one struct full of
Option<T> fields, matching the JSON Schema's oneOf for the same slot -
#[serde(deny_unknown_fields)] on EmptyTray is load-bearing: untagged enums
try variants in order, and a loaded tray (superset of Empty's one field)
would otherwise incorrectly match Empty first.

Verified against the full real ams/xcam example from the conversation via
examples/bambu_commands_demo.rs - both the empty and loaded tray in that
payload deserialize to the correct enum variant.
2026-08-28 23:19:20 +00:00
octoturge 166761479b Replace proto-based bambu_protocol with plain-Rust bambu_commands/
One file per command (get_version.rs, pushall.rs), each holding its
request AND response together. CommandEnvelope (sequence_id + command) is
the shared-fields piece every command embeds via #[serde(flatten)] — the
'inheritance' replacement for these structs, same composition idea as
BambuGenericPrinter in bambu.rs, just expressed as a struct field instead
of impl-block delegation.

examples/bambu_commands_demo.rs round-trips both commands against Bambu's
exact real JSON (from the original conversation) and confirms byte-for-
byte matches, same as the removed proto version did — this replacement is
behaviorally identical, just without protoc/build.rs in the loop.
2026-08-28 22:49:06 +00:00
octoturge 8e5d85202f Wire InfoCommand through as one real, runnable example
BambuRequest::Info(InfoCommand) is now live (was commented out) —
examples/bambu_protocol_demo.rs constructs one, serializes it, and prints
it next to Bambu's real wire JSON for comparison. Run it and copy this
exact shape (proto message -> use it here -> add an enum variant) for
every other command.
2026-08-28 22:41:55 +00:00
octoturge 6e18215af5 Add GenericPrinter::set_fan_speed() across all five printer kinds
Same override pattern as connect(): required, no default body, five
completely different bodies.

- BambuGenericPrinter::set_fan_speed_impl() is shared by BambuV1Printer and
  BambuV2Printer (composition reuse, same as connect_impl) - both speak
  the same MQTT gcode-injection mechanism.
- PrusaLinkPrinter and PrusaSerialPrinter both send the same M106 gcode
  but over different transports (HTTP command injection vs. raw serial
  bytes) - they share the percent->PWM conversion despite having unrelated
  connect() implementations.
- KlipperPrinter uses a hypothetical Moonraker-native endpoint that takes
  a percentage directly - no PWM conversion at all, since that math only
  applies to the gcode-speaking vendors.

percent_to_pwm() lives in mod.rs as a private free function rather than a
trait default: it's genuine shared logic, but only for the subset of
vendors that need it, which is exactly the case a trait default can't
express cleanly. Visible to bambu.rs/prusa.rs via super:: because Rust's
module privacy reaches into child modules.

Verified with cargo check --all-targets and a full run of
printer_polymorphism: 50% converges on the same PWM value (127) across all
three gcode-based printers, Klipper's native path takes 0.50 directly.
2026-08-28 21:40:11 +00:00
octoturge 8572d564b1 Split BambuPrinter into BambuGenericPrinter -> BambuV1Printer/BambuV2Printer
Completes the GenericPrinter -> BambuGenericPrinter -> BambuV1/V2 chain this
project wanted from the start. BambuGenericPrinter holds the fields and
behavior every Bambu printer shares (access code, serial number, CA trust,
the TLS test/fetch methods); BambuV1Printer and BambuV2Printer each *have*
one (composition) and are now genuinely separate types, ready to carry
flavour-specific report-schema fields later. Also threads through a new
'sn' (serial number) field the real MQTT topics will need.

Adds real 'fetch the CA automatically' capability, verified against a live
TLS server (not just compiled):
- BambuGenericPrinter::fetch_certificate() does trust-on-first-connect —
  connects once with verification disabled, captures the certificate the
  printer actually presents via native_tls's peer_certificate()/to_der(),
  and returns it as PEM. Deliberately a method you call once by hand
  (examples/fetch_bambu_cert.rs), not something connect() falls back to
  silently, since TOFU trusts whoever's on the network the moment you run
  it. Verified end-to-end against a local openssl s_server: the fetched
  PEM's SHA-256 fingerprint exactly matched the server's real certificate.
- Verified the other direction too: test_bambu_certs (bundled-CA mode)
  correctly REJECTS that same test server's cert, since it wasn't signed
  by the real Bambu CA.

Splitting BambuV1Printer/BambuV2Printer into distinct types broke the
PrinterHandle::BambuV1(x) | PrinterHandle::BambuV2(x) or-pattern in both
examples (or-patterns require every alternative to bind the same type) —
fixed by giving each variant its own match arm.
2026-08-28 21:04:56 +00:00
octoturge 9eab5e5a04 Bambu TLS trust config + finish PrusaLink/PrusaSerial split
Certs:
- Vendor Bambu's shared LAN-mode root CA (certs/bambu_ca2.pem, verified
  self-signed CA:TRUE, see certs/README.md for provenance/fingerprint).
- Config gains bambu_ca_cert_path (per-printer override) and
  bambu_require_valid_cert (the allow/reject flag); printer::bambu::BambuTls
  turns those into BundledCa/Custom/Insecure. connect() doesn't perform a
  real handshake yet (no TLS-capable MQTT client wired in), just reports
  which trust mode it would use.

Also reconciles a rename in flight (Printer -> GenericPrinter trait) and
finishes the PrusaPrinter -> PrusaLinkPrinter/PrusaSerialPrinter split:
added the missing GenericPrinter impl for PrusaSerialPrinter, fixed
PrinterHandle's variant payload types, updated mod.rs's pub use list and
doc comments, and updated the example to the new 5-variant shape.

Verified with cargo check --all-targets (0 errors) and a full run of
cargo run --example printer_polymorphism.
2026-08-28 20:38:10 +00:00
octoturge 5705f36c01 Simplify printer/ module for someone learning Rust
Drop reqwest, RPITIT+Send signature, and multi-variant error enum from the
teaching example so the only new idea per file is the trait override itself.
Real networking stays in adapters/, unaffected.
2026-08-28 18:04:13 +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 03bd07fbd7 Add Printer trait + PrinterHandle enum domain model with polymorphism example 2026-08-28 17:03:21 +00:00