From a8ed9f3f1b6e1d73549f422dbabc1faa1e9a72ac Mon Sep 17 00:00:00 2001 From: Yang Cao Date: Mon, 5 Oct 2026 01:49:18 +0000 Subject: [PATCH 01/17] feat: STL slicing and human-confirmed print dispatch Adds a control plane, off unless `dispatch.enabled` is set, that takes a job from mesh to a running print with a person in the loop: - POST /submissions accepts .stl for printers with a slicer preset; the gateway slices with the Bambu Studio CLI, forcing the printer's nozzle type and declared build plate, then validates and queues the 3mf. - STATUS_SPEC v1.1 claims per printer, and three claimed verbs under /printers/{id}/control/: snapshot (one camera frame), start_print, stop_print. allowed_actions comes from the same gate as the 412s. - start_print requires an approved job, a cold idle printer, a matching declared plate, revalidation, an explicit tray per filament, and plate_confirmed_empty by camera snapshot or in person. Verified edge identities are required by default. - Upload is size-verified on the printer; the job is running only when the printer reports it. One attempt per job, never retried; an unconfirmed or interrupted dispatch fails with its uncertainty recorded. - /ui gains the Start and Stop flows; docs and agent rules record the approved scope. --- AGENTS.md | 13 +- AGENT_RULES.md | 19 +- README.md | 106 ++-- docs/API_REFERENCE.md | 22 +- docs/CONTROL_PLANE_DESIGN.md | 25 +- docs/SUBMISSION_PIPELINE_DESIGN.md | 10 +- docs/TODO.md | 49 +- printers.example.yaml | 32 ++ src/bambu_server/artifacts.py | 103 +++- src/bambu_server/backend.py | 220 ++++++++- src/bambu_server/claims.py | 129 +++++ src/bambu_server/config.py | 80 ++++ src/bambu_server/dispatch.py | 743 +++++++++++++++++++++++++++++ src/bambu_server/main.py | 399 +++++++++++++++- src/bambu_server/models.py | 4 +- src/bambu_server/plates.py | 43 ++ src/bambu_server/slicer.py | 240 ++++++++++ src/bambu_server/static/index.html | 333 ++++++++++++- src/bambu_server/submissions.py | 239 ++++++++-- tests/conftest.py | 52 +- tests/test_claims.py | 61 +++ tests/test_dispatch.py | 375 +++++++++++++++ tests/test_slicer.py | 194 ++++++++ tests/test_submission_api.py | 8 +- tests/test_submissions.py | 54 ++- 25 files changed, 3396 insertions(+), 157 deletions(-) create mode 100644 src/bambu_server/claims.py create mode 100644 src/bambu_server/dispatch.py create mode 100644 src/bambu_server/plates.py create mode 100644 src/bambu_server/slicer.py create mode 100644 tests/test_claims.py create mode 100644 tests/test_dispatch.py create mode 100644 tests/test_slicer.py diff --git a/AGENTS.md b/AGENTS.md index a00a298..2f047cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,10 +17,15 @@ 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 three verbs -- camera snapshot, +`start_print` for an approved job after a person confirms the plate is empty, +and `stop_print`. 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..fd0b02a 100644 --- a/AGENT_RULES.md +++ b/AGENT_RULES.md @@ -5,13 +5,20 @@ 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, `start_print` of an approved job, 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. +4. A print is started only by a person who has confirmed the build plate is + empty. 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..91599f2 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 three verbs (camera snapshot, 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,56 @@ 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`. | +| `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; and the request says +`plate_confirmed_empty: true`, by camera snapshot taken under this claim or in +person. 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 @@ -287,7 +333,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..f44dbcc 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -41,10 +41,24 @@ 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`, `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..051790c 100644 --- a/docs/CONTROL_PLANE_DESIGN.md +++ b/docs/CONTROL_PLANE_DESIGN.md @@ -1,10 +1,27 @@ # 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). Phase 2, pause/resume, +temperature, motion, filament, and every excluded verb remain unapproved. +`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/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..ab65914 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -9,7 +9,8 @@ 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. +- The live deployment stays monitoring-only until `dispatch.enabled` is set in + its config; the control plane below is built but off by default. The printer IP addresses and MAC addresses are intentionally not recorded here; they are site-specific configuration and belong in the gitignored local files. @@ -24,23 +25,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. + +## Submission pipeline (implemented, including dispatch) -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. +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 +64,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/src/bambu_server/artifacts.py b/src/bambu_server/artifacts.py index 5fa0ff3..35b8b32 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. @@ -110,6 +112,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 +137,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 @@ -402,6 +428,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 +456,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 +543,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 +581,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 +646,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 +667,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 +688,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..afeaddc 100644 --- a/src/bambu_server/backend.py +++ b/src/bambu_server/backend.py @@ -1,17 +1,68 @@ -"""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 +import time from dataclasses import dataclass 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 + + +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: @@ -84,6 +135,18 @@ 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 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 +177,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__( @@ -144,6 +210,152 @@ 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 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. + + 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") + logger.info("Published %s command", payload["print"]["command"]) # type: ignore[index] + def read(self) -> PrinterReading: connected = bool(self._printer.mqtt_client_connected()) ready = bool(self._printer.mqtt_client_ready()) 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..1e0fad7 --- /dev/null +++ b/src/bambu_server/dispatch.py @@ -0,0 +1,743 @@ +"""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 ArtifactFacts +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 +_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 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 allowed_actions(self, monitor: PrinterMonitor, *, busy_with_dispatch: bool) -> list[str]: + if not self.settings.enabled: + return [] + actions = ["snapshot"] + 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) + 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() + self._snapshots[token] = _Snapshot( + token=token, + printer_id=printer_id, + session_id=holder.session_id if holder else None, + captured_at=now, + sha256=digest, + path=path, + ) + self._prune_snapshots(now) + return SnapshotResponse( + token=token, + printer_id=printer_id, + captured_at=now, + sha256=digest, + image_base64=base64.b64encode(image).decode("ascii"), + ) + + 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}) + + facts = job.facts + 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 (a sliced .3mf)", + "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 = 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 request.plate_check_method == "printer_camera" and request.snapshot_token: + await self._keep_snapshot(request.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, + use_ams=True, + 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, + ) + + # -- 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 + + def _plate_check( + self, + request: StartPrintRequest, + *, + control: _PrinterControl, + printer_id: str, + who: str, + verified: bool, + ) -> PlateCheck: + now = datetime.now(UTC) + if request.plate_check_method == "in_person": + return PlateCheck( + confirmed_by=who, + confirmed_by_verified=verified, + method="in_person", + confirmed_at=now, + ) + snap = self._snapshots.get(request.snapshot_token or "") + 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, + }, + ) + return PlateCheck( + confirmed_by=who, + confirmed_by_verified=verified, + method="printer_camera", + confirmed_at=now, + snapshot_sha256=snap.sha256, + snapshot_captured_at=snap.captured_at, + ) + + 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 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 + where = f"AMS {assignment.ams_id} tray {assignment.tray_id}" + 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 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..111b641 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,24 @@ 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 .claims import ClaimConflict, ClaimLost from .config import ( PrinterCredentials, PrinterDefinition, @@ -50,6 +56,14 @@ resolve_credentials, resolve_edge_secret, ) +from .dispatch import ( + ControlRefusal, + Dispatcher, + SnapshotResponse, + StartPrintRequest, + StopPrintRequest, + StopPrintResponse, +) from .identity import Actor, actor_for, resolve_actor from .models import ( PROTOCOL_VERSION, @@ -63,16 +77,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 +166,11 @@ 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] = {} + control_enabled = bool(settings and settings.dispatch.enabled) @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: @@ -156,24 +181,41 @@ 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 + 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 +270,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 +347,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 +409,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 +474,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 +505,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 +556,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 +799,170 @@ async def delete_submission( " (verified)" if actor.verified else "", ) + if control_enabled: + _register_control_routes(app, get_monitor, get_actor, get_dispatcher) + return app +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/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/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/slicer.py b/src/bambu_server/slicer.py new file mode 100644 index 0000000..10b0ab0 --- /dev/null +++ b/src/bambu_server/slicer.py @@ -0,0 +1,240 @@ +"""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``. + +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 ._@+()-]+$") +#: 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) + # The machine preset is copied whole and amended, rather than layered + # with an `inherits` stub: the CLI resolves `inherits` only against its + # bundled presets, and a stub it cannot resolve is silently ignored. + try: + machine_body = json.loads(machine.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise SlicerUnavailable(f"machine preset {machine.name} could not be read") from exc + if not isinstance(machine_body, dict): + raise SlicerUnavailable(f"machine preset {machine.name} is not a settings object") + 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") + 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) + + +_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..8ea75d7 100644 --- a/src/bambu_server/static/index.html +++ b/src/bambu_server/static/index.html @@ -62,6 +62,17 @@ 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; } } + .snapshot { width: 100%; max-height: 360px; object-fit: contain; background: var(--chip); border: 1px solid var(--line); border-radius: 8px; display: block; } + .snapshot-empty { display: flex; align-items: center; justify-content: center; min-height: 180px; color: var(--muted); background: var(--chip); border-radius: 8px; font-size: 13px; text-align: center; padding: 12px; } + .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; }