diff --git a/AGENTS.md b/AGENTS.md index a00a298..aaf8043 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,10 +17,24 @@ The following documents take precedence over this file and must not be weakened: This service is a multi-printer gateway. It translates Bambu Lab's local MQTT telemetry into one STATUS_SPEC v1.0 HTTP surface per configured printer. -The first release is monitoring-only. Do not add control endpoints or invoke -command methods from `bambulabs_api` without an explicitly approved design that -routes execution through `lab-skills`, implements claims and interlocks, and -conforms to STATUS_SPEC v1.1 or later. +Monitoring is the default. A human approved (2026-10-04) a narrow, +human-in-the-loop control plane, enabled per deployment by `dispatch.enabled`: +STATUS_SPEC v1.1 claims plus exactly four verbs -- camera snapshot, chamber +light (added 2026-10-05), `start_print` for an approved job after a person +confirms the plate is empty, and `stop_print`. On 2026-10-20 a human approved +the external spool (`vt_tray`) as a `start_print` mapping target for printers +without an AMS, checked like an AMS tray, and the one `M500` in Bambu's stock +start gcode (saving the bed mesh right after `G29`) as accepted by the gcode +check. On 2026-10-21 a view-only camera (`GET /printers/{id}/camera.jpg`, no +claim, one shared read per printer every 2 s, off on the page until a person +turns it on, never plate-check evidence) was approved, and with it the manual +snapshot step was dropped: a camera start records its own frame under the +claim when the person presses start. Homing was considered and +deliberately not added: it moves the bed into whatever is on the plate. All printer commands go through `bambu_server.dispatch` and +the backend's control methods; nothing else may call `bambulabs_api` command +methods. Adding a verb, relaxing a gate, or letting an agent start a print +without a person needs a new explicitly approved design. Routing control +through `lab-skills` is still owed. ## Working conventions diff --git a/AGENT_RULES.md b/AGENT_RULES.md index dfe4915..20c01e4 100644 --- a/AGENT_RULES.md +++ b/AGENT_RULES.md @@ -5,13 +5,31 @@ first and in full. The authoritative device contract is `../ac-organic-lab/docs/STATUS_SPEC.md`. This file only adds rules specific to the Bambu printer gateway. -1. The service is read-only until a human explicitly approves a control-plane - design integrated with `lab-skills`, cooperative claims, preconditions, and - audited plan execution. +1. Control is limited to what a human approved on 2026-10-04: cooperative + claims, a camera snapshot, the chamber light (approved 2026-10-05), + `start_print` of an approved job (from an AMS tray or, approved 2026-10-20, + the external spool, checked the same way; and, approved the same day, with + the stock start gcode's bed-mesh `M500` right after `G29` accepted -- every + other refused command still refused), and + `stop_print`, all behind `dispatch.enabled` and the gates in + `bambu_server.dispatch`. Any other verb, a relaxed gate, or integration with + `lab-skills` plan execution needs a new, explicitly approved design. 2. Monitoring must never start, pause, resume, stop, heat, home, move, calibrate, upload to, or otherwise control a printer. 3. The service may request MQTT state refreshes in its background monitoring - loop. A dashboard request must never cause printer I/O. -4. Printer access codes, serial numbers, addresses, and raw MQTT payloads are + loop. A status, profile, or queue request must never cause printer I/O; only + a claimed `/control/*` request may, with one exception approved 2026-10-21: + the view-only `GET /printers/{id}/camera.jpg`, present only with + `dispatch.enabled`, which reads the camera port (never publishes a command), + at most once per printer every 2 s shared by all viewers, and whose frames + are never plate-check evidence. The page loads with it off; a person turns + it on. +4. A print is started only by a person who has confirmed the build plate is + empty. (Since 2026-10-21, approved, a camera confirmation needs no separate + snapshot step: the person checks the live view and the gateway records a + frame under the claim when they press start, refusing if it cannot.) An agent must never set `plate_confirmed_empty`, start a print, or + approve a job on its own initiative, and must never retry a failed or + uncertain dispatch -- a person checks the printer first. +5. Printer access codes, serial numbers, addresses, and raw MQTT payloads are local secrets and must not be committed, returned by HTTP, or logged. -5. Tests and development defaults must not contact lab hardware. +6. Tests and development defaults must not contact lab hardware. diff --git a/README.md b/README.md index 2ba0691..19acd36 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,26 @@ # AC Bambu Server -A monitoring-only FastAPI gateway for the Bambu Lab printers in the lab. It -uses [`bambulabs_api`](https://github.com/BambuTools/bambulabs_api) for local -MQTT telemetry and publishes one +A FastAPI gateway for the Bambu Lab printers in the lab. It uses +[`bambulabs_api`](https://github.com/BambuTools/bambulabs_api) for local MQTT +telemetry and publishes one [AC lab STATUS_SPEC v1.2](../ac-organic-lab/docs/STATUS_SPEC.md) surface per printer. This repo conforms to lab status spec v1.2 on its per-printer surfaces; the aggregate gateway envelope stays on v1.0 (it fronts printers and has no primary operation of its own). -The service deliberately exposes **no control endpoints**. The third-party -package supports commands, but those methods are isolated behind a narrow -monitoring adapter and are not reachable from HTTP. Future control work must go -through `lab-skills`, claims, preconditions, audited plans, and the -human-approval rules in the lab contract. +It runs a **submission pipeline**: remote users upload a print artifact (or an +`.stl` the gateway slices with Bambu Studio), the gateway validates it against +the target machine's profile, and valid jobs wait in a per-machine queue with +expected finish times. That path writes to the gateway's own disk and never to +a printer. -It also runs a **submission pipeline**: remote users upload a print artifact, -the gateway validates it against the target machine's profile, and valid jobs -wait in a per-machine queue with expected finish times. That whole path is -read-and-analysis — it writes to the gateway's own disk and never to a printer. -The one printer-touching step, dispatch, is not implemented; see -[Submission pipeline](#submission-pipeline). +By default the service is **monitoring-only** and exposes no control endpoints. +A deployment that sets `dispatch.enabled` gets a narrow, human-in-the-loop +control plane: claims plus four verbs (camera snapshot, chamber light, start an +approved job, stop), behind the gates described in [Control plane](#control-plane). The +third-party library's other command methods remain unreachable. Control is not +yet exposed as `lab-skills` skills; the only caller is a person on the `/ui` +page or a direct HTTP client holding a claim. ## Architecture @@ -28,19 +29,24 @@ Bambu printers -- local MQTT/TLS --> background monitors --> cached status | Lab dashboard ---------------- HTTP GET /printers/{id}/status -+ | -Remote user -- POST /submissions --> validate against ------- + (reads the cache) - the machine profile +Remote user -- POST /submissions --> (.stl: Bambu Studio CLI slice) + validate against the ------+ (reads the cache) + machine profile | v per-machine queue --> GET /printers/{id}/queue + | person approves + v +Person on /ui -- claim, snapshot, confirm plate, start_print (dispatch.enabled only) | - x dispatch: not implemented + v + FTPS upload (size-verified) + MQTT start ``` -Dashboard requests only read the cache. They never connect to a printer or -request a telemetry refresh. The background monitor starts only the MQTT client; -camera and FTP clients are not started. The submission pipeline reads that same -cache and writes only to the gateway's own disk. +Status, profile and queue requests only read the cache. They never connect to a +printer or request a telemetry refresh. The background monitor starts only the +MQTT client. The camera and FTP connections are opened only by the claimed +control routes, one transfer or one frame at a time. ## Install @@ -205,16 +211,61 @@ wait in a per-machine queue with expected finish times. The design contract is [`docs/SUBMISSION_PIPELINE_DESIGN.md`](docs/SUBMISSION_PIPELINE_DESIGN.md). ```text -submitted -> validating -> validated -> queued -> approved -> | dispatch - \-> rejected (terminal) | not implemented - \--------\-> cancelled (terminal) +submitted -> validating -> validated -> queued -> approved -> dispatching -> running -> finished + \-> rejected (terminal) | \-> failed \-> failed + \-----------\-> cancelled (terminal) ``` Everything up to and including approval is analysis and bookkeeping. Approval is a *record*, not an action: it marks the job `approved` and sets -`verdict.dispatch_ready`, and moves nothing. `dispatching`, `running` and -`finished` are declared by the contract but unreachable — no route in this -service can enter them. +`verdict.dispatch_ready`, and moves nothing. Only the control plane +(`dispatch.enabled: true`, see *Control plane* below) moves a job past +`approved`, and only when a person starts it. + +### STL slicing + +With `slicer.enabled`, `POST /submissions` also takes an `.stl` for any printer +listed under `slicer.machine_profiles`, plus a `material` listed under +`slicer.filament_profiles`. The gateway runs the installed Bambu Studio CLI +headlessly in a scratch directory, forcing two machine facts the stock presets +get wrong: the nozzle type the printer reports and the build plate declared in +its profile. The resulting `.3mf` is then validated and queued like any other +upload, and the job carries `provenance` (source mesh hash, presets, slicer +version). Slicing runs inline in the request. + +### Control plane + +Off unless `dispatch.enabled` is set. When on, each printer gets STATUS_SPEC +v1.1 claims and three verbs under `/printers/{id}/control/`: + +| Route | What it does | +|---|---| +| `claim` / `heartbeat` / `release` | Cooperative claim (§5); every other control route needs `X-Claim-Token` or returns 423. | +| `snapshot` | One JPEG frame from the printer's chamber camera, for the plate check. Kept 5 minutes; the frame used to start a job is kept beside the job as `GET /submissions/{id}/plate.jpg`. | +| `light` | Switch the chamber light (`{"on": true}`), e.g. before a snapshot. No motion, no heat. | +| `start_print` | Upload an approved job's `.3mf` and start it. | +| `stop_print` | Stop the job in flight. | + +`start_print` is refused (412, a body naming the failed gate) unless the printer +is observable, idle, not in a failed state and cold (`safe_bed_c`, +`safe_nozzle_c`); a build plate is declared and matches the plate the job was +sliced for; the job revalidates against the printer's current state; every +filament is mapped to a loaded tray holding that material (an AMS tray, or the +external spool as `ams_id` 254 / `tray_id` 0 for a single-filament job on a +printer without an AMS); and the request says +`plate_confirmed_empty: true`, by camera or in person. A camera confirmation +records a frame under this claim: the gateway captures it when start is pressed +(or uses a `/control/snapshot` token sent with the request), and refuses with +502 if the camera delivers none. With `require_verified_identity` (the default) both the approver and the +person starting it must be edge-verified identities (403 otherwise). +`allowed_actions` on `/status` is computed by the same gate, so the two never +disagree. + +Dispatch is attempted once per job and never retried. The upload is verified by +size on the printer; the job becomes `running` only when the printer reports +it. If the printer never does, or the service restarts mid-dispatch, the job is +`failed` with `dispatch.uncertainty` saying what is not known. Look at the +printer before resubmitting. ### Machine profile @@ -231,11 +282,11 @@ whose live field is blank — a dual-nozzle H2D reports no parsable nozzle type. |---|---| | `machine_compatible` | sliced for another printer, or a nozzle diameter/type the machine does not have | | `material_allowed` | the filament is on the machine's forbidden list, or the request's declared material contradicts the sliced one | -| `material_filament_match` | any required material has no matching loaded AMS tray | +| `material_filament_match` | any required material has no matching loaded AMS tray or external spool | | `nozzle_temp_in_band` | the configured **or commanded** nozzle temperature is outside the machine's limit or the loaded filament's window | | `bed_chamber_temp_in_band` | the bed temperature is out of band, or a heated chamber is requested on a machine without one | | `build_fits_plate` | the model's XY footprint exceeds the declared plate | -| `gcode_sanity` | the toolpath contains a refused command (firmware update, EEPROM write, PID retune, cold-extrude override, …) | +| `gcode_sanity` | the toolpath contains a refused command (firmware update, EEPROM write, PID retune, cold-extrude override, …). One `M500` is accepted: Bambu's stock bed-mesh save directly after `G29` in the machine start gcode (approved 2026-10-20) | | `params_present` | filament type, nozzle temperature or bed temperature is missing — or a `.3mf` carries no sliced plate, so no printer could run it | One failing check rejects the submission, and rejection is terminal. @@ -287,7 +338,11 @@ observes. `GET /ui` serves a submission page: pick a machine (its plate size, nozzle, chamber and limits are shown so you know what you are targeting), upload a file, and read the per-check verdict. It also lists that machine's queue with -finish times, and offers Approve / Cancel. +finish times, and offers Approve / Cancel. With the control plane on, an +approved job gets **Start…**: the page claims the printer, shows a fresh frame +from its camera, asks for a tray per filament and for the empty-plate +confirmation, then starts the job and reports what the printer says. A running +job gets **Stop print**. The page never fetches a camera frame on its own. It is one static file with **no build step and no external resources** — no CDN, no npm, no bundler — served from the same origin as the API it calls, so diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index be1eea0..8d81064 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -11,7 +11,9 @@ viewer at `/api/equipment/bambu_gateway/documentation/docs`. `GET /printers` discovers configured printers. For each ID: - `GET /printers/{id}/status` is the STATUS_SPEC v1.2 envelope; inventory is - under `details.ams_trays` and `details.ams_unit_ids`. + under `details.ams_trays` and `details.ams_unit_ids`, and the external spool, + when a filament is selected for it, under `details.external_spool` (same tray + fields, `ams_id` 254, `tray_id` 0; never counted in `ams_trays`). - `GET /printers/{id}/profile` exposes typed `observed.loaded_trays` and `observed.ams_unit_ids`. Stale/unavailable observations are withheld. - `GET /printers/{id}/queue` shows the observed running job and waiting jobs. @@ -19,9 +21,18 @@ viewer at `/api/equipment/bambu_gateway/documentation/docs`. All paths above are relative to the gateway prefix. Reads use the background monitor's cache and never initiate printer I/O. +With `dispatch.enabled`, `GET /printers/{id}/camera.jpg` returns the latest +chamber camera frame (`image/jpeg`, `Cache-Control: no-store`, capture time in +`X-Captured-At`) without a claim. It is the one read that touches the printer: +the camera is read at most once per printer every 2 s and the frame is shared by +all callers; 503 when the camera does not deliver one. A frame from here is +never plate-check evidence: a camera `start_print` records its own frame under +the claim (a fresh capture when no `snapshot_token` is sent, or a +`/control/snapshot` taken under the claim within 5 minutes). + | Tray field | Meaning | |---|---| -| `ams_id`, `tray_id` | Printer-reported unit ID and zero-based slot; together they identify a tray. HT unit IDs are preserved. | +| `ams_id`, `tray_id` | Printer-reported unit ID and zero-based slot; together they identify a tray. HT unit IDs are preserved; 254/0 is the external spool, which `observed.loaded_trays` includes. | | `tray_index` | Legacy nullable field. Do not use MQTT `n` as a slot index. | | `tray_type` | Reported material, such as PLA, PETG, PP, or PC. | | `tray_color` | Reported RGB/RGBA hex, retained even when a display label is declared. | @@ -41,10 +52,25 @@ does not affect validation or establish physical spool identity after a swap. ## Submission boundary and authentication -`POST /submissions` accepts an artifact and metadata, validates it, and queues -passing jobs. Approval and cancellation update gateway records only. There is -no dispatch endpoint, no hardware control route, and approval alone does not -establish a complete filament-to-slot mapping or sufficient remaining quantity. +`POST /submissions` accepts an artifact (`.3mf`, `.gcode`, or `.stl` where +slicing is enabled for the target; `.stl` requires `material`) and metadata, +validates it, and queues passing jobs. A sliced job carries `provenance`. +Approval and cancellation update gateway records only, and approval alone does +not establish a filament-to-slot mapping or sufficient remaining quantity. + +## Control (only when the deployment enables dispatch) + +Per printer, under `/printers/{id}/control/`: `claim`, `heartbeat`, `release` +(STATUS_SPEC §5), then `snapshot`, `light` (`{"on": true|false}`), +`start_print`, `stop_print`, each requiring +`X-Claim-Token` (423 without it). `start_print` takes the approved +`submission_id`, `plate_confirmed_empty: true`, `plate_check_method` +(`printer_camera` with a `snapshot_token`, or `in_person`), and an +`ams_mapping` of `{filament_id, ams_id, tray_id}`. Refusals are 412 with a body +naming the failed gate; `allowed_actions` on `/status` mirrors them, and +`details.claimed_by` names the holder. `GET /submissions/{id}/plate.jpg` returns +the camera frame a start was confirmed against. Without dispatch enabled none +of these paths exist. The gateway trusts edge identity only with its configured shared edge secret. Names supplied directly by clients are unverified labels. Dashboard links use diff --git a/docs/CONTROL_PLANE_DESIGN.md b/docs/CONTROL_PLANE_DESIGN.md index 00edc1d..89bdb0c 100644 --- a/docs/CONTROL_PLANE_DESIGN.md +++ b/docs/CONTROL_PLANE_DESIGN.md @@ -1,10 +1,56 @@ # Bambu Printer Gateway — Control Plane Design (proposal, for review) -**Status:** DRAFT — no control code may ship until this design is approved. +**Status:** PARTIALLY APPROVED AND BUILT (2026-10-04). A human approved a +narrow slice of this design: phase 1 (claims, `details.claimed_by`, +`allowed_actions` from the shared gate), `stop_print` from phase 3, and +`start_print` with its upload from phase 4, plus a camera `snapshot` verb for +the plate check (not in the original table), and on 2026-10-05 the phase 2 +`light` verb (chamber light, any observable state, claim required). On +2026-10-20 the external spool (MQTT `vt_tray`, addressed as AMS 254 tray 0) was +approved as a `start_print` mapping target, so a printer with no AMS (the A1) +can be dispatched. It gets the same material and nozzle-window checks as an AMS +tray, maps a single-filament job only (the command's `use_ams` is all or +nothing), and is sent as `use_ams: false` with mapping `[254]`, as Bambu Studio +does. It counts as loaded only when the printer names a filament for it, which +on the A1 is what the operator selects after feeding the spool. The same day +the gcode check was narrowed for one case: current Bambu Studio start gcode +saves the bed mesh with `M500` right after probing (`G29`, optionally `M400`), +so that `M500` is accepted when it sits inside the machine start gcode, as +confirmed by the `; MACHINE_START_GCODE_END` marker after it. Every other +`M500`, and every other refused command, is still refused. On 2026-10-21 a +view-only camera was approved so a person can watch a printer before submitting +and while its job runs: `GET /printers/{id}/camera.jpg`, only with +`dispatch.enabled`, no claim, no command published, at most one camera read per +printer every 2 s shared by every viewer (failures cached the same way), and +camera reads serialized with the plate-check snapshot. Its frames carry no +snapshot token, so they can never satisfy the plate check. The submission page +shows it under Machine, off until a person presses Show camera, and pauses it +while the tab is hidden. With the live view in place the separate snapshot step +in the start panel was dropped (approved the same day): a camera +`start_print` without a `snapshot_token` makes the gateway capture the frame +itself, under the claim, at the moment start is pressed, and record its hash +and time with the job; if the camera delivers none the start is refused (502) +and nothing is uploaded. A `snapshot_token` from `/control/snapshot` is still +accepted. The person's `plate_confirmed_empty` statement is unchanged. The rest of +phase 2, pause/resume, temperature, motion, filament, and every excluded verb +remain unapproved. Homing to raise the plate into camera view was proposed and +declined: it drives the nozzle into the plate before anyone knows it is clear. +`start_print` uses the human-in-the-loop model of §7 with one addition: a +person must confirm the build plate is empty, by printer camera or in person. +Departures from this draft, recorded rather than smoothed over: + +- Control is reachable over HTTP from the `/ui` page; it is **not** yet routed + through `lab-skills` or plan execution. +- §6.4's reconcile verb is not built. A failed or uncertain dispatch leaves the + job `failed` with `dispatch.uncertainty`; a person clears the printer on the + printer itself. +- The one-shot approval ticket of §7 is realised as: job approval (verified + identity) + a claim + the start request's own plate confirmation, each + recorded on the job. + **Companion:** `docs/SUBMISSION_PIPELINE_DESIGN.md` — the concrete submission → -validation → queue → dispatch flow. Everything in it up to the approval gate is -built; its dispatch step is the work this design gates, and -`submissions.dispatch()` is the named, tested stub waiting on it. +validation → queue → dispatch flow; its dispatch step is implemented in +`bambu_server.dispatch`. **Binding constraints:** `../ac-organic-lab/docs/AGENT_RULES.md` and `../ac-organic-lab/docs/STATUS_SPEC.md` (authoritative). This design must never weaken them. Repo-local rules: `AGENT_RULES.md` (this repo) and `docs/TODO.md`. diff --git a/docs/LAN_ONLY_MIGRATION.md b/docs/LAN_ONLY_MIGRATION.md new file mode 100644 index 0000000..dc3d955 --- /dev/null +++ b/docs/LAN_ONLY_MIGRATION.md @@ -0,0 +1,195 @@ +# Moving the P1S to LAN Only + Developer Mode — plan + +**Status:** PLANNED, not started (decided 2026-10-05). Owner: Yang Cao. +**Scope:** `bambu_p1s_01` only. The H2D stays as it is until the P1S has run +cleanly in this mode. + +## Why + +The control plane (`docs/CONTROL_PLANE_DESIGN.md`) is deployed and its first +live start (submission `b3cdb589`, 2026-10-05 02:19 UTC) did not print: + +- the `.3mf` upload over FTPS succeeded and was size-verified (47,584 bytes); +- the `project_file` start command was published and acknowledged by the + printer's MQTT broker; +- the printer stayed idle (`gcode_state` FINISH, bed and nozzle cold), and the + job failed after 180 s with `dispatch.uncertainty` recorded. + +The P1S runs firmware **01.10.00.00**. Since Bambu's 2025 authorization +change, a printer that is not in LAN Only Mode keeps publishing telemetry to +local MQTT clients but ignores their control commands; local third-party +control requires **LAN Only Mode + Developer Mode**. This is the most likely +cause, but it is **not yet confirmed**: the gateway does not record the +printer's reply to a command, so the refusal itself was never observed. + +## What changes for people (decide and announce first) + +- **Lost:** Bambu Handy remote monitoring and printing, Bambu cloud printing + from Bambu Studio / MakerWorld, cloud print history, and over-the-air + firmware notifications for this printer. +- **Kept:** printing from Bambu Studio on the lab network in LAN mode (needs + the printer's IP and access code), the printer's touchscreen, SD-card + printing, the gateway's monitoring, camera snapshot and control. +- **Firmware updates** become manual (Bambu Studio in LAN mode, or SD card). + Do not update firmware without re-running the verification below: a firmware + release can change the local command surface again. +- The decision recorded in `docs/TODO.md` ("LAN Only Mode remains off, + preserving cloud control") is reversed for the P1S; update that line when + the move is done. + +## Plan + +### 0. Before touching the printer (software, no printer I/O) + +1. **Record command replies.** Make the backend capture the printer's response + to each command (the MQTT report carries `command`, `result` and `reason` + for `project_file`, `ledctrl`, `stop`), store it on the dispatch record, and + surface it in the 502/failed note. Without this, a refusal after the move + would look exactly like today's silent failure. +2. **Confirm the diagnosis cheaply, while still in cloud mode.** With reply + capture deployed, toggle the chamber light from `/bambu/ui/` (a command with + no motion and no heat). A refusal reason naming authorization confirms the + cause before anyone gives up cloud access. If the light command is + *accepted*, stop: the problem is elsewhere (start payload, firmware field + requirements) and this migration may not be needed. +3. **Pin the address.** The gateway, the API unit's egress allowlist and Bambu + Studio all reach the printer by IP. Get a DHCP reservation for the P1S (or + confirm its address is stable) before LAN-only removes the cloud fallback. + +### 1. Switch modes (at the printer, with a person present) + +4. Announce a short window to everyone who uses the P1S. +5. On the printer touchscreen, in its network settings: turn **LAN Only Mode** + on, then turn **Developer Mode** on. (Verify the exact menu path against + Bambu's wiki for firmware 01.10 before the window; it has moved between + releases.) +6. Read the **access code** shown on the screen. If it changed, update + `BAMBU_P1S_01_ACCESS_CODE` in `/etc/device-gateway-staging/bambu.env` + (root-owned; keep a backup), then `sudo systemctl restart bambu-server`. + +### 2. Verify, lowest risk first + +7. `GET /bambu/printers/bambu_p1s_01/status`: telemetry fresh, `activity` + idle, `allowed_actions` includes `light`, `snapshot`, `start_print`. +8. Toggle the chamber light from `/bambu/ui/`; confirm the reply is accepted + and `details.light_state` follows. +9. Take a camera snapshot. +10. First print: a 20 mm PLA cube STL, sliced by the gateway, approved and + started by a signed-in person **standing at the printer**, plate checked in + person, standard AMS tray. Watch it reach `running`, then `finished`. +11. Check that the printer's reported job name matches the uploaded + `gw__*.3mf` (the gateway accepts a state change if it does not, and + notes that in the job history). + +### 3. Afterwards + +12. Update `docs/TODO.md` (live printer configuration) and the README's + control-plane section with the mode the printer now runs in. +13. Tell Bambu Studio users how to add the P1S in LAN mode (IP + access code, + shared through the lab's usual private channel, never in a repo). +14. Delete the stale `gw_*.3mf` files left on the printer's storage by the + failed attempts, from the printer's file manager. + +## Remote Bambu Studio through Tailscale + +**Status:** planned for the week of 2026-10-19, at the user's request on +2026-10-12. No network or printer settings have been changed for this task. +The P1S migration gates above still apply; this does not expand the migration +to the H2D. + +The machine hosting the gateway can also act as a Tailscale subnet router if +it can reach the printer locally and stays online. Bambu Studio then connects +through that machine to the printer's local IP: + +```text +Remote laptop with Bambu Studio + -> Tailscale + -> lab server acting as subnet router + -> printer in LAN Only Mode +``` + +Studio uses the printer's native network services, not the `bambu-server` +HTTP API. Reaching the dashboard over Tailscale alone does not establish this +route. Bambu Studio supports binding across subnets by IP and access code; +use manual binding rather than relying on automatic discovery across the VPN. +This combination should support remote Studio access, but has not been +verified end to end in this lab. Bambu Handy remains unavailable in LAN Only +Mode, and switching cloud/LAN mode remains an on-printer step. + +### Starting observations + +The shell environment inspected on 2026-10-12 had the Tailscale CLI installed +and `net.ipv4.ip_forward = 1`. IPv6 forwarding was disabled. The CLI could not +connect to the local Tailscale daemon. This does not establish whether the +deployment host's daemon is stopped or merely inaccessible from this execution +environment. Confirm on the actual host before changing anything. Existing +advertised routes, tailnet policy, and remote Studio connectivity were not +verified. + +### Setup checklist for the operator + +- [ ] Confirm the actual gateway host, its local route to the printer, and + the printer's stable address/DHCP reservation. Keep addresses and access + codes in gitignored local configuration, never in this document. +- [ ] On that host, inspect `systemctl status tailscaled` and + `tailscale status`. Start/authenticate Tailscale only if needed; preserve + its existing identity, advertised routes, and other settings. +- [ ] Confirm persistent IPv4 forwarding and host firewall forwarding rules + using the official subnet-router setup guide below. IPv6 forwarding is + needed only if IPv6 routes are part of the chosen setup. +- [ ] Record the current routing configuration privately for rollback. + Advertise the selected printer's IPv4 address as a `/32` host route. + Include any existing advertised routes when updating the list; do not + replace them accidentally. This task needs no exit node or whole-campus + route. +- [ ] Approve the advertised route in the Tailscale admin console and ensure + tailnet access policy permits the intended users/devices to reach the + printer's required services. Keep the default subnet-router SNAT unless + the network administrator has designed a return route. +- [ ] Connect the remote laptop to the tailnet and enable acceptance of subnet + routes if needed (Linux clients require this explicitly). Check for an + overlapping local network and confirm the printer address routes through + Tailscale. Verify model-specific service requirements against Bambu's port + reference below; successful MQTT alone does not verify upload or camera. +- [ ] Complete the migration prerequisites above and have a person enable + LAN Only Mode at the printer. Developer Mode is for the gateway's + third-party control integration, not a requirement added by Tailscale. +- [ ] In a current Bambu Studio release, manually add/bind the printer using + its local IP and access code. Verify connection and telemetry from a laptop + outside the lab network. Check camera separately where supported. +- [ ] Validate printing only as a separate human-operated step with someone + checking the physical plate, following the migration verification above. + A connection check does not authorize a print or retry an uncertain job. +- [ ] Confirm gateway monitoring still works. After an agreed server reboot, + verify Tailscale and forwarding persist, and record completion in + `docs/TODO.md` without credentials or addresses. + +### Network rollback + +Withdraw only the newly added printer route and its route approval; restore +the previously recorded advertised-route list and any access-policy changes. +Restore forwarding/firewall settings only if this task changed them and no +other service depends on them. This removes remote Studio access without +changing the printer's mode. Printer-mode rollback is a separate operation +described below. + +### References + +- [Tailscale subnet routers](https://tailscale.com/docs/features/subnet-routers) +- [Tailscale subnet router setup](https://tailscale.com/kb/1019/subnets) +- [Bambu Studio cross-subnet binding support](https://github.com/bambulab/BambuStudio/releases/tag/v01.10.00.74) +- [Bambu printer network ports](https://wiki.bambulab.com/en/general/printer-network-ports) + +## Rollback + +Turn Developer Mode and LAN Only Mode off on the printer (re-binding to the +Bambu account if it asks), restore the access code in `bambu.env` if it +changed, and restart the gateway. Monitoring keeps working in cloud mode; +starts from the gateway will be refused again, and every such attempt fails +safely with its uncertainty recorded. + +## Out of scope + +- The H2D (different camera protocol, no declared plate yet). +- AMS HT tray mapping (refused by the gate until its tray index is verified). +- Homing / motion verbs (declined; see `docs/CONTROL_PLANE_DESIGN.md`). diff --git a/docs/SUBMISSION_PIPELINE_DESIGN.md b/docs/SUBMISSION_PIPELINE_DESIGN.md index 0e466df..506ce04 100644 --- a/docs/SUBMISSION_PIPELINE_DESIGN.md +++ b/docs/SUBMISSION_PIPELINE_DESIGN.md @@ -1,8 +1,12 @@ # Bambu Printer Submission & Validation Pipeline — design spec (for implementation) -**Status:** IMPLEMENTED up to the approval gate (see `docs/TODO.md` for what -shipped and the decisions taken on §12). Dispatch is **not** built: no control -code ships until the dispatch step is separately approved per `AGENT_RULES.md`. +**Status:** IMPLEMENTED through dispatch. The dispatch step (§8) was approved by +a human on 2026-10-04 as human-in-the-loop with a mandatory empty-plate +confirmation, and ships behind `dispatch.enabled` (off by default). See +`README.md` → *Control plane* and `docs/TODO.md` for what shipped and what is +still owed. §8's notes about the FTP client being off and §11's "keep +Camera/FTP off" now read: opened only by claimed control routes, one transfer +or one frame at a time. **Scope:** submission → model validation → per-machine queue → ETA → (approved) dispatch. **This document is the contract the implementer builds against.** diff --git a/docs/TODO.md b/docs/TODO.md index 09df632..250ef1f 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -9,11 +9,23 @@ Only Mode remains off, preserving cloud control. - `/health`, `/printers`, and both `/status` endpoints return HTTP 200 with live MQTT telemetry. -- The gateway remains monitoring-only and exposes no control endpoints. +- Since 2026-10-05 the live deployment runs with `dispatch.enabled` for the P1S. + Its first start was ignored by the printer (firmware 01.10.00.00 in cloud + mode most likely refuses LAN control). The plan to move the P1S to LAN Only + + Developer Mode is `docs/LAN_ONLY_MIGRATION.md`. The printer IP addresses and MAC addresses are intentionally not recorded here; they are site-specific configuration and belong in the gitignored local files. +## Remote Bambu Studio through Tailscale (planned) + +- Planned for the week of **2026-10-19**; requested 2026-10-12. +- Use the gateway host as a subnet router for the selected printer's `/32` + route, then bind Bambu Studio by local IP and access code. +- Follow the [setup and rollback checklist](LAN_ONLY_MIGRATION.md#remote-bambu-studio-through-tailscale). + Host Tailscale state and end-to-end connectivity still need verification; + no network settings were changed. The existing P1S migration gates apply. + ## Rich read-only telemetry (done) - `details` now enriches a `data_ready` status with read-only observations: @@ -24,23 +36,37 @@ they are site-specific configuration and belong in the gitignored local files. - Each getter is isolated so no single failure can poison a poll; `activity` and the top-level state never depend on these. -## Control plane (proposal, awaiting review) +## Control plane (built 2026-10-04, off by default) -See `docs/CONTROL_PLANE_DESIGN.md`. The gateway stays read-only until that -design is approved; no `/control/*` route ships without the v1.1 claim protocol, -per-action preconditions, and human-approval gating it specifies. +Approved scope and departures are in `docs/CONTROL_PLANE_DESIGN.md` (status +block). Built: per-printer claims, `snapshot`, `start_print`, `stop_print`, +`allowed_actions` from the same gate as the 412s, STL slicing via the Bambu +Studio CLI, the `/ui` start flow with the empty-plate confirmation, restart +recovery that fails an interrupted dispatch with its uncertainty. -## Submission pipeline (implemented up to the approval gate) +Still owed: -See `docs/SUBMISSION_PIPELINE_DESIGN.md`. Built: submission intake -(`POST /submissions`), the machine-profile read surface -(`GET /printers/{id}/profile`), the eight-check validator, the job state -machine with a durable store, and the per-machine queue with expected finish -times (`GET /printers/{id}/queue`). +- **First live run.** Enable on the P1S only, declare `plate`, and start a small + PLA cube with a person at the printer. Confirm the printer's `gcode_file` + reports the uploaded name (the job is marked running on a state change even + if it does not), and that the stored upload is visible on the printer's + storage. +- **Camera on the H2D.** The snapshot speaks the P1-series camera protocol + (port 6000). The H2D uses a different one; until it is supported the H2D's + plate check is in person only. +- **Printer storage housekeeping.** Uploaded `gw_*.3mf` files stay on the + printer's storage; nothing deletes them yet. +- **`lab-skills` integration** and the §6.4 human-gated reconcile verb. +- **Slicing is inline** in `POST /submissions`; a slow slice holds the request. -Dispatch is **not** implemented. `submissions.dispatch()` raises -`DispatchUnavailable`, no HTTP route calls it, and no route can enter -`dispatching` / `running` / `finished`. No `/control/*` route exists. +## Submission pipeline (implemented, including dispatch) + +See `docs/SUBMISSION_PIPELINE_DESIGN.md`. Built: submission intake +(`POST /submissions`, including `.stl` slicing), the machine-profile read +surface (`GET /printers/{id}/profile`), the eight-check validator, the job state +machine with a durable store, the per-machine queue with expected finish times +(`GET /printers/{id}/queue`), and dispatch through the control plane above. +The historical notes below describe decisions taken before dispatch existed. Decisions taken where §12 left them open, all following the design's own recommendation: @@ -49,9 +75,9 @@ recommendation: `POST /submissions/{id}/approve` records sign-off and sets `verdict.dispatch_ready`. Since dispatch is stubbed, this only shapes the gate, and relaxing it per machine stays available. -2. **Identity — opaque.** `ac_auth` is not wired here, so `requested_by` and - `approved_by` are opaque strings recorded in job history. They are **not** - authentication; network-layer gating is unchanged. +2. **Identity — opaque, superseded for control.** Without the edge, names are + opaque labels. The control plane requires edge-verified identities for the + approver and the person starting a print (`require_verified_identity`). 3. **Queue — gateway-owned.** Running remaining time comes from printer telemetry, queued durations from the artifact's own slicer estimate. 4. **`.3mf` parsing — standard library.** A sliced plate file embeds diff --git a/printers.example.yaml b/printers.example.yaml index 0edadf0..79ed2a3 100644 --- a/printers.example.yaml +++ b/printers.example.yaml @@ -16,6 +16,33 @@ submissions: # an artifact are read and plate fit is not # reported rather than guessed from a partial scan +# Control plane: claims and the start/stop/snapshot verbs. Off unless enabled +# here. With it off the gateway is exactly the monitoring-only release. +dispatch: + enabled: false + require_verified_identity: true # approver and starter must come through the edge + safe_bed_c: 45 # refuse to start above these: the last job + safe_nozzle_c: 70 # has not cooled and the plate is suspect + confirm_timeout_s: 180 # how long the printer has to report RUNNING + bed_leveling: true + flow_calibration: false + vibration_calibration: false + timelapse: false + +# STL slicing with an installed Bambu Studio (CLI, headless). Off unless enabled. +# `work_dir` must be writable by the service (its home is read-only under systemd). +slicer: + enabled: false + executable: /home/you/.local/bin/bambu-studio + profiles_dir: /home/you/.local/opt/bambu-studio/squashfs-root/resources/profiles/BBL + work_dir: var/slicer + timeout_s: 600 + process_profile: "0.20mm Standard @BBL X1C" + filament_profiles: # material a submitter names -> filament preset + PLA: "Bambu PLA Basic @BBL X1C" + machine_profiles: # printer id -> machine preset; others refuse .stl + bambu_x1c_01: "Bambu Lab X1 Carbon 0.4 nozzle" + printers: - id: bambu_x1c_01 name: Bambu X1 Carbon 01 @@ -30,6 +57,11 @@ printers: nozzle_type: hardened_steel # fallback for printers whose live field is blank nozzle_diameter_mm: 0.4 bed_size_mm: [256, 256] # X, Y + # The build plate installed. The printer cannot detect it, so this is + # the only record; dispatch refuses while it is undeclared and refuses a + # job sliced for another plate. One of: cool_plate, eng_plate, + # hot_plate, textured_plate, supertack_plate. + plate: textured_plate chamber_temperature_c: null # null on a machine with no chamber control limits: # `[min, max]` or `{min_c: , max_c: }` nozzle_temperature_c: [0, 300] diff --git a/result.json b/result.json new file mode 100644 index 0000000..06270de --- /dev/null +++ b/result.json @@ -0,0 +1,10 @@ +{ + "error_string": "Success.", + "export_time": 0, + "layer_height": 0.0, + "plate_index": 0, + "prepare_time": 1, + "return_code": 0, + "sparse_infill_density": 0.0, + "wall_loops": 0 +} diff --git a/src/bambu_server/artifacts.py b/src/bambu_server/artifacts.py index 5fa0ff3..c380995 100644 --- a/src/bambu_server/artifacts.py +++ b/src/bambu_server/artifacts.py @@ -34,6 +34,8 @@ from pydantic import BaseModel, Field +from .plates import plate_from_slicer + ArtifactKind = Literal["3mf", "gcode"] #: Extensions the intake accepts, mapped to the kind the inspector reports. @@ -79,6 +81,13 @@ "M999": "resets the controller after a halt", } +# The one M500 accepted (approved 2026-10-20): Bambu's stock machine start +# gcode saves the bed mesh right after probing it ("G29 ... M400 / M500 ; save +# cali data"). It is allowed only directly after a G29, with nothing but M400 +# between, and only inside the start gcode -- confirmed by the slicer's end +# marker appearing afterwards. Any other M500 is refused as before. +_START_GCODE_END_MARKER = "MACHINE_START_GCODE_END" + _NOZZLE_TEMP_COMMANDS = {"M104", "M109"} _BED_TEMP_COMMANDS = {"M140", "M190"} @@ -110,6 +119,20 @@ class GcodeFinding(BaseModel): detail: str +class FilamentUse(BaseModel): + """One filament the sliced plate calls for, as ``slice_info.config`` lists it. + + ``filament_id`` is the slicer's 1-based extruder/filament index; it is the + position a dispatch tray mapping has to fill. The type is what the loaded + tray must match. + """ + + filament_id: int + filament_type: str + color: str | None = None + used_g: float | None = None + + class ArtifactFacts(BaseModel): """Everything the inspector could observe about one submitted file. @@ -121,6 +144,16 @@ class ArtifactFacts(BaseModel): sliced: bool = False scan_truncated: bool = False filament_types: tuple[str, ...] = () + #: Per-filament detail from the 3mf's slice metadata. Empty for a bare + #: gcode, which names types but not slots. + filaments: tuple[FilamentUse, ...] = () + #: The plate the embedded gcode belongs to (``Metadata/plate_N.gcode``); + #: what a start command has to name. + plate_index: int | None = None + #: The build plate the job was sliced for, in the printer's vocabulary; + #: ``None`` when the slicer named none or one this service does not know. + bed_type: str | None = None + bed_type_label: str | None = None nozzle_temperature_c: float | None = None bed_temperature_c: float | None = None chamber_temperature_c: float | None = None @@ -144,12 +177,17 @@ class ArtifactFacts(BaseModel): class _Scan: """Accumulates observations over one pass of a gcode stream.""" - def __init__(self) -> None: + def __init__(self, *, from_start: bool = False) -> None: self.settings: dict[str, str] = {} self.total_minutes: float | None = None self.fallback_minutes: float | None = None self.command_count = 0 - self.findings: list[GcodeFinding] = [] + self._findings: list[GcodeFinding] = [] + # Only a scan that begins at the top of the file can be inside the + # machine start gcode; a tail scan never is. + self._in_start_gcode = from_start + self._last_word: str | None = None + self._unconfirmed_mesh_saves = 0 self.relative_positioning_seen = False self._min = [None, None, None] # type: list[float | None] self._max = [None, None, None] # type: list[float | None] @@ -171,9 +209,20 @@ def extent(self) -> tuple[float, float, float] | None: round(high_z - low_z, 3), # type: ignore[operator] ) + @property + def findings(self) -> list[GcodeFinding]: + findings = list(self._findings) + if self._unconfirmed_mesh_saves and len(findings) < _MAX_FINDINGS: + # A G29-then-M500 the scan never saw close the start gcode. + findings.append(GcodeFinding( + code="forbidden_command", + detail=f"M500 {_FORBIDDEN_COMMANDS['M500']}; refused in a submitted job", + )) + return findings + def add_finding(self, code: str, detail: str) -> None: - if len(self.findings) < _MAX_FINDINGS: - self.findings.append(GcodeFinding(code=code, detail=detail)) + if len(self._findings) < _MAX_FINDINGS: + self._findings.append(GcodeFinding(code=code, detail=detail)) def observe_line(self, line: str) -> None: stripped = line.strip() @@ -185,6 +234,9 @@ def observe_line(self, line: str) -> None: self._observe_command(stripped) def _observe_comment(self, comment: str) -> None: + if self._in_start_gcode and comment.lstrip(";").strip().upper() == _START_GCODE_END_MARKER: + self._in_start_gcode = False + self._unconfirmed_mesh_saves = 0 # they were in the start gcode match = _COMMENT_SETTING_RE.match(comment) if match: key, raw = match.group(1).lower(), match.group(2).strip() @@ -208,6 +260,13 @@ def _observe_command(self, command_line: str) -> None: if not _COMMAND_WORD_RE.match(word): return self.command_count += 1 + previous = self._last_word + if word != "M400": # a wait between the probe and the save is fine + self._last_word = word + + if word == "M500" and self._in_start_gcode and previous == "G29": + self._unconfirmed_mesh_saves += 1 + return if word == "G90": self._absolute = True @@ -320,7 +379,7 @@ def _scan_stream(stream: IO[bytes], *, budget: int) -> tuple[_Scan, bool]: file) but its motion extents are incomplete, so the caller discards them. """ - scan = _Scan() + scan = _Scan(from_start=True) consumed = 0 remainder = b"" exhausted = False @@ -402,6 +461,14 @@ def _local_name(tag: str) -> str: return tag.rpartition("}")[2] +_PLATE_INDEX_RE = re.compile(r"plate_(\d+)\.gcode$", re.IGNORECASE) + + +def _plate_index(member_name: str) -> int | None: + match = _PLATE_INDEX_RE.search(member_name) + return int(match.group(1)) if match else None + + def _settings_from_project(data: bytes) -> dict[str, str]: """Flatten ``Metadata/project_settings.config`` (JSON) into scalar strings.""" @@ -422,30 +489,52 @@ def _settings_from_project(data: bytes) -> dict[str, str]: return settings -def _slice_info(data: bytes) -> tuple[dict[str, str], tuple[str, ...], float | None]: +class _SliceInfo: + def __init__(self) -> None: + self.settings: dict[str, str] = {} + self.filament_types: tuple[str, ...] = () + self.filaments: tuple[FilamentUse, ...] = () + self.duration_minutes: float | None = None + + +def _slice_info(data: bytes) -> _SliceInfo: """Read ``Metadata/slice_info.config`` for plate metadata and filaments.""" + info = _SliceInfo() root = _parse_xml(data) if root is None: - return {}, (), None + return info - settings: dict[str, str] = {} - filaments: list[str] = [] - duration_minutes: float | None = None + types: list[str] = [] + uses: list[FilamentUse] = [] for plate in root.iter(): if _local_name(plate.tag) == "metadata": key = (plate.get("key") or "").strip().lower() value = (plate.get("value") or "").strip() if key and value: - settings.setdefault(key, value) + info.settings.setdefault(key, value) elif _local_name(plate.tag) == "filament": filament_type = (plate.get("type") or "").strip().upper() - if filament_type and filament_type not in filaments: - filaments.append(filament_type) - prediction = settings.get("prediction") + if not filament_type: + continue + if filament_type not in types: + types.append(filament_type) + filament_id = _first_number(plate.get("id")) + if filament_id is not None and filament_id == int(filament_id): + uses.append( + FilamentUse( + filament_id=int(filament_id), + filament_type=filament_type, + color=(plate.get("color") or "").strip() or None, + used_g=_first_number(plate.get("used_g")), + ) + ) + info.filament_types = tuple(types) + info.filaments = tuple(sorted(uses, key=lambda use: use.filament_id)) + prediction = info.settings.get("prediction") if prediction: - duration_minutes = parse_duration_minutes(prediction) - return settings, tuple(filaments), duration_minutes + info.duration_minutes = parse_duration_minutes(prediction) + return info def _bed_temperature(settings: dict[str, str]) -> float | None: @@ -487,9 +576,15 @@ def _facts_from_settings( extra_duration: float | None, extent_source: str | None, notes: list[str], + filament_uses: tuple[FilamentUse, ...] = (), + plate_index: int | None = None, ) -> ArtifactFacts: settings = scan.settings filaments = _split_values(settings.get("filament_type")) or extra_filaments + bed_type = plate_from_slicer(settings.get("curr_bed_type")) + bed_type_label = (settings.get("curr_bed_type") or "").strip() or None + if bed_type_label and bed_type is None: + notes.append(f"the sliced plate type {bed_type_label!r} is not one this service knows") extent = None if truncated else scan.extent() if truncated and scan.extent() is not None: @@ -519,6 +614,10 @@ def _facts_from_settings( sliced=sliced, scan_truncated=truncated, filament_types=filaments, + filaments=filament_uses, + plate_index=plate_index, + bed_type=bed_type, + bed_type_label=bed_type_label, nozzle_temperature_c=nozzle_temperature, bed_temperature_c=_bed_temperature(settings), chamber_temperature_c=_first_number(settings.get("chamber_temperature")), @@ -580,7 +679,9 @@ def _inspect_3mf(path: Path, *, scan_max_bytes: int) -> ArtifactFacts: plate_names = sorted(name for name in names if _PLATE_GCODE_RE.match(name)) scan = _Scan() truncated = False + plate_index: int | None = None if plate_names: + plate_index = _plate_index(plate_names[0]) if len(plate_names) > 1: notes.append( f"the container holds {len(plate_names)} plates; " @@ -599,13 +700,12 @@ def _inspect_3mf(path: Path, *, scan_max_bytes: int) -> ArtifactFacts: "be run by a printer as submitted" ) - slice_settings: dict[str, str] = {} - slice_filaments: tuple[str, ...] = () - slice_duration: float | None = None + info = _SliceInfo() if slice_member is not None: - slice_settings, slice_filaments, slice_duration = _slice_info(slice_member) + info = _slice_info(slice_member) elif "Metadata/slice_info.config" in names: notes.append("slice_info.config could not be read") + slice_settings = info.settings project_settings = _settings_from_project(project) if project is not None else {} @@ -621,10 +721,12 @@ def _inspect_3mf(path: Path, *, scan_max_bytes: int) -> ArtifactFacts: sliced=bool(plate_names) and scan.command_count > 0, scan=scan, truncated=truncated, - extra_filaments=slice_filaments, - extra_duration=slice_duration, + extra_filaments=info.filament_types, + extra_duration=info.duration_minutes, extent_source="embedded_plate_gcode", notes=notes, + filament_uses=info.filaments, + plate_index=plate_index, ) diff --git a/src/bambu_server/backend.py b/src/bambu_server/backend.py index e2f1ac9..6a700ea 100644 --- a/src/bambu_server/backend.py +++ b/src/bambu_server/backend.py @@ -1,17 +1,87 @@ -"""Narrow monitoring adapter around the command-capable third-party client.""" +"""Narrow adapter around the command-capable third-party client. + +Monitoring reads go through :meth:`BambuLabsBackend.read`. The control plane's +three verbs -- upload a file, start the print it names, stop the running print +-- are the *only* command methods exposed, and only the dispatch gate +(:mod:`bambu_server.dispatch`) calls them, under a claim, after the +preconditions pass. Nothing else in the library's command surface is reachable +from here. +""" from __future__ import annotations +import logging import math +import socket +import ssl +import struct import threading -from dataclasses import dataclass +import time +from dataclasses import dataclass, replace from datetime import UTC, datetime +from pathlib import Path from typing import Protocol, TypedDict import bambulabs_api as bambu from .config import PrinterCredentials, PrinterDefinition +logger = logging.getLogger(__name__) + +#: The printer's chamber-camera service (P1 series: authenticated JPEG frames). +_CAMERA_PORT = 6000 + +#: Models with no chamber temperature sensor. Their firmware still reports a +#: fixed ``chamber_temper`` (5 C on the P1S and A1), which is a placeholder, not +#: a reading. Matched exactly against the operator-declared ``model``; an +#: undeclared or unlisted model keeps whatever the printer reports. +_NO_CHAMBER_SENSOR_MODELS = frozenset({"p1p", "p1s", "a1", "a1 mini"}) + + +#: The printer-side id of the external spool holder (MQTT ``vt_tray``). Bambu +#: Studio addresses it with this id, and a job fed from it is started with +#: ``use_ams`` off. It is carried through the gateway as AMS unit 254, slot 0. +EXTERNAL_SPOOL_ID = 254 + + +def _has_chamber_sensor(model: str | None) -> bool: + if not model: + return True + name = " ".join(model.lower().split()).removeprefix("bambu lab ") + return name not in _NO_CHAMBER_SENSOR_MODELS + + +class PrinterCommandError(RuntimeError): + """A command could not be delivered to the printer. + + Raised only when delivery itself failed (no connection, the publish was not + confirmed, an upload could not be verified). Delivery is not execution: a + command that was delivered may still not have been acted on, which is the + dispatch step's job to observe. + """ + + +@dataclass(frozen=True) +class UploadResult: + remote_name: str + byte_size: int + + +@dataclass(frozen=True) +class StartPrintCommand: + """Everything the printer's ``project_file`` command needs, resolved.""" + + remote_name: str + plate_index: int + bed_type: str + use_ams: bool + #: Per filament (in slicer order), the printer-side tray index. + ams_mapping: tuple[int, ...] + bed_leveling: bool = True + flow_calibration: bool = False + vibration_calibration: bool = False + timelapse: bool = False + @dataclass(frozen=True) class AmsTrayReading: @@ -61,6 +131,8 @@ class PrinterReading: skipped_objects: list[int] | None = None ams_trays: list[AmsTrayReading] | None = None ams_unit_ids: list[int] | None = None + #: The external spool, when the printer reports a filament on it. + external_spool: AmsTrayReading | None = None class AdvancedReading(TypedDict): @@ -75,6 +147,7 @@ class AdvancedReading(TypedDict): skipped_objects: list[int] | None ams_trays: list[AmsTrayReading] | None ams_unit_ids: list[int] | None + external_spool: AmsTrayReading | None class PrinterBackend(Protocol): @@ -84,6 +157,20 @@ def stop(self) -> None: ... def read(self) -> PrinterReading: ... + # -- control plane (dispatch only) --------------------------------------- + + def upload_file(self, path: Path, remote_name: str) -> UploadResult: ... + + def start_print(self, command: StartPrintCommand) -> None: ... + + def stop_print(self) -> None: ... + + def set_chamber_light(self, on: bool) -> None: ... + + def current_job_file(self) -> str | None: ... + + def snapshot(self, timeout_s: float = 10.0) -> bytes: ... + def _optional_int(value: object) -> int | None: """Coerce a tray field to ``int`` or drop it. @@ -114,10 +201,13 @@ def _number(value: object) -> int | float | None: class BambuLabsBackend: - """MQTT-only use of ``bambulabs_api.Printer``. + """``bambulabs_api.Printer`` with MQTT monitoring plus the control verbs. - Camera and FTP clients are never started. Public command methods remain - deliberately unreachable from the HTTP layer. + The library's camera thread is never started; a snapshot opens its own + short-lived connection. The FTP client is used for exactly one thing -- + uploading an artifact dispatch is about to start -- and the connection is + opened and closed around that single transfer. No command method is + reachable from an HTTP handler except through :mod:`bambu_server.dispatch`. """ def __init__( @@ -131,7 +221,11 @@ def __init__( credentials.serial.get_secret_value(), ) self._lock = threading.Lock() + # One camera connection at a time: the viewer and the plate-check + # snapshot share the printer's camera service. + self._camera_lock = threading.Lock() self._data_updated_at: datetime | None = None + self._has_chamber_sensor = _has_chamber_sensor(definition.model) self._printer.mqtt_client.on_message_handler = self._on_message def _on_message(self, *_args: object) -> None: @@ -144,6 +238,178 @@ def start(self) -> None: def stop(self) -> None: self._printer.mqtt_stop() + # -- control plane ----------------------------------------------------- + + def upload_file(self, path: Path, remote_name: str) -> UploadResult: + """Upload ``path`` to the printer's storage root as ``remote_name``. + + The library's own ``upload_file`` wraps every exception into a log line + and returns ``None``, so a failed transfer would look like a success. + This drives its FTPS client directly instead: connect, store, then ask + the printer for the stored size and refuse to call the upload done + unless it matches the local file byte for byte. + """ + + expected = path.stat().st_size + ftp = self._printer.ftp_client + ftps = ftp.ftps + try: + ftps.connect(host=ftp.server_ip, port=ftp.port) + ftps.login(ftp.user, ftp.access_code) + ftps.prot_p() + with path.open("rb") as handle: + ftps.storbinary(f"STOR {remote_name}", handle, blocksize=32768) + stored = ftps.size(remote_name) + except Exception as exc: # ftplib's error hierarchy is wide and not ours + raise PrinterCommandError(f"upload failed: {type(exc).__name__}") from exc + finally: + try: + ftps.close() + except Exception: # noqa: BLE001 - closing a dead socket is not news + pass + if stored != expected: + raise PrinterCommandError( + f"upload not verified: printer reports {stored} bytes, expected {expected}" + ) + return UploadResult(remote_name=remote_name, byte_size=expected) + + def start_print(self, command: StartPrintCommand) -> None: + """Publish the ``project_file`` command for an uploaded 3mf. + + Built here rather than through the library's ``start_print``, which + hard-codes ``bed_type`` to a textured plate: the plate is a machine + fact the gateway has already checked, and naming the wrong one makes + the printer heat the bed for a surface it does not have. The payload + otherwise mirrors the library's (and Bambu Studio's) field set. + """ + + payload = { + "print": { + "command": "project_file", + "param": f"Metadata/plate_{command.plate_index}.gcode", + "file": command.remote_name, + "url": f"ftp:///{command.remote_name}", + "bed_type": command.bed_type, + "bed_leveling": command.bed_leveling, + "flow_cali": command.flow_calibration, + "vibration_cali": command.vibration_calibration, + "timelapse": command.timelapse, + "layer_inspect": False, + "use_ams": command.use_ams, + "ams_mapping": list(command.ams_mapping), + "skip_objects": None, + "sequence_id": "10000000", + } + } + self._publish(payload) + + def stop_print(self) -> None: + self._publish({"print": {"command": "stop"}}) + + def set_chamber_light(self, on: bool) -> None: + """Switch the chamber LED. No motion and no heat; it only lights the camera.""" + + self._publish( + { + "system": { + "command": "ledctrl", + "led_node": "chamber_light", + "led_mode": "on" if on else "off", + "led_on_time": 500, + "led_off_time": 500, + "loop_times": 0, + "interval_time": 0, + "sequence_id": "10000001", + } + } + ) + + def current_job_file(self) -> str | None: + """The file the printer says it is printing, from the cached telemetry.""" + + try: + name = self._printer.mqtt_client.gcode_file() + except Exception: # noqa: BLE001 - a missing field is "not reported" + return None + return str(name).strip() or None + + def snapshot(self, timeout_s: float = 10.0) -> bytes: + """One JPEG frame from the printer's chamber camera, one reader at a time.""" + + with self._camera_lock: + return self._read_camera_frame(timeout_s) + + def _read_camera_frame(self, timeout_s: float) -> bytes: + """One JPEG frame from the printer's chamber camera. + + Opens its own authenticated TLS connection to the camera port, reads + until a complete frame arrives, and closes. The library's camera + client is a perpetual reconnecting thread; a confirmation step wants + one current picture, not a stream, so this does not use it. + """ + + host = self._printer.ip_address + access_code = str(self._printer.access_code) + auth = bytearray() + auth += struct.pack("= expected: + image = bytes(frame[:expected]) + if image[:2] != b"\xff\xd8" or image[-2:] != b"\xff\xd9": + raise PrinterCommandError("camera frame is not a JPEG") + return image + except PrinterCommandError: + raise + except (OSError, ssl.SSLError) as exc: + raise PrinterCommandError(f"camera unreachable: {type(exc).__name__}") from exc + raise PrinterCommandError("camera did not deliver a frame in time") + + def _publish(self, payload: dict[str, object]) -> None: + """Publish one command and insist the broker accepted it. + + Uses the library's own publish path (connection check + wait for the + broker's acknowledgement) via its name-mangled helper; a library + upgrade that renames it fails loudly here rather than silently + skipping the command. + """ + + publish = getattr( + self._printer.mqtt_client, "_PrinterMQTTClient__publish_command", None + ) + if publish is None: + raise PrinterCommandError("bambulabs_api publish helper not found; library changed") + if not self._printer.mqtt_client_connected(): + raise PrinterCommandError("MQTT is not connected") + if not publish(payload): + raise PrinterCommandError("the MQTT broker did not confirm the command") + section = next(iter(payload.values())) + command = section.get("command") if isinstance(section, dict) else None + logger.info("Published %s command", command or "unnamed") + def read(self) -> PrinterReading: connected = bool(self._printer.mqtt_client_connected()) ready = bool(self._printer.mqtt_client_ready()) @@ -165,7 +431,11 @@ def read(self) -> PrinterReading: activity=str(self._printer.get_current_state()), bed_temperature_c=_number(self._printer.get_bed_temperature()), nozzle_temperature_c=_number(self._printer.get_nozzle_temperature()), - chamber_temperature_c=_number(self._printer.get_chamber_temperature()), + chamber_temperature_c=( + _number(self._printer.get_chamber_temperature()) + if self._has_chamber_sensor + else None + ), progress_percent=_number(self._printer.get_percentage()), remaining_time_minutes=_number(self._printer.get_time()), current_layer=int(self._printer.current_layer_num()), @@ -194,6 +464,7 @@ def _advanced_reading(self) -> AdvancedReading: "skipped_objects": None, "ams_trays": None, "ams_unit_ids": None, + "external_spool": None, } try: @@ -228,8 +499,27 @@ def _advanced_reading(self) -> AdvancedReading: pass values["ams_unit_ids"], values["ams_trays"] = self._read_ams_inventory() + values["external_spool"] = self._read_external_spool() return values + def _read_external_spool(self) -> AmsTrayReading | None: + """The external spool (``vt_tray``), decoded like an AMS tray. + + Reported as loaded only when the printer names a filament for it -- + on the A1 that is what the operator selects on the screen after + feeding a spool by hand. Read from the same local cache as the AMS. + """ + try: + tray = self._printer.mqtt_client.dump().get("print", {}).get("vt_tray") + if not isinstance(tray, dict): + return None + reading = _tray_reading(EXTERNAL_SPOOL_ID, 0, tray) + # A hand-fed spool has no remaining-filament sensor; the A1 reports + # 0, which is a placeholder, not "empty". + return replace(reading, remaining_percent=None) if reading else None + except (AttributeError, TypeError, ValueError, OverflowError): + return None + def _read_ams_inventory(self) -> tuple[list[int] | None, list[AmsTrayReading] | None]: """Decode only allowlisted fields from the MQTT client's local cache. @@ -259,34 +549,40 @@ def _read_ams_inventory(self) -> tuple[list[int] | None, list[AmsTrayReading] | if tray_id is None or tray_id < 0 or tray_id in seen: return None, None seen.add(tray_id) - material = _tray_text(tray.get("tray_type")) - # An id-only slot is empty; optional calibration/spool-tag - # fields are not evidence that filament is loaded. - if material is None: - continue - remaining = _optional_int(tray.get("remain")) - trays.append( - AmsTrayReading( - ams_id=ams_id, - tray_id=tray_id, - tray_type=material, - tray_color=_tray_text(tray.get("tray_color")), - tray_weight=_tray_text(tray.get("tray_weight")), - tray_diameter=_tray_text(tray.get("tray_diameter")), - tray_temp=_tray_text(tray.get("tray_temp")), - nozzle_temp_min=_optional_int(tray.get("nozzle_temp_min")), - nozzle_temp_max=_optional_int(tray.get("nozzle_temp_max")), - remaining_percent=( - remaining if remaining is not None and 0 <= remaining <= 100 - else None - ), - ) - ) + reading = _tray_reading(ams_id, tray_id, tray) + if reading is not None: + trays.append(reading) except (AttributeError, TypeError, ValueError, OverflowError): return None, None return unit_ids, trays +def _tray_reading(ams_id: int, tray_id: int, tray: dict) -> AmsTrayReading | None: + """Decode one tray's allowlisted fields, or ``None`` when it holds nothing. + + An id-only slot is empty; optional calibration/spool-tag fields are not + evidence that filament is loaded. + """ + material = _tray_text(tray.get("tray_type")) + if material is None: + return None + remaining = _optional_int(tray.get("remain")) + return AmsTrayReading( + ams_id=ams_id, + tray_id=tray_id, + tray_type=material, + tray_color=_tray_text(tray.get("tray_color")), + tray_weight=_tray_text(tray.get("tray_weight")), + tray_diameter=_tray_text(tray.get("tray_diameter")), + tray_temp=_tray_text(tray.get("tray_temp")), + nozzle_temp_min=_optional_int(tray.get("nozzle_temp_min")), + nozzle_temp_max=_optional_int(tray.get("nozzle_temp_max")), + remaining_percent=( + remaining if remaining is not None and 0 <= remaining <= 100 else None + ), + ) + + def _tray_text(value: object) -> str | None: if not isinstance(value, str): return None diff --git a/src/bambu_server/camera.py b/src/bambu_server/camera.py new file mode 100644 index 0000000..a25260d --- /dev/null +++ b/src/bambu_server/camera.py @@ -0,0 +1,76 @@ +"""View-only camera frames, shared and rate-limited (approved 2026-10-21). + +A person choosing a printer, or watching its job, can see the chamber camera +without holding a claim. That is printer I/O outside ``/control/*``, so it is +held to three limits: + +* **Shared.** At most one camera read per printer per ``min_interval_s``, + however many pages are open; every viewer gets the same cached frame. +* **No commands.** A frame read opens the camera port only; nothing is + published to the printer. +* **Not evidence.** A viewer frame carries no snapshot token, so it can never + stand in for the plate check: a camera ``start_print`` captures its own + frame under the claim when start is pressed. + +A failed read is cached for the same interval, so an unreachable camera is not +retried by every poll. +""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from datetime import UTC, datetime +from time import monotonic + +from .backend import PrinterBackend, PrinterCommandError + +#: The approved refresh: one read per printer every 2 s at most. +DEFAULT_MIN_INTERVAL_S = 2.0 + + +class CameraUnavailable(RuntimeError): + """The printer's camera did not deliver a frame. The message is safe to show.""" + + +@dataclass(frozen=True) +class CameraFrame: + image: bytes + captured_at: datetime + + +class CameraViewer: + def __init__( + self, + backends: dict[str, PrinterBackend], + *, + min_interval_s: float = DEFAULT_MIN_INTERVAL_S, + timeout_s: float = 5.0, + ) -> None: + self._backends = backends + self._min_interval_s = min_interval_s + self._timeout_s = timeout_s + self._locks: dict[str, asyncio.Lock] = {} + # printer id -> (monotonic read time, frame or failure message) + self._latest: dict[str, tuple[float, CameraFrame | str]] = {} + + async def frame(self, printer_id: str) -> CameraFrame: + lock = self._locks.setdefault(printer_id, asyncio.Lock()) + async with lock: + cached = self._latest.get(printer_id) + if cached is None or monotonic() - cached[0] >= self._min_interval_s: + cached = (monotonic(), await self._read(printer_id)) + self._latest[printer_id] = cached + result = cached[1] + if isinstance(result, str): + raise CameraUnavailable(result) + return result + + async def _read(self, printer_id: str) -> CameraFrame | str: + try: + image = await asyncio.to_thread( + self._backends[printer_id].snapshot, self._timeout_s + ) + except PrinterCommandError as exc: + return f"camera unavailable: {exc}" + return CameraFrame(image=image, captured_at=datetime.now(UTC)) diff --git a/src/bambu_server/claims.py b/src/bambu_server/claims.py new file mode 100644 index 0000000..59312a9 --- /dev/null +++ b/src/bambu_server/claims.py @@ -0,0 +1,129 @@ +"""STATUS_SPEC §5 cooperative claims, one registry per printer. + +A claim is a cheap optimistic lock: a session asks for it, keeps it alive with +heartbeats, and releases it when done. The gateway enforces it hard on every +``/control/*`` route (``X-Claim-Token`` or 423), because the one action those +routes exist for -- starting a print -- is exactly the kind of thing two callers +must not race on. + +Claims are cooperative, not authenticated (spec §5): the token proves that the +caller is the session that claimed, not who that session is. Identity comes +from the trusted edge (:mod:`bambu_server.identity`) and is recorded separately. + +Expiry is evaluated lazily on every access rather than by a background reaper: +there is nothing to do when a claim lapses except stop honouring it, and +checking the clock at the moment of use cannot drift from a timer. +""" + +from __future__ import annotations + +import secrets +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta + +from sdl_lab_contract.claims import ClaimedBy, ClaimRequest, ClaimResponse + +#: Bounds the device applies to a requested TTL (spec: "device may clamp"). +MIN_TTL_S = 5.0 +MAX_TTL_S = 300.0 + + +class ClaimConflict(RuntimeError): + """Another session holds the claim.""" + + def __init__(self, holder: ClaimedBy) -> None: + super().__init__(f"claimed by {holder.owner} until {holder.expires_at.isoformat()}") + self.holder = holder + + +class ClaimLost(RuntimeError): + """The presented token matches no live claim.""" + + +@dataclass +class _Claim: + token: str + owner: str + session_id: str + ttl_s: float + expires_at: datetime + + def holder(self) -> ClaimedBy: + return ClaimedBy(session_id=self.session_id, owner=self.owner, expires_at=self.expires_at) + + +class ClaimRegistry: + """The claim state of one printer.""" + + def __init__(self) -> None: + self._claim: _Claim | None = None + + # -- reads ------------------------------------------------------------- + + def holder(self, now: datetime | None = None) -> ClaimedBy | None: + """Who holds the claim right now, or ``None`` (including after expiry).""" + + claim = self._live(now or datetime.now(UTC)) + return claim.holder() if claim else None + + def verify(self, token: str | None, now: datetime | None = None) -> bool: + """True when ``token`` is the live claim's token.""" + + claim = self._live(now or datetime.now(UTC)) + return bool(claim and token and secrets.compare_digest(claim.token, token)) + + # -- writes ------------------------------------------------------------ + + def claim(self, request: ClaimRequest, now: datetime | None = None) -> ClaimResponse: + """Acquire the claim, or raise :class:`ClaimConflict`. + + A repeat request from the session that already holds it is idempotent: + it extends the claim and returns a fresh token (the spec allows either). + """ + + now = now or datetime.now(UTC) + current = self._live(now) + if current is not None and current.session_id != request.session_id: + raise ClaimConflict(current.holder()) + ttl = min(max(float(request.ttl_s), MIN_TTL_S), MAX_TTL_S) + claim = _Claim( + token=secrets.token_urlsafe(32), + owner=request.owner, + session_id=request.session_id, + ttl_s=ttl, + expires_at=now + timedelta(seconds=ttl), + ) + self._claim = claim + return ClaimResponse( + claim_token=claim.token, + # Half the TTL leaves a missed beat before the claim lapses. + heartbeat_interval_s=ttl / 2, + expires_at=claim.expires_at, + ) + + def heartbeat(self, token: str | None, now: datetime | None = None) -> datetime: + """Extend the claim and return the new expiry, or raise :class:`ClaimLost`.""" + + now = now or datetime.now(UTC) + if not self.verify(token, now): + raise ClaimLost("no live claim matches the presented token") + assert self._claim is not None + self._claim.expires_at = now + timedelta(seconds=self._claim.ttl_s) + return self._claim.expires_at + + def release(self, token: str | None, now: datetime | None = None) -> None: + """Release the claim. Idempotent: an unknown token is not an error.""" + + if self.verify(token, now): + self._claim = None + + # -- internals --------------------------------------------------------- + + def _live(self, now: datetime) -> _Claim | None: + claim = self._claim + if claim is None: + return None + if claim.expires_at <= now: + self._claim = None + return None + return claim diff --git a/src/bambu_server/config.py b/src/bambu_server/config.py index 4f14f40..1e3f730 100644 --- a/src/bambu_server/config.py +++ b/src/bambu_server/config.py @@ -88,6 +88,12 @@ class MachineProfileConfig(BaseModel): nozzle_type: str | None = Field(default=None, max_length=60) nozzle_diameter_mm: float | None = Field(default=None, gt=0, le=2.0) bed_size_mm: tuple[float, float] | None = None + #: The build plate physically installed, in the printer's own vocabulary + #: (the ``bed_type`` a print command names). Declared, never observed: a + #: P1S does not detect its plate, so this is the only place the fact + #: exists. Dispatch refuses a model sliced for a different plate, and + #: refuses outright while it is undeclared. + plate: BedPlate | None = None chamber_temperature_c: float | None = None limits: MachineLimits = Field(default_factory=MachineLimits) ams: AmsPolicy = Field(default_factory=AmsPolicy) @@ -100,6 +106,11 @@ def validate_bed(cls, value: tuple[float, float] | None) -> tuple[float, float] return value +#: Plate names as the printer's print command spells them. The slicer's +#: ``curr_bed_type`` strings map onto these in :mod:`bambu_server.plates`. +BedPlate = Literal["cool_plate", "eng_plate", "hot_plate", "textured_plate", "supertack_plate"] + + class PrinterDefinition(BaseModel): id: str = Field(pattern=r"^[a-z][a-z0-9_]*$") name: str = Field(min_length=1, max_length=120) @@ -133,12 +144,79 @@ class SubmissionSettings(BaseModel): retain_terminal_days: float | None = Field(default=30.0, gt=0) +class DispatchSettings(BaseModel): + """The control plane's switches. Off unless the local config turns it on. + + ``enabled: false`` is the development and test default (AGENT_RULES 5: a + default must not be able to contact lab hardware). With it off the gateway + behaves exactly as the monitoring-only release did: no claim routes, no + control routes, ``allowed_actions`` empty. + """ + + enabled: bool = False + #: Refuse dispatch and stop unless both the approver and the caller are + #: edge-verified identities. A direct tailnet caller can still submit, + #: but cannot start a printer. Only a test should turn this off. + require_verified_identity: bool = True + #: A print may only be started on a cold machine. Above either value the + #: previous job has not finished cooling (or something is still heating), + #: and the plate state is not trustworthy. + safe_bed_c: float = Field(default=45.0, gt=0) + safe_nozzle_c: float = Field(default=70.0, gt=0) + #: How long after publishing the start command the printer has to report + #: the job in flight before the dispatch is recorded as failed. Covers the + #: P1S's file-prepare phase; a bed-leveling pass happens *after* PREPARE. + confirm_timeout_s: float = Field(default=180.0, ge=10, le=1800) + #: Printer-side pre-print steps, forwarded in the start command. + bed_leveling: bool = True + flow_calibration: bool = False + vibration_calibration: bool = False + timelapse: bool = False + + +class SlicerSettings(BaseModel): + """Bambu Studio CLI slicing of an uploaded ``.stl``. + + Off by default: slicing shells out to an installed Bambu Studio, which is a + machine-local fact. ``executable`` is the launcher; ``profiles_dir`` is + Bambu Studio's bundled ``resources/profiles/BBL`` directory, where the + machine, process and filament presets live; ``work_dir`` is where the + slicer may write (the service runs with a read-only home). + """ + + enabled: bool = False + executable: Path = Path("bambu-studio") + profiles_dir: Path | None = None + work_dir: Path = Path("var/slicer") + timeout_s: float = Field(default=600.0, ge=30, le=3600) + #: The process preset applied to every slice. One, deliberately: the + #: submitter picks a material, not a print profile. + process_profile: str = "0.20mm Standard @BBL X1C" + #: Material name (as the submitter types it, upper-cased) to the filament + #: preset to slice with. A material with no entry cannot be sliced here. + filament_profiles: dict[str, str] = Field(default_factory=dict) + #: Printer id to the machine preset for it (e.g. "Bambu Lab P1S 0.4 nozzle"). + #: A printer with no entry does not accept ``.stl`` uploads. + machine_profiles: dict[str, str] = Field(default_factory=dict) + #: Ceiling on an uploaded mesh. + max_stl_bytes: int = Field(default=100 * 1024 * 1024, ge=1024) + + @field_validator("filament_profiles", mode="before") + @classmethod + def upper_keys(cls, value: Any) -> Any: + if isinstance(value, dict): + return {str(key).strip().upper(): preset for key, preset in value.items()} + return value + + class Settings(BaseModel): printers: list[PrinterDefinition] = Field(min_length=1) poll_interval_seconds: float = Field(default=2.0, ge=0.5, le=60.0) stale_after_seconds: float = Field(default=20.0, ge=2.0, le=600.0) cors_origins: list[str] = Field(default_factory=lambda: ["http://localhost:8000"]) submissions: SubmissionSettings = Field(default_factory=SubmissionSettings) + dispatch: DispatchSettings = Field(default_factory=DispatchSettings) + slicer: SlicerSettings = Field(default_factory=SlicerSettings) @model_validator(mode="after") def validate_unique_printers(self) -> Settings: @@ -212,4 +290,6 @@ def load_settings(path: str | Path | None = None) -> Settings: settings.submissions.directory = ( resolved.parent / settings.submissions.directory ).resolve() + if not settings.slicer.work_dir.is_absolute(): + settings.slicer.work_dir = (resolved.parent / settings.slicer.work_dir).resolve() return settings diff --git a/src/bambu_server/dispatch.py b/src/bambu_server/dispatch.py new file mode 100644 index 0000000..159a5d8 --- /dev/null +++ b/src/bambu_server/dispatch.py @@ -0,0 +1,863 @@ +"""Dispatch: the gate in front of the printer, and the one step that goes through it. + +What the lab's rules ask of a control plane meets here: + +* **one predicate, two surfaces** -- :meth:`ControlGate.start_print_blockers` + decides both whether ``start_print`` appears in ``allowed_actions`` and + whether a request for it is refused with 412 (STATUS_SPEC §6.2); +* **a claim** on every control route (423 without a live token); +* **a human's statement** that the build plate is empty, recorded with how + they looked and, for a camera check, the hash of the frame they saw; +* **revalidation** against the printer as it is now; +* **an explicit tray mapping**, checked against the loaded spools; +* **honest outcomes** -- ``running`` only once the printer reports it, and a + step that ends without the printer settling the question is ``failed`` with + the uncertainty written down. Nothing here retries. + +Refusals never touch ``last_error`` (STATUS_SPEC §6.3). +""" + +from __future__ import annotations + +import asyncio +import base64 +import contextlib +import hashlib +import logging +import re +import secrets +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Literal + +from pydantic import BaseModel, Field + +from .artifacts import ArtifactError, ArtifactFacts, inspect_artifact +from .backend import PrinterBackend, PrinterCommandError, StartPrintCommand +from .claims import ClaimRegistry +from .config import DispatchSettings +from .identity import Actor +from .monitor import PrinterMonitor +from .plates import plate_label +from .profiles import MachineProfile +from .submissions import ( + DispatchRecord, + InvalidTransition, + PlateCheck, + PlateCheckMethod, + SubmissionJob, + SubmissionStore, + TrayAssignment, +) +from .validation import validate_model + +logger = logging.getLogger(__name__) + +#: A camera frame is evidence of the plate only briefly. +SNAPSHOT_MAX_AGE_S = 300.0 +_SNAPSHOT_RETENTION_S = 3600.0 +#: Standard AMS units are numbered 0-3; HT units start at 128. +_STANDARD_AMS_UNITS = 4 +_REMOTE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+") + + +class ControlRefusal(Exception): + """A control request the gateway will not carry out.""" + + def __init__(self, status: int, body: dict[str, object]) -> None: + super().__init__(str(body.get("detail", "refused"))) + self.status = status + self.body = body + + +class StartPrintRequest(BaseModel): + submission_id: str = Field(pattern=r"^[0-9a-f]{32}$") + #: Must be literally true, so the record carries an affirmative statement. + plate_confirmed_empty: Literal[True] + plate_check_method: PlateCheckMethod + snapshot_token: str | None = Field(default=None, max_length=64) + ams_mapping: list[TrayAssignment] = Field(min_length=1, max_length=16) + dispatched_by: str | None = Field(default=None, min_length=1, max_length=120) + + +class StopPrintRequest(BaseModel): + reason: str | None = Field(default=None, max_length=500) + stopped_by: str | None = Field(default=None, min_length=1, max_length=120) + + +class SnapshotResponse(BaseModel): + token: str + printer_id: str + captured_at: datetime + sha256: str + content_type: Literal["image/jpeg"] = "image/jpeg" + image_base64: str + + +class LightRequest(BaseModel): + on: bool + + +class LightResponse(BaseModel): + printer_id: str + on: bool + requested_at: datetime + note: str = "command delivered; the printer's light_state in /status confirms it" + + +class StopPrintResponse(BaseModel): + printer_id: str + stop_requested_at: datetime + submission_id: str | None = None + note: str + + +def _metric(status: object, name: str) -> float | None: + metric = (getattr(status, "metrics", {}) or {}).get(name) + if metric is None: + return None + value = metric.value + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + return float(value) + + +class ControlGate: + """Device-level preconditions shared by ``allowed_actions`` and the routes.""" + + def __init__(self, settings: DispatchSettings) -> None: + self.settings = settings + + def start_print_blockers( + self, monitor: PrinterMonitor, *, busy_with_dispatch: bool + ) -> list[dict[str, object]]: + status = monitor.status() + if status.activity == "unknown": + return [ + { + "detail": "printer state is not determinable (telemetry missing or stale)", + "activity": "unknown", + "retry_after_s": 10, + } + ] + blockers: list[dict[str, object]] = [] + if status.activity != "idle": + blockers.append( + {"detail": "a print job is already in flight", "activity": status.activity} + ) + if status.equipment_status == "error": + blockers.append( + { + "detail": "the printer reports a failed job; clear it on the printer first", + "equipment_status": "error", + "last_error_code": status.last_error.code if status.last_error else None, + } + ) + bed = _metric(status, "bed_temperature") + nozzle = _metric(status, "nozzle_temperature") + if bed is None or nozzle is None: + blockers.append( + { + "detail": "bed or nozzle temperature is not reported", + "bed_c": bed, + "nozzle_c": nozzle, + } + ) + elif bed > self.settings.safe_bed_c or nozzle > self.settings.safe_nozzle_c: + blockers.append( + { + "detail": "the printer is not cold enough to start a new job", + "bed_c": bed, + "nozzle_c": nozzle, + "limit_bed_c": self.settings.safe_bed_c, + "limit_nozzle_c": self.settings.safe_nozzle_c, + "retry_after_s": 60, + } + ) + if monitor.definition.profile.plate is None: + blockers.append( + {"detail": "no build plate is declared for this printer", "machine_plate": None} + ) + if busy_with_dispatch: + blockers.append( + { + "detail": "the gateway is already tracking a job on this printer", + "gateway_job_in_flight": True, + } + ) + return blockers + + def stop_print_blockers(self, monitor: PrinterMonitor) -> list[dict[str, object]]: + status = monitor.status() + if status.activity != "running": + return [{"detail": "no print job is in flight", "activity": status.activity}] + return [] + + def light_blockers(self, monitor: PrinterMonitor) -> list[dict[str, object]]: + """The light is on the design's safe whitelist: any observable state.""" + + if monitor.status().activity == "unknown": + return [ + { + "detail": "printer state is not determinable (telemetry missing or stale)", + "activity": "unknown", + "retry_after_s": 10, + } + ] + return [] + + def allowed_actions(self, monitor: PrinterMonitor, *, busy_with_dispatch: bool) -> list[str]: + if not self.settings.enabled: + return [] + actions = ["snapshot"] + if not self.light_blockers(monitor): + actions.append("light") + if not self.start_print_blockers(monitor, busy_with_dispatch=busy_with_dispatch): + actions.append("start_print") + if not self.stop_print_blockers(monitor): + actions.append("stop_print") + return actions + + +@dataclass +class _Snapshot: + token: str + printer_id: str + session_id: str | None + captured_at: datetime + sha256: str + path: Path + + +@dataclass +class _PrinterControl: + claims: ClaimRegistry = field(default_factory=ClaimRegistry) + lock: asyncio.Lock = field(default_factory=asyncio.Lock) + active_job: str | None = None + watcher: asyncio.Task[None] | None = None + + +class Dispatcher: + """Per-printer control state, and the dispatch step itself.""" + + def __init__( + self, + *, + settings: DispatchSettings, + store: SubmissionStore, + monitors: dict[str, PrinterMonitor], + backends: dict[str, PrinterBackend], + poll_interval_s: float, + ) -> None: + self.settings = settings + self.gate = ControlGate(settings) + self._store = store + self._monitors = monitors + self._backends = backends + self._poll_s = max(0.01, poll_interval_s) + self._printers = {printer_id: _PrinterControl() for printer_id in monitors} + self._snapshots: dict[str, _Snapshot] = {} + + # -- lifecycle --------------------------------------------------------- + + async def start(self) -> None: + """Re-attach to jobs a previous process left ``running``.""" + + for job in self._store.list(state="running"): + control = self._printers.get(job.target_machine) + if control is None: + continue + control.active_job = job.submission_id + control.watcher = asyncio.create_task(self._watch_running(job.submission_id)) + + async def stop(self) -> None: + for control in self._printers.values(): + if control.watcher is not None: + control.watcher.cancel() + with contextlib.suppress(asyncio.CancelledError): + await control.watcher + control.watcher = None + + # -- reads ------------------------------------------------------------- + + def claims(self, printer_id: str) -> ClaimRegistry: + return self._control(printer_id).claims + + def busy(self, printer_id: str) -> bool: + control = self._printers.get(printer_id) + return bool(control and control.active_job) + + def allowed_actions(self, printer_id: str) -> list[str]: + monitor = self._monitors.get(printer_id) + if monitor is None: + return [] + return self.gate.allowed_actions(monitor, busy_with_dispatch=self.busy(printer_id)) + + def require_claim(self, printer_id: str, token: str | None) -> None: + control = self._control(printer_id) + if not control.claims.verify(token): + holder = control.claims.holder() + raise ControlRefusal( + 423, + { + "detail": "a live claim token is required on control routes", + "claimed_by": holder.model_dump(mode="json") if holder else None, + }, + ) + + def plate_snapshot_path(self, job: SubmissionJob) -> Path | None: + path = self._store.root / f"{job.submission_id}.plate.jpg" + return path if path.is_file() else None + + def _control(self, printer_id: str) -> _PrinterControl: + control = self._printers.get(printer_id) + if control is None: + raise ControlRefusal(404, {"detail": "printer not configured"}) + return control + + # -- snapshot ---------------------------------------------------------- + + async def snapshot(self, printer_id: str, *, claim_token: str | None) -> SnapshotResponse: + self.require_claim(printer_id, claim_token) + snap, image = await self._capture(printer_id) + return SnapshotResponse( + token=snap.token, + printer_id=printer_id, + captured_at=snap.captured_at, + sha256=snap.sha256, + image_base64=base64.b64encode(image).decode("ascii"), + ) + + async def _capture(self, printer_id: str) -> tuple[_Snapshot, bytes]: + """Read one frame under the claim the caller already checked, and keep it.""" + + control = self._control(printer_id) + try: + image = await asyncio.to_thread(self._backends[printer_id].snapshot) + except PrinterCommandError as exc: + raise ControlRefusal(502, {"detail": f"camera snapshot failed: {exc}"}) from exc + now = datetime.now(UTC) + token = secrets.token_urlsafe(24) + digest = hashlib.sha256(image).hexdigest() + directory = self._store.root / "snapshots" + directory.mkdir(parents=True, exist_ok=True) + path = directory / f"{token}.jpg" + await asyncio.to_thread(path.write_bytes, image) + holder = control.claims.holder() + snap = _Snapshot( + token=token, + printer_id=printer_id, + session_id=holder.session_id if holder else None, + captured_at=now, + sha256=digest, + path=path, + ) + self._snapshots[token] = snap + self._prune_snapshots(now) + return snap, image + + def _prune_snapshots(self, now: datetime) -> None: + cutoff = now - timedelta(seconds=_SNAPSHOT_RETENTION_S) + for token, snap in list(self._snapshots.items()): + if snap.captured_at < cutoff: + snap.path.unlink(missing_ok=True) + self._snapshots.pop(token, None) + + # -- start_print ------------------------------------------------------- + + async def start_print( + self, + printer_id: str, + request: StartPrintRequest, + *, + actor: Actor, + claim_token: str | None, + ) -> SubmissionJob: + control = self._control(printer_id) + self.require_claim(printer_id, claim_token) + monitor = self._monitors[printer_id] + backend = self._backends[printer_id] + + async with control.lock: + job = self._store.get(request.submission_id) + if job is None: + raise ControlRefusal(404, {"detail": "unknown submission"}) + if job.target_machine != printer_id: + raise ControlRefusal( + 409, + { + "detail": "the submission targets a different printer", + "target_machine": job.target_machine, + }, + ) + if job.state != "approved" or job.verdict is None or not job.verdict.dispatch_ready: + raise ControlRefusal( + 409, + { + "detail": "only an approved, dispatch-ready submission can be started", + "state": job.state, + }, + ) + if job.dispatch is not None: + raise ControlRefusal( + 409, {"detail": "dispatch has already been attempted for this submission"} + ) + + who, verified = self._identity(actor, request.dispatched_by) + if self.settings.require_verified_identity and not ( + verified and job.approved_by_verified + ): + raise ControlRefusal( + 403, + { + "detail": "starting a print requires edge-verified identities for both " + "the approver and the person starting it", + "approver_verified": job.approved_by_verified, + "caller_verified": verified, + }, + ) + + blockers = self.gate.start_print_blockers( + monitor, busy_with_dispatch=self.busy(printer_id) + ) + if blockers: + raise ControlRefusal(412, {**blockers[0], "blockers": blockers}) + + # Re-read the artifact itself rather than trusting the facts stored + # at intake: those may come from an older inspector that did not + # record plate or slot details, and the file is what will print. + try: + facts = await asyncio.to_thread( + inspect_artifact, + self._store.artifact_path(job), + scan_max_bytes=self._store.settings.scan_max_bytes, + ) + except (ArtifactError, OSError) as exc: + raise ControlRefusal( + 412, + {"detail": f"the stored artifact could not be re-read: {type(exc).__name__}"}, + ) from exc + plate = monitor.definition.profile.plate + missing = [] + if facts is None or not facts.sliced: + missing.append("sliced plate gcode") + if facts is None or facts.plate_index is None: + missing.append("plate index") + if facts is None or facts.bed_type is None: + missing.append("recognised build plate type") + if missing or facts is None or plate is None: + raise ControlRefusal( + 412, + { + "detail": "the artifact lacks what a start command needs: " + + ", ".join(missing), + "missing": missing, + }, + ) + if facts.bed_type != plate: + raise ControlRefusal( + 412, + { + "detail": f"the model was sliced for " + f"{facts.bed_type_label or facts.bed_type} but this printer has " + f"{plate_label(plate)}", + "artifact_bed_type": facts.bed_type, + "machine_plate": plate, + }, + ) + + profile = monitor.profile() + verdict = validate_model( + submission_id=job.submission_id, + facts=facts, + profile=profile, + requested_material=job.material, + ) + if verdict.verdict != "pass": + raise ControlRefusal( + 412, + { + "detail": "revalidation against the printer's current state failed", + "reasons": verdict.reasons, + }, + ) + mapping = check_tray_mapping(request.ams_mapping, facts, profile) + plate_check, snapshot_token = await self._plate_check( + request, control=control, printer_id=printer_id, who=who, verified=verified + ) + + remote_name = remote_filename(job) + record = DispatchRecord( + dispatched_by=who, + dispatched_by_verified=verified, + requested_at=datetime.now(UTC), + plate_check=plate_check, + ams_mapping=mapping, + bed_type=plate, + plate_index=facts.plate_index, # type: ignore[arg-type] + remote_filename=remote_name, + ) + try: + job = await self._store.begin_dispatch(job, record) + except InvalidTransition as exc: + raise ControlRefusal(409, {"detail": str(exc)}) from exc + control.active_job = job.submission_id + if snapshot_token: + await self._keep_snapshot(snapshot_token, job) + + path = self._store.artifact_path(job) + try: + upload = await asyncio.to_thread(backend.upload_file, path, remote_name) + except PrinterCommandError as exc: + control.active_job = None + return await self._store.update_dispatch( + job, to_state="failed", note=f"upload failed: {exc}; nothing was started" + ) + job = await self._store.update_dispatch(job, upload_verified_bytes=upload.byte_size) + + command = StartPrintCommand( + remote_name=remote_name, + plate_index=record.plate_index, + bed_type=plate, + # Off for the external spool, as Bambu Studio sends it; the + # mapping then names the spool by its own id (254). + use_ams=not any(a.external for a in mapping), + ams_mapping=tuple(a.printer_index for a in mapping), + bed_leveling=self.settings.bed_leveling, + flow_calibration=self.settings.flow_calibration, + vibration_calibration=self.settings.vibration_calibration, + timelapse=self.settings.timelapse, + ) + try: + await asyncio.to_thread(backend.start_print, command) + except PrinterCommandError as exc: + control.active_job = None + note = ( + f"start command not confirmed by the broker: {exc}; the file is on the " + "printer but no start was confirmed -- check the printer" + ) + return await self._store.update_dispatch( + job, to_state="failed", note=note, uncertainty=note + ) + job = await self._store.update_dispatch(job, command_published_at=datetime.now(UTC)) + + return await self._await_running(job, monitor, backend, control) + + async def _await_running( + self, + job: SubmissionJob, + monitor: PrinterMonitor, + backend: PrinterBackend, + control: _PrinterControl, + ) -> SubmissionJob: + loop = asyncio.get_running_loop() + deadline = loop.time() + self.settings.confirm_timeout_s + stem = Path(job.dispatch.remote_filename).stem if job.dispatch else "" + while loop.time() < deadline: + await asyncio.sleep(self._poll_s) + if monitor.status().activity != "running": + continue + name = await asyncio.to_thread(backend.current_job_file) + note = "printer reports the job running" + if not (stem and name and stem in name): + note += ( + " (the printer's job name did not confirm which file; " + "matched on state change)" + ) + job = await self._store.update_dispatch( + job, to_state="running", note=note, observed_running_at=datetime.now(UTC) + ) + control.watcher = asyncio.create_task(self._watch_running(job.submission_id)) + return job + control.active_job = None + note = ( + "the start command was delivered but the printer did not report the job running " + f"within {int(self.settings.confirm_timeout_s)} s; its real state is unknown -- " + "look at the printer before doing anything else" + ) + return await self._store.update_dispatch( + job, to_state="failed", note=note, uncertainty=note + ) + + async def _watch_running(self, submission_id: str) -> None: + job = self._store.get(submission_id) + if job is None: + return + monitor = self._monitors[job.target_machine] + control = self._printers[job.target_machine] + try: + while True: + await asyncio.sleep(self._poll_s) + status = monitor.status() + if status.activity in ("running", "unknown"): + continue + print_job = status.components.get("print_job") + gcode_state = (print_job.state if print_job and print_job.state else "").upper() + now = datetime.now(UTC) + if gcode_state == "FINISH": + await self._store.update_dispatch( + job, + to_state="finished", + note="printer reports the job finished", + observed_finished_at=now, + ) + else: + note = "printer left the running state without finishing" + if gcode_state: + note += f" (state {gcode_state.lower()})" + if status.last_error: + note += f"; printer error {status.last_error.code}" + await self._store.update_dispatch( + job, to_state="failed", note=note, observed_finished_at=now + ) + return + except InvalidTransition: + logger.exception("Could not record the outcome of %s", submission_id) + finally: + if control.active_job == submission_id: + control.active_job = None + if control.watcher is asyncio.current_task(): + control.watcher = None + + # -- stop_print -------------------------------------------------------- + + async def stop_print( + self, + printer_id: str, + request: StopPrintRequest, + *, + actor: Actor, + claim_token: str | None, + ) -> StopPrintResponse: + control = self._control(printer_id) + self.require_claim(printer_id, claim_token) + who, verified = self._identity(actor, request.stopped_by) + if self.settings.require_verified_identity and not verified: + raise ControlRefusal( + 403, {"detail": "stopping a print requires an edge-verified identity"} + ) + blockers = self.gate.stop_print_blockers(self._monitors[printer_id]) + if blockers: + raise ControlRefusal(412, {**blockers[0], "blockers": blockers}) + try: + await asyncio.to_thread(self._backends[printer_id].stop_print) + except PrinterCommandError as exc: + raise ControlRefusal(502, {"detail": f"stop command not delivered: {exc}"}) from exc + note = f"stop requested by {who}{' (verified identity)' if verified else ''}" + if request.reason: + note += f": {request.reason.strip()}" + logger.info("%s on %s", note, printer_id) + return StopPrintResponse( + printer_id=printer_id, + stop_requested_at=datetime.now(UTC), + submission_id=control.active_job, + note=note, + ) + + # -- light ------------------------------------------------------------- + + async def light( + self, printer_id: str, request: LightRequest, *, claim_token: str | None + ) -> LightResponse: + self.require_claim(printer_id, claim_token) + blockers = self.gate.light_blockers(self._monitors[printer_id]) + if blockers: + raise ControlRefusal(412, {**blockers[0], "blockers": blockers}) + try: + await asyncio.to_thread(self._backends[printer_id].set_chamber_light, request.on) + except PrinterCommandError as exc: + raise ControlRefusal(502, {"detail": f"light command not delivered: {exc}"}) from exc + return LightResponse(printer_id=printer_id, on=request.on, requested_at=datetime.now(UTC)) + + # -- helpers ----------------------------------------------------------- + + def _identity(self, actor: Actor, supplied: str | None) -> tuple[str, bool]: + if actor.verified and actor.user: + return actor.user, True + name = (supplied or "").strip()[:120] + if not name and not self.settings.require_verified_identity: + raise ControlRefusal(422, {"detail": "a name is required"}) + return name or "unverified caller", False + + async def _plate_check( + self, + request: StartPrintRequest, + *, + control: _PrinterControl, + printer_id: str, + who: str, + verified: bool, + ) -> tuple[PlateCheck, str | None]: + """The person's plate confirmation, and the token of the frame recorded with it. + + A camera confirmation with no snapshot token (approved 2026-10-21) has + the gateway capture the frame itself, now, under this claim: the person + looked at the live camera view, and the record keeps the frame from the + moment they pressed start. If the camera cannot deliver one, nothing + starts. + """ + + now = datetime.now(UTC) + if request.plate_check_method == "in_person": + plate_check = PlateCheck( + confirmed_by=who, + confirmed_by_verified=verified, + method="in_person", + confirmed_at=now, + ) + return plate_check, None + if not request.snapshot_token: + try: + snap, _image = await self._capture(printer_id) + except ControlRefusal as exc: + raise ControlRefusal( + 502, + { + "detail": f"{exc.body.get('detail')}; nothing was started -- " + "check the plate in person instead", + "camera_unavailable": True, + }, + ) from exc + plate_check = PlateCheck( + confirmed_by=who, + confirmed_by_verified=verified, + method="printer_camera", + confirmed_at=now, + snapshot_sha256=snap.sha256, + snapshot_captured_at=snap.captured_at, + ) + return plate_check, snap.token + snap = self._snapshots.get(request.snapshot_token) + holder = control.claims.holder() + if ( + snap is None + or snap.printer_id != printer_id + or (holder is not None and snap.session_id != holder.session_id) + ): + raise ControlRefusal( + 412, + { + "detail": "camera confirmation needs a snapshot taken under this claim", + "snapshot_missing": True, + }, + ) + age = (now - snap.captured_at).total_seconds() + if age > SNAPSHOT_MAX_AGE_S: + raise ControlRefusal( + 412, + { + "detail": "the camera snapshot is too old; take a new one", + "snapshot_age_s": round(age, 1), + "snapshot_max_age_s": SNAPSHOT_MAX_AGE_S, + }, + ) + plate_check = PlateCheck( + confirmed_by=who, + confirmed_by_verified=verified, + method="printer_camera", + confirmed_at=now, + snapshot_sha256=snap.sha256, + snapshot_captured_at=snap.captured_at, + ) + return plate_check, snap.token + + async def _keep_snapshot(self, token: str, job: SubmissionJob) -> None: + snap = self._snapshots.pop(token, None) + if snap is not None: + target = self._store.root / f"{job.submission_id}.plate.jpg" + await asyncio.to_thread(snap.path.replace, target) + + +def remote_filename(job: SubmissionJob) -> str: + """A printer-side filename: ASCII, short, unique per submission.""" + + stem = _REMOTE_NAME_RE.sub("_", Path(job.original_filename).stem).strip("._-")[:40] + return f"gw_{job.submission_id[:12]}_{stem or 'job'}.3mf" + + +def check_tray_mapping( + assignments: list[TrayAssignment], facts: ArtifactFacts, profile: MachineProfile +) -> list[TrayAssignment]: + """Refuse unless every filament the plate uses maps to a tray that holds it.""" + + uses = {use.filament_id: use.filament_type for use in facts.filaments} + if not uses: + if len(facts.filament_types) != 1: + raise ControlRefusal( + 412, + { + "detail": "the artifact's filament slots could not be determined", + "filament_types": list(facts.filament_types), + }, + ) + uses = {1: facts.filament_types[0]} + trays = {(t.ams_id, t.tray_id): t for t in profile.observed.loaded_trays} + if not trays: + raise ControlRefusal( + 412, + { + "detail": "no loaded AMS trays or external spool are reported, so the " + "mapping cannot be checked" + }, + ) + problems: list[str] = [] + seen: set[int] = set() + for assignment in assignments: + if assignment.filament_id in seen: + problems.append(f"filament {assignment.filament_id} is mapped twice") + seen.add(assignment.filament_id) + needed = uses.get(assignment.filament_id) + if needed is None: + problems.append(f"filament {assignment.filament_id} is not used by this plate") + continue + if assignment.external: + where = "the external spool" + if assignment.tray_id != 0: + problems.append("the external spool is addressed as tray 0") + continue + else: + where = f"AMS {assignment.ams_id} tray {assignment.tray_id}" + if assignment.ams_id >= _STANDARD_AMS_UNITS and not assignment.external: + # AMS HT units report ids from 128. The print command's flat tray + # index (ams_id * 4 + tray) is only known to be right for standard + # units 0-3; sending a guessed index would feed the wrong spool. + problems.append( + f"{where} is an AMS HT unit, which dispatch does not map yet; " + "choose a tray in a standard AMS unit" + ) + continue + tray = trays.get((assignment.ams_id, assignment.tray_id)) + if tray is None: + problems.append(f"{where} reports no loaded filament") + continue + loaded = (tray.tray_type or "").strip().upper() + if loaded != needed.strip().upper(): + problems.append( + f"filament {assignment.filament_id} needs {needed} but {where} holds " + f"{loaded or 'unknown'}" + ) + temp = facts.nozzle_temperature_c + if ( + temp is not None + and tray.nozzle_temp_min_c is not None + and tray.nozzle_temp_max_c is not None + and not tray.nozzle_temp_min_c <= temp <= tray.nozzle_temp_max_c + ): + problems.append( + f"the model's {temp:g} C nozzle is outside the {tray.nozzle_temp_min_c}-" + f"{tray.nozzle_temp_max_c} C window of the spool in {where}" + ) + unmapped = sorted(set(uses) - seen) + if unmapped: + problems.append(f"filament(s) {unmapped} have no tray assigned") + if any(a.external for a in assignments) and len(assignments) > 1: + # One spool, and the start command's use_ams switch is all or nothing: + # a job fed from the external spool can use no other tray. + problems.append("the external spool can feed a single-filament job only") + if problems: + raise ControlRefusal( + 412, + {"detail": "the tray mapping does not match the loaded filament", "problems": problems}, + ) + return sorted(assignments, key=lambda a: a.filament_id) diff --git a/src/bambu_server/main.py b/src/bambu_server/main.py index e38cc93..67be0f5 100644 --- a/src/bambu_server/main.py +++ b/src/bambu_server/main.py @@ -1,21 +1,23 @@ -"""FastAPI application for the monitoring-only Bambu printer gateway. +"""FastAPI application for the Bambu printer gateway. -Two surfaces live here and they are deliberately different in kind: +Three surfaces live here and they are deliberately different in kind: * the **status surface** (``/printers/*``) is a pure read of the background monitor's cache -- no request on it ever reaches a printer; * the **submission surface** (``/submissions``, ``/printers/{id}/queue``) accepts - print jobs, validates them against the target machine's profile, and queues - them. It is read-and-analysis too: intake writes to the gateway's own disk, - and the one step that would touch a printer -- dispatch -- is not implemented - here (see :func:`bambu_server.submissions.dispatch`). - -There are no ``/control/*`` routes, and the service still issues no printer -commands. + print jobs (a sliced ``.3mf``/``.gcode``, or an ``.stl`` the gateway slices), + validates them against the target machine's profile, and queues them. It + writes to the gateway's own disk and never reaches a printer; +* the **control surface** (``/printers/{id}/control/*``) exists only when the + local config enables dispatch. Every route on it needs a live claim; it is + the only place a request can cause printer I/O, and it does so only through + :mod:`bambu_server.dispatch`. """ import asyncio +import hashlib import logging +import uuid from collections.abc import AsyncIterator, Callable from contextlib import asynccontextmanager from datetime import UTC, datetime @@ -28,20 +30,25 @@ FastAPI, File, Form, + Header, HTTPException, Path, Query, Request, + Response, UploadFile, ) from fastapi.middleware.cors import CORSMiddleware from fastapi.openapi.docs import get_swagger_ui_html -from fastapi.responses import HTMLResponse +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from pydantic import BaseModel, Field +from sdl_lab_contract.claims import ClaimRejection, ClaimRequest, ClaimResponse from . import __version__ from .artifacts import ARTIFACT_EXTENSIONS from .backend import BambuLabsBackend, PrinterBackend +from .camera import CameraUnavailable, CameraViewer +from .claims import ClaimConflict, ClaimLost from .config import ( PrinterCredentials, PrinterDefinition, @@ -50,6 +57,16 @@ resolve_credentials, resolve_edge_secret, ) +from .dispatch import ( + ControlRefusal, + Dispatcher, + LightRequest, + LightResponse, + SnapshotResponse, + StartPrintRequest, + StopPrintRequest, + StopPrintResponse, +) from .identity import Actor, actor_for, resolve_actor from .models import ( PROTOCOL_VERSION, @@ -63,16 +80,22 @@ from .monitor import PrinterMonitor from .profiles import MachineProfile from .queueing import QueueView, build_queue_view +from .slicer import BambuStudioSlicer, SliceRequest, SlicerError, SlicerUnavailable from .submissions import ( ArtifactTooLarge, InvalidTransition, JobState, + SliceProvenance, SubmissionError, SubmissionJob, SubmissionStore, run_validation, + safe_display_name, ) +#: Header carrying the claim token on control routes (STATUS_SPEC §5). +CLAIM_HEADER = "X-Claim-Token" + logger = logging.getLogger(__name__) BackendFactory = Callable[[PrinterDefinition, PrinterCredentials], PrinterBackend] @@ -146,6 +169,12 @@ def create_app( # credential, and re-reading the environment per call would let a later # change silently alter who the service trusts mid-run. secrets: dict[str, str | None] = {"edge": None} + # Control-plane objects, present only while the app is running. A one-slot + # dict for the same reason as `stores`. + dispatchers: dict[str, Dispatcher] = {} + slicers: dict[str, BambuStudioSlicer] = {} + cameras: dict[str, CameraViewer] = {} + control_enabled = bool(settings and settings.dispatch.enabled) @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: @@ -156,24 +185,42 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: await asyncio.to_thread(store.load) stores["default"] = store app.state.submissions = store + backends: dict[str, PrinterBackend] = {} for definition in active_settings.printers: credentials = resolve_credentials(definition) + backend = backend_factory(definition, credentials) + backends[definition.id] = backend monitor = PrinterMonitor( definition, - backend_factory(definition, credentials), + backend, poll_interval_seconds=active_settings.poll_interval_seconds, stale_after_seconds=active_settings.stale_after_seconds, ) monitors[definition.id] = monitor + dispatcher = Dispatcher( + settings=active_settings.dispatch, + store=store, + monitors=monitors, + backends=backends, + poll_interval_s=active_settings.poll_interval_seconds, + ) + dispatchers["default"] = dispatcher + cameras["default"] = CameraViewer(backends) + slicers["default"] = BambuStudioSlicer(active_settings.slicer) try: for monitor in monitors.values(): await monitor.start() + if active_settings.dispatch.enabled: + await dispatcher.start() yield finally: + await dispatcher.stop() for monitor in reversed(list(monitors.values())): await monitor.stop() monitors.clear() stores.clear() + dispatchers.clear() + slicers.clear() app = FastAPI( title="AC Bambu Printer Gateway", @@ -228,11 +275,24 @@ def get_store() -> SubmissionStore: raise HTTPException(status_code=503, detail="submission store unavailable") return store + def get_dispatcher() -> Dispatcher: + dispatcher = dispatchers.get("default") + if dispatcher is None: # pragma: no cover - only outside the app lifespan + raise HTTPException(status_code=503, detail="control plane unavailable") + return dispatcher + + def get_slicer() -> BambuStudioSlicer: + slicer = slicers.get("default") + if slicer is None: # pragma: no cover - only outside the app lifespan + raise HTTPException(status_code=503, detail="slicer unavailable") + return slicer + @app.get("/", response_model=GatewayInfo, tags=["gateway"]) async def gateway_info() -> GatewayInfo: return GatewayInfo( service="ac-bambu-server", version=__version__, + mode="control" if control_enabled else "monitoring_only", printer_count=len(monitors), ) @@ -292,7 +352,7 @@ async def gateway_status() -> EquipmentStatus: device_time=datetime.now(UTC), components=components, allowed_actions=[], - details={"monitoring_only": True, "printer_count": len(monitors)}, + details={"monitoring_only": not control_enabled, "printer_count": len(monitors)}, ) @app.get("/whoami", response_model=Whoami, tags=["gateway"]) @@ -354,8 +414,26 @@ async def printer_status( reported color, tray_color_name, tray_color_source, and remaining_percent. details.ams_unit_ids includes empty units. Use the profile endpoint for typed inventory schemas and freshness-filtered observations. + + With dispatch enabled, ``allowed_actions`` is computed by the same gate + the control routes refuse with (STATUS_SPEC §6.2) and + ``details.claimed_by`` names the current claim holder. """ - return monitor.status() + status = monitor.status() + if not control_enabled: + return status + dispatcher = get_dispatcher() + printer_id = monitor.definition.id + holder = dispatcher.claims(printer_id).holder() + details = { + **status.details, + "monitoring_only": False, + "claimed_by": holder.model_dump(mode="json") if holder else None, + "build_plate": monitor.definition.profile.plate, + } + return status.model_copy( + update={"allowed_actions": dispatcher.allowed_actions(printer_id), "details": details} + ) @app.get( "/printers/{printer_id}/profile", @@ -401,7 +479,13 @@ async def printer_queue( ) async def create_submission( store: Annotated[SubmissionStore, Depends(get_store)], - file: Annotated[UploadFile, File(description="A .3mf or .gcode artifact")], + file: Annotated[ + UploadFile, + File( + description="A sliced .3mf or .gcode, or an .stl the gateway slices " + "(when slicing is enabled for the target; requires material)" + ), + ], actor: Annotated[Actor, Depends(get_actor)], target_machine: Annotated[str, Form(max_length=120)], requested_by: Annotated[str | None, Form(max_length=120)] = None, @@ -426,6 +510,16 @@ async def create_submission( raise HTTPException(status_code=404, detail="unknown target machine") extension = PurePosixPath(file.filename or "").suffix.lower() + if extension == ".stl": + return await _slice_and_accept( + store=store, + slicer=get_slicer(), + monitor=monitor, + file=file, + owner=owner, + owner_verified=owner_verified, + material=material, + ) kind = ARTIFACT_EXTENSIONS.get(extension) if kind is None: supported = ", ".join(sorted(ARTIFACT_EXTENSIONS)) @@ -467,6 +561,127 @@ async def chunks() -> AsyncIterator[bytes]: return await run_validation(store, job, monitor.profile()) + async def _slice_and_accept( + *, + store: SubmissionStore, + slicer: BambuStudioSlicer, + monitor: PrinterMonitor, + file: UploadFile, + owner: str, + owner_verified: bool, + material: str | None, + ) -> SubmissionJob: + """Slice an uploaded mesh for the target machine, then intake the 3mf. + + The mesh and the slicer's scratch files live in a per-request directory + under the slicer's work dir and are removed afterwards; what survives + is the sliced 3mf (as an ordinary submission) and its provenance. + """ + + printer_id = monitor.definition.id + if not slicer.accepts(printer_id): + raise HTTPException( + status_code=400, + detail=f"this gateway does not slice .stl files for {printer_id}; " + "upload a sliced .3mf or .gcode", + ) + if not material or not material.strip(): + raise HTTPException( + status_code=422, + detail="material is required to slice an .stl (one of: " + + ", ".join(slicer.materials()) + ")", + ) + settings_ = slicer.settings + job_dir = settings_.work_dir / uuid.uuid4().hex + job_dir.mkdir(parents=True, exist_ok=True) + try: + mesh = job_dir / "model.stl" + digest = hashlib.sha256() + size = 0 + with mesh.open("wb") as handle: + while chunk := await file.read(_UPLOAD_CHUNK_BYTES): + size += len(chunk) + if size > settings_.max_stl_bytes: + raise HTTPException( + status_code=413, + detail=f"mesh exceeds the {settings_.max_stl_bytes} byte limit", + ) + digest.update(chunk) + await asyncio.to_thread(handle.write, chunk) + if size == 0: + raise HTTPException(status_code=400, detail="the uploaded mesh is empty") + + request = SliceRequest( + printer_id=printer_id, + material=material, + nozzle_type=monitor.profile().nozzle_type, + plate=monitor.definition.profile.plate, + ) + try: + machine, process, filament = slicer.resolve_presets(request) + result = await slicer.slice(mesh, request, job_dir=job_dir) + except SlicerUnavailable as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except SlicerError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + source_name = safe_display_name(file.filename or "model.stl") + provenance = SliceProvenance( + source_filename=source_name, + source_sha256=digest.hexdigest(), + source_byte_size=size, + slicer_version=result.slicer_version, + machine_preset=machine.stem, + process_preset=process.stem, + filament_preset=filament.stem, + warnings=list(result.warnings), + ) + + async def sliced_chunks() -> AsyncIterator[bytes]: + with result.artifact.open("rb") as artifact: + while chunk := await asyncio.to_thread(artifact.read, _UPLOAD_CHUNK_BYTES): + yield chunk + + try: + job = await store.accept( + chunks=sliced_chunks(), + extension=".3mf", + target_machine=printer_id, + requested_by=owner, + requested_by_verified=owner_verified, + material=material, + original_filename=f"{PurePosixPath(source_name).stem}.3mf", + provenance=provenance, + ) + except ArtifactTooLarge as exc: + raise HTTPException(status_code=413, detail=str(exc)) from exc + except SubmissionError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + finally: + await asyncio.to_thread(slicer.discard, job_dir) + return await run_validation(store, job, monitor.profile()) + + @app.get( + "/submissions/{submission_id}/plate.jpg", + response_class=FileResponse, + tags=["submissions"], + ) + async def submission_plate_snapshot( + store: Annotated[SubmissionStore, Depends(get_store)], + submission_id: Annotated[str, Path(pattern=r"^[0-9a-f]{32}$")], + ) -> FileResponse: + """The camera frame a person confirmed the empty plate against. + + Evidence for the dispatch record, read from the gateway's own disk. + 404 when the plate was confirmed in person or no dispatch happened. + """ + + job = store.get(submission_id) + path = store.root / f"{submission_id}.plate.jpg" + if job is None or not path.is_file(): + raise HTTPException(status_code=404, detail="no plate snapshot for this submission") + return FileResponse(path, media_type="image/jpeg") + @app.get("/submissions", response_model=list[SubmissionJob], tags=["submissions"]) async def list_submissions( store: Annotated[SubmissionStore, Depends(get_store)], @@ -589,9 +804,239 @@ async def delete_submission( " (verified)" if actor.verified else "", ) + def get_camera() -> CameraViewer: + camera = cameras.get("default") + if camera is None: # pragma: no cover - only outside the app lifespan + raise HTTPException(status_code=503, detail="camera unavailable") + return camera + + if control_enabled: + _register_control_routes(app, get_monitor, get_actor, get_dispatcher) + _register_camera_route(app, get_monitor, get_camera) + return app +def _register_camera_route( + app: FastAPI, + get_monitor: Callable[..., PrinterMonitor], + get_camera: Callable[[], CameraViewer], +) -> None: + """The view-only camera (approved 2026-10-21). Control deployments only.""" + + @app.get( + "/printers/{printer_id}/camera.jpg", + response_class=Response, + responses={ + 200: {"content": {"image/jpeg": {}}, "description": "The latest shared frame."}, + 503: {"description": "The camera did not deliver a frame."}, + }, + tags=["camera"], + ) + async def camera_frame( + monitor: Annotated[PrinterMonitor, Depends(get_monitor)], + camera: Annotated[CameraViewer, Depends(get_camera)], + ) -> Response: + """The printer's chamber camera, for watching -- not for the plate check. + + No claim, no command: one camera read per printer every 2 s at most, + shared by every viewer. A frame from here is never evidence that the + plate is empty; a camera ``start_print`` captures its own under the claim. + """ + + try: + frame = await camera.frame(monitor.definition.id) + except CameraUnavailable as exc: + return JSONResponse(status_code=503, content={"detail": str(exc)}) + return Response( + content=frame.image, + media_type="image/jpeg", + headers={ + "Cache-Control": "no-store", + "X-Captured-At": frame.captured_at.isoformat(), + }, + ) + + +def _refusal(exc: ControlRefusal) -> JSONResponse: + headers = {} + retry = exc.body.get("retry_after_s") + if isinstance(retry, (int, float)): + headers["Retry-After"] = str(int(retry)) + return JSONResponse(status_code=exc.status, content=exc.body, headers=headers) + + +def _register_control_routes( + app: FastAPI, + get_monitor: Callable[..., PrinterMonitor], + get_actor: Callable[..., Actor], + get_dispatcher: Callable[[], Dispatcher], +) -> None: + """STATUS_SPEC §5 claims plus the three control verbs, per printer. + + Only called when the local config enables dispatch, so a deployment that + has not opted in exposes no ``/control/`` path at all. + """ + + control_responses = { + 412: {"description": "A precondition is not met; the body names which."}, + 423: {"description": "No live claim token was presented."}, + } + + @app.post( + "/printers/{printer_id}/control/claim", + response_model=ClaimResponse, + responses={409: {"model": ClaimRejection}}, + tags=["control"], + ) + async def control_claim( + monitor: Annotated[PrinterMonitor, Depends(get_monitor)], + actor: Annotated[Actor, Depends(get_actor)], + dispatcher: Annotated[Dispatcher, Depends(get_dispatcher)], + request: ClaimRequest, + ) -> ClaimResponse | JSONResponse: + """Acquire this printer's claim. A verified identity replaces ``owner``.""" + + if actor.verified and actor.user: + request = request.model_copy(update={"owner": actor.user}) + try: + return dispatcher.claims(monitor.definition.id).claim(request) + except ClaimConflict as exc: + retry = max(0.0, (exc.holder.expires_at - datetime.now(UTC)).total_seconds()) + body = ClaimRejection( + detail="this printer is claimed by another session", + claimed_by=exc.holder, + retry_after_s=round(retry, 1), + ) + return JSONResponse( + status_code=409, + content=body.model_dump(mode="json"), + headers={"Retry-After": str(int(retry) + 1)}, + ) + + @app.post( + "/printers/{printer_id}/control/heartbeat", + status_code=200, + tags=["control"], + ) + async def control_heartbeat( + monitor: Annotated[PrinterMonitor, Depends(get_monitor)], + dispatcher: Annotated[Dispatcher, Depends(get_dispatcher)], + claim_token: Annotated[str | None, Header(alias=CLAIM_HEADER)] = None, + ) -> dict[str, datetime]: + try: + expires = dispatcher.claims(monitor.definition.id).heartbeat(claim_token) + except ClaimLost as exc: + raise HTTPException(status_code=401, detail="claim lost") from exc + return {"expires_at": expires} + + @app.post( + "/printers/{printer_id}/control/release", + status_code=204, + tags=["control"], + ) + async def control_release( + monitor: Annotated[PrinterMonitor, Depends(get_monitor)], + dispatcher: Annotated[Dispatcher, Depends(get_dispatcher)], + claim_token: Annotated[str | None, Header(alias=CLAIM_HEADER)] = None, + ) -> Response: + dispatcher.claims(monitor.definition.id).release(claim_token) + return Response(status_code=204) + + @app.post( + "/printers/{printer_id}/control/snapshot", + response_model=SnapshotResponse, + responses={423: control_responses[423], 502: {"description": "Camera unreachable."}}, + tags=["control"], + ) + async def control_snapshot( + monitor: Annotated[PrinterMonitor, Depends(get_monitor)], + dispatcher: Annotated[Dispatcher, Depends(get_dispatcher)], + claim_token: Annotated[str | None, Header(alias=CLAIM_HEADER)] = None, + ) -> SnapshotResponse | JSONResponse: + """One frame from the printer's chamber camera, for the plate check. + + A POST under the claim because it opens a connection to the printer; + nothing on a plain page view ever fetches a frame. + """ + + try: + return await dispatcher.snapshot(monitor.definition.id, claim_token=claim_token) + except ControlRefusal as exc: + return _refusal(exc) + + @app.post( + "/printers/{printer_id}/control/start_print", + response_model=SubmissionJob, + responses={409: {"description": "Job state conflict."}, **control_responses}, + tags=["control"], + ) + async def control_start_print( + monitor: Annotated[PrinterMonitor, Depends(get_monitor)], + actor: Annotated[Actor, Depends(get_actor)], + dispatcher: Annotated[Dispatcher, Depends(get_dispatcher)], + request: StartPrintRequest, + claim_token: Annotated[str | None, Header(alias=CLAIM_HEADER)] = None, + ) -> SubmissionJob | JSONResponse: + """Upload an approved job to the printer and start it. + + Requires the claim, a person's statement that the build plate is + empty, an explicit tray for every filament, and every gate in + :mod:`bambu_server.dispatch`. Returns once the printer reports the job + running, or with the job ``failed`` and the uncertainty recorded. + """ + + try: + return await dispatcher.start_print( + monitor.definition.id, request, actor=actor, claim_token=claim_token + ) + except ControlRefusal as exc: + return _refusal(exc) + + @app.post( + "/printers/{printer_id}/control/light", + response_model=LightResponse, + responses=control_responses, + tags=["control"], + ) + async def control_light( + monitor: Annotated[PrinterMonitor, Depends(get_monitor)], + dispatcher: Annotated[Dispatcher, Depends(get_dispatcher)], + request: LightRequest, + claim_token: Annotated[str | None, Header(alias=CLAIM_HEADER)] = None, + ) -> LightResponse | JSONResponse: + """Switch the chamber light, e.g. to see the plate in a snapshot. No motion.""" + + try: + return await dispatcher.light( + monitor.definition.id, request, claim_token=claim_token + ) + except ControlRefusal as exc: + return _refusal(exc) + + @app.post( + "/printers/{printer_id}/control/stop_print", + response_model=StopPrintResponse, + responses=control_responses, + tags=["control"], + ) + async def control_stop_print( + monitor: Annotated[PrinterMonitor, Depends(get_monitor)], + actor: Annotated[Actor, Depends(get_actor)], + dispatcher: Annotated[Dispatcher, Depends(get_dispatcher)], + request: StopPrintRequest, + claim_token: Annotated[str | None, Header(alias=CLAIM_HEADER)] = None, + ) -> StopPrintResponse | JSONResponse: + """Stop the print in flight. The job's final state comes from the printer.""" + + try: + return await dispatcher.stop_print( + monitor.definition.id, request, actor=actor, claim_token=claim_token + ) + except ControlRefusal as exc: + return _refusal(exc) + + def application_factory() -> FastAPI: """Load local configuration before constructing middleware and monitors.""" diff --git a/src/bambu_server/models.py b/src/bambu_server/models.py index c5e3ebb..15bf317 100644 --- a/src/bambu_server/models.py +++ b/src/bambu_server/models.py @@ -29,7 +29,9 @@ class GatewayInfo(BaseModel): service: str version: str - mode: Literal["monitoring_only"] = "monitoring_only" + #: ``control`` once the local config enables dispatch; until then the + #: gateway is exactly the monitoring-only release. + mode: Literal["monitoring_only", "control"] = "monitoring_only" printer_count: int diff --git a/src/bambu_server/monitor.py b/src/bambu_server/monitor.py index b040c7c..0aaf1e4 100644 --- a/src/bambu_server/monitor.py +++ b/src/bambu_server/monitor.py @@ -10,7 +10,7 @@ from time import monotonic from . import __version__ -from .backend import PrinterBackend, PrinterReading +from .backend import AmsTrayReading, PrinterBackend, PrinterReading from .config import PrinterDefinition from .filament_colors import tray_color_label from .models import ( @@ -235,24 +235,11 @@ def status(self) -> EquipmentStatus: if reading.ams_unit_ids is not None: details["ams_unit_ids"] = reading.ams_unit_ids if reading.ams_trays is not None: - details["ams_trays"] = [ - { - "ams_id": tray.ams_id, - "tray_id": tray.tray_id, - "tray_index": tray.tray_index, - "tray_type": tray.tray_type, - "tray_color": tray.tray_color, - "tray_color_name": tray_color_label(tray, self.definition.profile.ams.color_labels)[0], - "tray_color_source": tray_color_label(tray, self.definition.profile.ams.color_labels)[1], - "tray_weight": tray.tray_weight, - "tray_diameter": tray.tray_diameter, - "tray_temp": tray.tray_temp, - "nozzle_temp_min": tray.nozzle_temp_min, - "nozzle_temp_max": tray.nozzle_temp_max, - "remaining_percent": tray.remaining_percent, - } - for tray in reading.ams_trays - ] + details["ams_trays"] = [self._tray_details(tray) for tray in reading.ams_trays] + # Kept apart from ams_trays: it is not a slot of any AMS unit, and + # counting it among them would misstate how many slots are loaded. + if reading.external_spool is not None: + details["external_spool"] = self._tray_details(reading.external_spool) return EquipmentStatus( protocol_version=PROTOCOL_VERSION, @@ -304,6 +291,24 @@ def _map_state( ) return "unknown", "Printer state is unknown", None + def _tray_details(self, tray: AmsTrayReading) -> dict[str, object]: + name, source = tray_color_label(tray, self.definition.profile.ams.color_labels) + return { + "ams_id": tray.ams_id, + "tray_id": tray.tray_id, + "tray_index": tray.tray_index, + "tray_type": tray.tray_type, + "tray_color": tray.tray_color, + "tray_color_name": name, + "tray_color_source": source, + "tray_weight": tray.tray_weight, + "tray_diameter": tray.tray_diameter, + "tray_temp": tray.tray_temp, + "nozzle_temp_min": tray.nozzle_temp_min, + "nozzle_temp_max": tray.nozzle_temp_max, + "remaining_percent": tray.remaining_percent, + } + @staticmethod def _metrics(reading: PrinterReading) -> dict[str, MetricValue]: timestamp = reading.data_updated_at diff --git a/src/bambu_server/plates.py b/src/bambu_server/plates.py new file mode 100644 index 0000000..6f1e6e7 --- /dev/null +++ b/src/bambu_server/plates.py @@ -0,0 +1,43 @@ +"""Build-plate vocabulary: the slicer's names and the printer's. + +Bambu Studio records the plate a job was sliced for as ``curr_bed_type`` +("Textured PEI Plate"); the printer's start command wants the same fact as +``bed_type`` ("textured_plate"). The two never meet except here. A plate the +mapping does not know is reported as ``None`` -- never guessed -- and the +dispatch gate refuses an unknown plate rather than defaulting one. +""" + +from __future__ import annotations + +from .config import BedPlate + +#: Slicer ``curr_bed_type`` (lower-cased) to the printer's ``bed_type``. +_SLICER_TO_PRINTER: dict[str, BedPlate] = { + "cool plate": "cool_plate", + "cool plate (supertack)": "supertack_plate", + "supertack plate": "supertack_plate", + "engineering plate": "eng_plate", + "high temp plate": "hot_plate", + "textured pei plate": "textured_plate", +} + +#: Printer ``bed_type`` back to the slicer's spelling, for a settings override. +PRINTER_TO_SLICER: dict[BedPlate, str] = { + "cool_plate": "Cool Plate", + "supertack_plate": "Supertack Plate", + "eng_plate": "Engineering Plate", + "hot_plate": "High Temp Plate", + "textured_plate": "Textured PEI Plate", +} + + +def plate_from_slicer(value: str | None) -> BedPlate | None: + """Translate a ``curr_bed_type`` string, or ``None`` when it is not recognised.""" + + if not value: + return None + return _SLICER_TO_PRINTER.get(value.strip().lower()) + + +def plate_label(plate: BedPlate | None) -> str: + return PRINTER_TO_SLICER.get(plate, "undeclared") if plate else "undeclared" diff --git a/src/bambu_server/profiles.py b/src/bambu_server/profiles.py index 7f4f697..9b15bc9 100644 --- a/src/bambu_server/profiles.py +++ b/src/bambu_server/profiles.py @@ -20,6 +20,7 @@ from pydantic import BaseModel, Field +from .backend import EXTERNAL_SPOOL_ID from .config import AmsPolicy, MachineLimits, PrinterDefinition, TrayColorLabel from .filament_colors import ColorSource, tray_color_label @@ -39,7 +40,7 @@ class LoadedTray(BaseModel): inventory, and are never carried here. """ - ams_id: int | None = Field(default=None, description="Printer-reported AMS unit ID; HT units retain IDs such as 128/129.") + ams_id: int | None = Field(default=None, description="Printer-reported AMS unit ID; HT units retain IDs such as 128/129, and 254 is the external spool (tray 0).") tray_id: int | None = Field(default=None, description="Zero-based slot within the AMS unit. Use (ams_id, tray_id) as the address.") tray_index: int | None = Field(default=None, description="Legacy field, null for decoded inventory. MQTT n is not a slot address.") tray_type: str | None = None @@ -50,9 +51,14 @@ class LoadedTray(BaseModel): nozzle_temp_max_c: int | None = None remaining_percent: int | None = Field(default=None, ge=0, le=100, description="Printer-estimated remaining filament percentage; null means unavailable, not zero or a full spool.") + @property + def external(self) -> bool: + """The external spool holder rather than an AMS slot.""" + return self.ams_id == EXTERNAL_SPOOL_ID + @property def label(self) -> str: - location = f"AMS {self.ams_id} tray {self.tray_id}" + location = "external spool" if self.external else f"AMS {self.ams_id} tray {self.tray_id}" return f"{self.tray_type or 'unknown filament'} ({location})" @@ -76,6 +82,8 @@ def from_reading( nozzle_type=reading.nozzle_type, nozzle_diameter_mm=reading.nozzle_diameter, ams_unit_ids=reading.ams_unit_ids, + # The external spool is a loaded tray like any other for matching + # and dispatch, addressed as (254, 0). loaded_trays=[ LoadedTray( ams_id=tray.ams_id, @@ -89,7 +97,10 @@ def from_reading( nozzle_temp_max_c=tray.nozzle_temp_max, remaining_percent=tray.remaining_percent, ) - for tray in (reading.ams_trays or []) + for tray in [ + *(reading.ams_trays or []), + *([reading.external_spool] if reading.external_spool else []), + ] ], ) diff --git a/src/bambu_server/slicer.py b/src/bambu_server/slicer.py new file mode 100644 index 0000000..83f2487 --- /dev/null +++ b/src/bambu_server/slicer.py @@ -0,0 +1,299 @@ +"""Slice an uploaded mesh into a printable ``.3mf`` with the Bambu Studio CLI. + +The slicer is a subprocess, never a library: Bambu Studio is a desktop +application whose command line happens to slice headlessly. Everything it needs +is written into a per-job scratch directory under the configured work dir -- +the mesh, an override settings file, Bambu Studio's own ``--datadir`` -- and +the one thing read back is the ``.3mf`` it exports plus its ``result.json``. + +Every preset is written out **flattened**: its whole ``inherits`` chain folded +into one self-contained file. The CLI does not resolve ``inherits`` for a file +outside its bundled profiles, so a copy carrying only the child's own keys +silently loses what the parents define -- the bed size, the machine start gcode, +the filament temperatures. Until 2026-10-21 that is exactly what happened: an A1 +slice came out with a 200 x 200 bed, a two-line ``G28`` start instead of the +A1's (no wipe, no leveling, no purge) and generic 200 C PLA. + +Two facts the gateway knows and the bundled presets do not are forced through +the override file: + +* the **nozzle type** fitted to the target machine (the stock P1S preset says + stainless steel; the lab's printer has hardened steel, and the validator + rightly rejects the mismatch), and +* the **build plate** declared for the machine, as ``curr_bed_type``, so the + bed temperatures and the plate named in the gcode belong to the surface the + printer actually has. + +Slicing touches no printer. It is pure computation on the gateway host. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import re +import shutil +from dataclasses import dataclass +from pathlib import Path + +from .config import BedPlate, SlicerSettings +from .plates import PRINTER_TO_SLICER + +logger = logging.getLogger(__name__) + +_PRESET_NAME_RE = re.compile(r"^[A-Za-z0-9 ._@+()-]+$") +#: Bundled chains are 3-5 deep; anything near this is a loop or a bad file. +_MAX_INHERITS_DEPTH = 16 +#: A preset's identity, not settings: never copied from an included template. +_PRESET_META_KEYS = {"name", "inherits", "include", "instantiation", "setting_id", "from", "type"} +#: Result codes Bambu Studio's CLI writes to ``result.json``; 0 is success. +_SUCCESS_RETURN_CODE = 0 + + +class SlicerError(RuntimeError): + """The mesh could not be sliced. The message is safe to show a submitter.""" + + +class SlicerUnavailable(SlicerError): + """Slicing is not configured for this deployment or this machine.""" + + +@dataclass(frozen=True) +class SliceRequest: + printer_id: str + material: str + nozzle_type: str | None + plate: BedPlate | None + + +@dataclass(frozen=True) +class SliceResult: + artifact: Path + slicer_version: str | None + estimated_minutes: float | None + warnings: tuple[str, ...] + + +class BambuStudioSlicer: + def __init__(self, settings: SlicerSettings) -> None: + self._settings = settings + + @property + def enabled(self) -> bool: + return self._settings.enabled + + @property + def settings(self) -> SlicerSettings: + return self._settings + + def accepts(self, printer_id: str) -> bool: + """Whether an ``.stl`` can be sliced for this printer here.""" + + return self._settings.enabled and printer_id in self._settings.machine_profiles + + def materials(self) -> list[str]: + return sorted(self._settings.filament_profiles) + + def resolve_presets(self, request: SliceRequest) -> tuple[Path, Path, Path]: + """Locate the machine, process and filament preset files, or refuse.""" + + settings = self._settings + if not settings.enabled: + raise SlicerUnavailable("slicing is not enabled on this gateway") + if settings.profiles_dir is None: + raise SlicerUnavailable("the slicer's profiles directory is not configured") + machine_name = settings.machine_profiles.get(request.printer_id) + if machine_name is None: + raise SlicerUnavailable( + f"{request.printer_id} has no slicer machine preset; upload a sliced .3mf instead" + ) + filament_name = settings.filament_profiles.get(request.material.strip().upper()) + if filament_name is None: + offered = ", ".join(self.materials()) or "none" + raise SlicerError( + f"no filament preset for {request.material!r}; materials that can be sliced " + f"here: {offered}" + ) + for name in (machine_name, settings.process_profile, filament_name): + if not _PRESET_NAME_RE.match(name): + raise SlicerUnavailable(f"preset name {name!r} contains characters not allowed") + root = settings.profiles_dir + machine = root / "machine" / f"{machine_name}.json" + process = root / "process" / f"{settings.process_profile}.json" + filament = root / "filament" / f"{filament_name}.json" + for path in (machine, process, filament): + if not path.is_file(): + raise SlicerUnavailable(f"slicer preset not found: {path.name}") + return machine, process, filament + + async def slice( + self, mesh: Path, request: SliceRequest, *, job_dir: Path + ) -> SliceResult: + """Slice ``mesh`` into ``job_dir / 'plate.3mf'``. + + ``job_dir`` must be a fresh directory the caller owns; everything the + slicer writes lands inside it, and the caller decides when to remove it. + """ + + machine, process, filament = self.resolve_presets(request) + if request.plate is None: + raise SlicerError( + f"{request.printer_id} has no declared build plate, so the slice cannot " + "target the right surface" + ) + job_dir.mkdir(parents=True, exist_ok=True) + root = machine.parent.parent + machine_body = _flatten_preset(root, "machine", machine.stem) + process_body = _flatten_preset(root, "process", process.stem) + filament_body = _flatten_preset(root, "filament", filament.stem) + if request.nozzle_type: + # The stock preset carries the value as a one-element list. + machine_body["nozzle_type"] = [request.nozzle_type] + # `curr_bed_type` is a project-level setting; the CLI takes it from any + # loaded settings file and the exported plate gcode carries it. + machine_body["curr_bed_type"] = PRINTER_TO_SLICER[request.plate] + override = job_dir / "machine.json" + override.write_text(json.dumps(machine_body, indent=2), encoding="utf-8") + process = job_dir / "process.json" + process.write_text(json.dumps(process_body, indent=2), encoding="utf-8") + filament = job_dir / "filament.json" + filament.write_text(json.dumps(filament_body, indent=2), encoding="utf-8") + datadir = job_dir / "datadir" + datadir.mkdir(exist_ok=True) + output = job_dir / "plate.3mf" + + argv = [ + str(self._settings.executable), + "--debug", "1", + "--datadir", str(datadir), + "--load-settings", f"{override};{process}", + "--load-filaments", str(filament), + "--arrange", "1", + "--orient", "0", + "--slice", "0", + "--export-3mf", output.name, + "--outputdir", str(job_dir), + str(mesh), + ] + logger.info("Slicing %s for %s (%s)", mesh.name, request.printer_id, request.material) + try: + process_handle = await asyncio.create_subprocess_exec( + *argv, + cwd=job_dir, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.PIPE, + env={"LC_ALL": "C", "HOME": str(job_dir), "PATH": "/usr/bin:/bin"}, + ) + except FileNotFoundError as exc: + raise SlicerUnavailable("the slicer executable is not installed") from exc + try: + _, stderr = await asyncio.wait_for( + process_handle.communicate(), timeout=self._settings.timeout_s + ) + except asyncio.TimeoutError as exc: + process_handle.kill() + await process_handle.wait() + raise SlicerError( + f"slicing did not finish within {int(self._settings.timeout_s)} s" + ) from exc + + result_path = job_dir / "result.json" + result: dict[str, object] = {} + if result_path.is_file(): + try: + result = json.loads(result_path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + result = {} + return_code = result.get("return_code", process_handle.returncode) + if return_code != _SUCCESS_RETURN_CODE or not output.is_file(): + reason = str(result.get("error_string") or "").strip() + if not reason: + tail = (stderr or b"")[-400:].decode("utf-8", errors="replace").strip() + reason = tail.splitlines()[-1] if tail else "no reason reported" + raise SlicerError(f"slicing failed: {reason}") + + warnings: list[str] = [] + estimated: float | None = None + for plate in result.get("sliced_plates") or []: + if not isinstance(plate, dict): + continue + warning = str(plate.get("warning_message") or "").strip() + if warning: + warnings.append(warning) + prediction = plate.get("total_predication") + if isinstance(prediction, (int, float)) and estimated is None: + estimated = float(prediction) / 60.0 + return SliceResult( + artifact=output, + slicer_version=_slicer_version(stderr), + estimated_minutes=estimated, + warnings=tuple(warnings), + ) + + def discard(self, job_dir: Path) -> None: + """Remove a job's scratch directory. Only ever inside the work dir.""" + + root = self._settings.work_dir.resolve() + target = job_dir.resolve() + if root not in target.parents: + raise ValueError("refusing to remove a directory outside the slicer work dir") + shutil.rmtree(target, ignore_errors=True) + + +def _flatten_preset( + root: Path, kind: str, name: str, _depth: int = 0 +) -> dict[str, object]: + """``root/kind/name.json`` with its ``inherits`` chain folded in, child winning. + + Each level's ``include`` list (newer presets keep their gcode in separate + "template" presets, e.g. the A1's machine start gcode) is merged just + before that level's own keys, so a preset's own value still wins. + """ + + chain: list[dict[str, object]] = [] + visited: set[str] = set() + current = name + while current: + if current in visited or len(chain) >= _MAX_INHERITS_DEPTH: + raise SlicerUnavailable(f"{kind} preset {name!r} has an inherits loop") + visited.add(current) + if not _PRESET_NAME_RE.match(current): + raise SlicerUnavailable(f"preset name {current!r} contains characters not allowed") + path = root / kind / f"{current}.json" + try: + body = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise SlicerUnavailable(f"slicer preset not found: {kind}/{path.name}") from exc + except (OSError, json.JSONDecodeError) as exc: + raise SlicerUnavailable(f"{kind} preset {path.name} could not be read") from exc + if not isinstance(body, dict): + raise SlicerUnavailable(f"{kind} preset {path.name} is not a settings object") + body.setdefault("name", current) + chain.append(body) + parent = body.get("inherits") + current = parent.strip() if isinstance(parent, str) else "" + if _depth > _MAX_INHERITS_DEPTH: + raise SlicerUnavailable(f"{kind} preset {name!r} includes too deeply") + merged: dict[str, object] = {} + for body in reversed(chain): + includes = body.get("include") or [] + if not isinstance(includes, list): + raise SlicerUnavailable(f"{kind} preset {body.get('name')!r} has a malformed include") + for included in includes: + fragment = _flatten_preset(root, kind, str(included).strip(), _depth + 1) + merged.update({k: v for k, v in fragment.items() if k not in _PRESET_META_KEYS}) + merged.update(body) + merged.pop("inherits", None) + merged.pop("include", None) + return merged + + +_VERSION_RE = re.compile(r"BambuStudio Version (\S+)") + + +def _slicer_version(stderr: bytes | None) -> str | None: + if not stderr: + return None + match = _VERSION_RE.search(stderr.decode("utf-8", errors="replace")) + return match.group(1) if match else None diff --git a/src/bambu_server/static/index.html b/src/bambu_server/static/index.html index 711b82d..fee46dd 100644 --- a/src/bambu_server/static/index.html +++ b/src/bambu_server/static/index.html @@ -62,6 +62,37 @@ code { background: var(--chip); padding: 1px 5px; border-radius: 4px; font-size: 13px; } .err { color: var(--bad); font-size: 14px; } .actions { display: flex; gap: 6px; } + button.danger { background: var(--bad); border-color: var(--bad); color: #fff; } + .start-grid { display: grid; gap: 16px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } + @media (max-width: 760px) { .start-grid { grid-template-columns: 1fr; } } + /* Printer picker and camera controls on one line; camera at a fixed size + on the left, the machine's detail tiles beside it. */ + .machine-bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } + .machine-bar select { flex: 0 1 420px; width: auto; } + .machine-grid { display: grid; grid-template-columns: 420px minmax(0, 1fr); gap: 16px; align-items: start; margin-top: 12px; } + @media (max-width: 620px) { .machine-grid { grid-template-columns: 1fr; } } + .machine-grid .specs { margin-top: 0; } + .machine-grid .camera-frame { max-width: 480px; } + /* Submission: the form and a file preview on the left, the checklist on the right. */ + .submit-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.25fr); gap: 20px; align-items: start; } + @media (max-width: 760px) { .submit-grid { grid-template-columns: 1fr; } } + .submit-form { display: grid; gap: 12px; } + .submit-form [hidden] { display: none; } + .preview { width: 100%; max-width: 180px; aspect-ratio: 1; background: var(--chip); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; display: flex; align-items: center; justify-content: center; } + .preview img { width: 100%; height: 100%; object-fit: contain; display: block; } + .preview-empty { color: var(--muted); font-size: 13px; text-align: center; padding: 12px; } + tr.pending td { color: var(--muted); } + .camera-frame { width: 100%; aspect-ratio: 4 / 3; background: var(--chip); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; display: flex; align-items: center; justify-content: center; } + .camera-frame img { width: 100%; height: 100%; object-fit: contain; display: block; } + .camera-empty { color: var(--muted); font-size: 13px; text-align: center; padding: 12px; } + .camera-frame [hidden] { display: none; } + .camera-bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 6px; } + .confirm { display: flex; gap: 8px; align-items: flex-start; margin: 12px 0; padding: 10px 12px; border: 1px solid var(--warn); border-radius: 8px; color: var(--ink); } + .confirm input { margin-top: 3px; } + .method { display: flex; gap: 16px; margin-bottom: 10px; flex-wrap: wrap; } + .method label { display: flex; gap: 6px; align-items: center; color: var(--ink); margin: 0; } + fieldset { border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; margin: 0 0 12px; } + legend { font-size: 13px; color: var(--muted); padding: 0 4px; }