Commit Graph
30 Commits
Author SHA1 Message Date
octoturge b1e733a5fe Bound MQTT publishes and send status refreshes unacked
Windows discovery tool / windows (push) Successful in 1m35s
On the first real dispatch the proxy froze about 4.5 minutes in: pushall was
published at QoS 1 from two tasks while holding the fleet lock, and once the
in-flight window filled every publish blocked forever, stalling telemetry,
heartbeats and the uplink. Publishes now time out after 10 s and pushall is
QoS 0.

Also adds an optional ams_slot to start_print so a job can choose its AMS
slot (single-filament projects, same material type only).
2026-10-30 06:56:24 +00:00
octoturge 0c449c47bc Add a read-only dry run of a print's filament mapping
Windows discovery tool / windows (push) Successful in 1m4s
bambu_mapping_check downloads one project from the card and prints the AMS
mapping the production path would send, starting nothing.
2026-10-29 16:20:03 +00:00
octoturge 9061d39684 Don't let a missing serial listing discard an 'all' discovery on Windows
Windows CI (run 415) ran the discovery tests: 48 passed and one failed,
because port listing reads Linux sysfs and the test assumed it exists.
That is a real limitation, not just a bad test - and in the example it
meant `all` would listen and sweep and then abort on the serial step,
throwing the results away.

The serial step in `all` is now a printed note on failure; the standalone
`serial` command still errors. The Linux-only test is gated to Linux and
a counterpart asserts that off Linux the listing is an error, not a clean
empty result.
2026-10-29 11:17:01 +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 da28597eeb 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.
2026-10-19 07:40:28 +00:00
octoturge a401773406 Discover Klipper, PrusaLink and serial printers too
Discovery found only Bambu printers, so a mixed farm still meant typing
most of printers.toml by hand. It now covers the four vendors fleet.rs
accepts, over both transports.

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

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

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

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

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

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

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

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

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

Unverified, and untestable here: the Klipper and PrusaLink probes (no such
hardware) and all serial detection (no serial device attached, and
/sys/class/tty holds only virtual consoles). Every parser those depend on is
unit tested against recorded protocol text, including the real non-printer
responses above.
2026-10-19 07:28:05 +00:00
octoturge e4344b208b Find printers on the network instead of retyping their details
Setting up a farm meant reading five fields off each printer's screen into
printers.toml, and the serial is the one that punishes a typo: 15 unspaced
alphanumerics that address Bambu's MQTT topics, so a wrong character
produces a gateway that connects to the broker, subscribes to a topic no
printer publishes, and reports nothing.

Two mechanisms, because they fail in different places:

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

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

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

Three decisions that are not incidental:

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

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

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

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

openssl is named directly for the CN read. It is already in the tree -
native-tls is openssl on Linux - so this adds nothing to the build, and
Apache-2.0 passes `cargo deny check licenses`.
2026-10-19 00:51:09 +00:00
octoturge df6dfca3ca Refuse a truncated project, and drive the whole path from the probe
`upload_project_file` already existed - as a `GenericPrinter` trait method,
which is why a grep for `pub async fn` missed it and this commit nearly
reimplemented it. It logged `written` and `byte_count` next to each other and
never compared them, so a short write was something you could only notice by
reading the logs afterwards, and the next line printed the truncated file
anyway. A `.3mf` is a zip: cut the end off and the central directory goes with
it, so the printer gets an archive it cannot open. That is also the failure a
slow transfer invites, and this transfer is slow - 4.96 MB took 31.3s, so a
62 MB project is minutes of exposure.

`print-project <local> [name]` runs that method against a real printer, so
the harness tests the production entry point rather than the three pieces in
sequence. It deliberately takes no `--ams`: the whole point is that the
mapping comes out of the archive. Gated like `start`, on a terminal state and
`--yes-start-a-real-print`.

Confirmed end to end on the P1P (2026-10-15, with the owner's go-ahead), with
the same project the 2026-10-11 run used: the derivation read one PETG/GFG99/
`#2850E0` filament out of `Metadata/slice_info.config` and produced
`mapping=[3]`, padded to five trays; 297,227 bytes went up with an exact byte
count; `IDLE -> PREPARE -> RUNNING`. The AMS on that machine has slot 2 in
black and slot 3 in blue, same product and same `tray_info_idx`, so picking
slot 3 is the colour tie-break doing real work rather than a one-candidate
match.
2026-10-15 22:32:31 +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 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 0077e15490 Record what a real sliced project did, and widen the start guard
A second live run, this time against a project the owner sliced in Bambu
Studio rather than a file already on the card, and through
`start_uploaded_file` itself rather than a hand-built body - so the
root-vs-cache fix is confirmed end to end. `gcode_file` in the printer's own
status came back as `cube-test-2026-10-11.gcode.3mf`, 150 layers, print_type
`local`.

It also found a defect the first run could not. The print heated to 250C,
reached 1%, and then the printer paused itself: `print_error: 50364420`
(`0x03008004`), `hms: []`, `tray_now: 255`. 255 is the external spool, which
is exactly what the hardcoded `use_ams: false` selects - so that pair
overrode the slicer's own filament choice and pointed an AMS-sliced project
at an external spool. The first run looked fine only because it was stopped
during PREPARE, before filament mattered. The doc comment said that pair was
"accepted, and the print ran"; it now says what it actually does, because
silently changing which filament a print uses is worse than not supporting
AMS at all.

The `start` guard demanded IDLE, which meant the harness refused every print
following a cancelled one - a Bambu reports an operator cancellation as
FAILED. IDLE, FINISH and FAILED are all terminal; PREPARE, RUNNING, PAUSE and
SLICING mean the machine is committed, and anything unrecognised is still
refused rather than assumed idle.

`bambu_cache_list` now prints nozzle/bed temperatures and remaining time.
State plus layer number cannot tell a print that is working from one that is
stalled: a P1P sits at layer 0 for minutes during bed levelling and purge,
and the only thing moving in that window is the heat.
2026-10-11 17:55:01 +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 9a314b2b9f 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.
2026-10-03 10:12:05 +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 6ce60542c6 Add pushall + print_status cache; fix envelope, race, and matching bugs
PrintReport now flattens CommandEnvelope (sequence_id/command) -
push_status.schema.json composes the shared envelope like every other
Bambu message; the struct's old doc comment concluded otherwise from a
capture that turned out to be an incomplete example (also missing dozens
of other real fields), not proof the message lacks them.

Added BambuMqttSession's PrintStatusCache: the in-memory
print.push_status object, replaced wholesale on v2 (always full) or on a
v1 message with msg != 1 (forced/full), merged field-by-field otherwise
(v1's organic partial updates) via PrintReport::merge_from. Tracks
last_sequence_id to warn on a gap in the organic stream. BambuPrinter
gets pushall() (v1-only; pushing.pushall doesn't exist in v2's schema at
all) and print_status().

Three real bugs found and fixed via live testing against an actual P1P,
none of them guessable from the schema alone:
- pushall's reply doesn't echo the sequence_id we send - the printer
  stamps its own free-running counter regardless of what triggered it.
  request()'s ID correlation silently can't work here; first fix attempt
  added request_next_of_category (wait for the next message under a
  category, no ID needed).
- That undershot too: matching by category alone let an unrelated
  "print" message (that category carries many commands - gcode_line,
  pause, push_status, ...) satisfy the wait before the real push_status
  reply arrived, so pushall() returned Ok having consumed the wrong
  message - now keyed by (category, command).
- A real ordering race: handle_incoming_report woke a waiting oneshot
  before updating the print_status cache, so a caller could resume and
  check print_status() before the write landed - pushall() returned Ok
  immediately followed by print_status() still showing None. Fixed by
  applying the cache update before sending on any matched oneshot.

Superseded by real protocol knowledge before any of the above needed to
ship, though: push_status carries its own msg field (1 = automatic/
partial, 0 = forced/full) - the actual, reliable, self-describing signal
for which kind a message is, regardless of timing or which client (if
any) triggered it. PrintStatusCache now reads that directly instead of
inferring it from request correlation, and pushall() is plain fire-and-
forget (publish, no wait) since there's nothing left to correlate for -
confirmed live: msg reads correctly off a real P1P, and the cache
populates/updates correctly with no gap warnings across several organic
pushes. request_next_of_category is kept (a future case may still need
"no correlation ID at all" for real), just no longer used here.

Also: the poll loop now logs when it exits instead of returning silently
- that silence is exactly what made a real dropped MQTT connection to a
live H2C indistinguishable from "nothing happening" during testing.
2026-09-07 14:39:55 +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 a77e93940f Fix ensure_trusted(): pin by serial number, not the arbitrary id label
id in printers.toml is just a label you chose for config purposes - nothing
ties it to a specific physical printer, and renaming it (or reusing it for
a different unit down the line) would silently break the pin lookup and
re-trigger trust-on-first-connect for hardware that was already trusted.
The serial number is the one thing about a printer that can't change, so
that's what a pin should be keyed by: certs/pinned/<sn>.pem instead of
certs/pinned/<id>.pem.

Verified against a real TLS server: pinned a printer under one id, renamed
it in printers.toml with the sn left unchanged, and confirmed the second
run found the existing pin silently (no re-TOFU) rather than re-pinning.
2026-08-28 21:22:16 +00:00
octoturge af2f20a45b Add BambuGenericPrinter::ensure_trusted() — smooth auto-connect, SSH-style
The manual fetch_bambu_cert workflow works but needs two commands and a
printers.toml edit. ensure_trusted() collapses that into one call, using
the same trust model SSH uses for host keys:
- a pin already on disk (certs/pinned/<id>.pem) is used directly, no new
  trust decision is made on every run
- no pin yet + bundled CA verifies fine (current-gen printers): nothing to
  do
- no pin yet + bundled CA fails (P1P, etc.): auto-pins via
  trust-on-first-connect, same as fetch_bambu_cert, but automatic
- an *explicit* pin (you set ca_cert_path yourself) never gets silently
  auto-pinned over — a failure there is a real error

examples/auto_connect_bambu.rs demonstrates the one-command flow.

Verified all three states against local test servers, not just compiled:
first run with no pin auto-pins and connects; second run against the same
server is silent (no re-TOFU message) and just verifies against the pin;
third run after swapping the server's certificate correctly FAILS instead
of silently re-pinning — confirms the security property survives the
smoother UX.
2026-08-28 21:18:46 +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 d8b7430296 Add printers.toml (gitignored) + real TLS cert test against actual hardware
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).
2026-08-28 20:48:05 +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 03bd07fbd7 Add Printer trait + PrinterHandle enum domain model with polymorphism example 2026-08-28 17:03:21 +00:00