From bb9682302d90475b271d9330c8f841c501eb5d08 Mon Sep 17 00:00:00 2001 From: yoyoliuuu Date: Sun, 6 Sep 2026 21:24:50 -0400 Subject: [PATCH] feat: submission pipeline up to the approval gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements docs/SUBMISSION_PIPELINE_DESIGN.md: remote users submit a print artifact, it is validated against the target machine's profile, and valid jobs wait in a per-machine queue with expected finish times. Dispatch is deliberately NOT built. `submissions.dispatch()` raises `DispatchUnavailable`, no route calls it, and no route can enter `dispatching` / `running` / `finished`. There are still no `/control/*` routes and the service issues no printer commands. The whole shipped path is read-and-analysis: it reads the monitor's cache and writes only to the gateway's own disk. New modules: - `profiles.py` machine profile: operator-declared config merged with live telemetry, each field tagged observed / declared / unknown - `artifacts.py` read-only inspection of .3mf and .gcode - `validation.py` the eight checks from the design's §6 - `submissions.py` intake, job state machine, durable store, dispatch stub - `queueing.py` per-machine queue and expected finish times Routes: GET /printers/{id}/profile, GET /printers/{id}/queue, POST /submissions, GET /submissions[/{id}], POST /submissions/{id}/approve. Decisions on the design's §12 open blockers, all following its own recommendation and none of which changes what ships while dispatch is stubbed: human-in-the-loop approval; opaque requested_by / approved_by (documented as not authentication); gateway-owned queue; standard-library .3mf parsing. Recorded in docs/TODO.md. Two judgement calls worth recording: - No mesh parsing. A printable .3mf is a *sliced* plate file embedding Metadata/plate_N.gcode, so the footprint comes from scanning that rather than 3D/3dmodel.model. This sidesteps the 3MF build-transform coordinate ambiguity, and makes "this project file is not sliced, no printer can run it" a real check. The footprint is an extent, so slicer placement cannot change the answer. - `not_applicable` is a third check outcome, never a pass. The design's §10 data gaps (no AMS trays on either live printer, blank H2D nozzle type) report "not compared, and why" instead of quietly approving. Undeclared bed_size_mm and limits behave the same way: there are no built-in defaults, because a guessed limit is a fabricated machine fact. Untrusted input is bounded throughout: size caps on upload and scan, a truncated scan withholds the footprint rather than reporting a partial one as whole, artifacts are stored under the submission's UUID (never a client path), no response carries a stored path, and XML with a document type declaration is refused outright since that is the only place an entity expansion can be declared. The backend also now reads each AMS tray's nozzle_temp_min / nozzle_temp_max, so the filament-window check becomes real as soon as tray data appears. This commit also carries the previously uncommitted rich read-only telemetry work already recorded as done in docs/TODO.md; the two files touch the same structures and could not be separated cleanly after the fact. `uv run ruff check .` and `uv run pytest -q` (114 tests) pass. Tests build their own .3mf and .gcode fixtures and use fake backends; nothing touches hardware. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UQvsfEeDitEyNzbCwrcEdD --- .gitignore | 1 + README.md | 180 +++++++- deploy/bambu-server.local.service | 3 + deploy/bambu-server.service | 3 + docs/CONTROL_PLANE_DESIGN.md | 213 ++++++++++ docs/SUBMISSION_PIPELINE_DESIGN.md | 239 +++++++++++ docs/TODO.md | 73 +++- printers.example.yaml | 25 ++ pyproject.toml | 1 + src/bambu_server/artifacts.py | 639 +++++++++++++++++++++++++++++ src/bambu_server/backend.py | 144 ++++++- src/bambu_server/config.py | 108 ++++- src/bambu_server/main.py | 218 +++++++++- src/bambu_server/monitor.py | 52 ++- src/bambu_server/profiles.py | 169 ++++++++ src/bambu_server/queueing.py | 131 ++++++ src/bambu_server/submissions.py | 492 ++++++++++++++++++++++ src/bambu_server/validation.py | 422 +++++++++++++++++++ tests/conftest.py | 128 +++++- tests/test_api.py | 33 ++ tests/test_artifacts.py | 177 ++++++++ tests/test_backend.py | 57 +++ tests/test_config.py | 85 +++- tests/test_queueing.py | 130 ++++++ tests/test_submission_api.py | 296 +++++++++++++ tests/test_submissions.py | 302 ++++++++++++++ tests/test_validation.py | 288 +++++++++++++ uv.lock | 15 +- 28 files changed, 4604 insertions(+), 20 deletions(-) create mode 100644 docs/CONTROL_PLANE_DESIGN.md create mode 100644 docs/SUBMISSION_PIPELINE_DESIGN.md create mode 100644 src/bambu_server/artifacts.py create mode 100644 src/bambu_server/profiles.py create mode 100644 src/bambu_server/queueing.py create mode 100644 src/bambu_server/submissions.py create mode 100644 src/bambu_server/validation.py create mode 100644 tests/test_artifacts.py create mode 100644 tests/test_queueing.py create mode 100644 tests/test_submission_api.py create mode 100644 tests/test_submissions.py create mode 100644 tests/test_validation.py diff --git a/.gitignore b/.gitignore index df6af09..f72526a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ __pycache__/ *.egg-info/ *.py[cod] printers.local.yaml +var/ diff --git a/README.md b/README.md index 8932375..bcdfa26 100644 --- a/README.md +++ b/README.md @@ -8,23 +8,39 @@ 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** in v0.1. 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 +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 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). + ## Architecture ```text 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 + | + v + per-machine queue --> GET /printers/{id}/queue + | + x dispatch: not implemented ``` 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. +camera and FTP clients are not started. The submission pipeline reads that same +cache and writes only to the gateway's own disk. ## Install @@ -65,6 +81,7 @@ Gateway routes: | GET | `/` | Service identity and configured printer count | | GET | `/health` | Process liveness | | GET | `/printers` | Safe printer inventory (no addresses or credentials) | +| GET | `/status` | Aggregate gateway envelope (one component per printer) | Per-printer STATUS_SPEC routes: @@ -75,7 +92,18 @@ Per-printer STATUS_SPEC routes: | GET | `/printers/{id}/status` | | GET | `/openapi.json` | -No `/control/*` routes exist. +Submission pipeline routes: + +| Method | Path | Purpose | +|---|---|---| +| GET | `/printers/{id}/profile` | The machine a submitter targets and is validated against | +| GET | `/printers/{id}/queue` | Running job and waiting submissions, with finish times | +| POST | `/submissions` | Upload a `.3mf` / `.gcode` artifact; validated inline | +| GET | `/submissions` | List jobs (`machine`, `state`, `limit` filters) | +| GET | `/submissions/{submission_id}` | One job with its verdict and history | +| POST | `/submissions/{submission_id}/approve` | Record sign-off on a queued job | + +No `/control/*` routes exist, and no route dispatches a print. The status envelope uses `equipment_kind: other` because the authoritative contract does not yet define a `3d_printer` kind. `details.device_type` carries @@ -104,6 +132,26 @@ exact sub-state stays visible in `components["print_job"]` and `message`. `FAILED` is `idle` because the job has stopped — §2.3 permits any activity under `error`. +### Rich read-only telemetry + +Beyond the core state and metrics, a `data_ready` status enriches `details` with +read-only observations when the printer actually reports them (never as a bare +null or empty sentinel): + +- `print_type` — source of the job (`cloud` / `local`). +- `nozzle_type`, `nozzle_diameter` — configured nozzle. +- `wifi_signal` — reported signal (dBm as a string). +- `print_error_code` — the printer's reported error code (`0` = none). A failed + job's `last_error.message` appends the code when present. +- `skipped_objects` — object indices skipped in the current job. +- `ams_trays` — loaded AMS filament inventory: per-tray `tray_type`, + `tray_color`, `tray_weight`, `tray_diameter`, `tray_temp`, and the spool's own + `nozzle_temp_min` / `nozzle_temp_max` window. Tray/tag UUIDs are intentionally + not surfaced (identifiers, not inventory). + +These are best-effort: a failure in any one getter is isolated, and the core +`activity`/state decision never depends on them. + `activity_since` is the instant the value last changed, observed by the background poll (every `poll_interval_seconds`), not the time the status request was built. It is `null` whenever the transition itself was never observed — a @@ -116,6 +164,116 @@ started; it gets a timestamp at the next real transition. Print jobs run far longer than the dashboard's 60 s poll, so the sampling caveat in §2.3.1 does not apply and no `cycles_total` metric is published. +## Submission pipeline + +Remote users submit a print artifact to the gateway, an agent-style checker +validates it against the **specific machine** it is destined for, and valid jobs +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 +``` + +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. + +### Machine profile + +`GET /printers/{id}/profile` publishes what a submitter targets. It merges the +operator-declared profile from `printers.local.yaml` (bed size, enclosure, safe +temperature envelope, forbidden materials) with what the printer currently +reports (nozzle, loaded AMS trays). Where the two overlap the observed value +wins and `*_source` says so; the declared value is the fallback for machines +whose live field is blank — a dual-nozzle H2D reports no parsable nozzle type. + +### What is checked + +| check | fails when | +|---|---| +| `machine_compatible` | sliced for another printer, or a nozzle diameter/type the machine does not have | +| `material_allowed` | the filament is on the machine's forbidden list, or the request's declared material contradicts the sliced one | +| `material_filament_match` | no loaded AMS tray holds the model's filament | +| `nozzle_temp_in_band` | the configured **or commanded** nozzle temperature is outside the machine's limit or the loaded filament's window | +| `bed_chamber_temp_in_band` | the bed temperature is out of band, or a heated chamber is requested on a machine without one | +| `build_fits_plate` | the model's XY footprint exceeds the declared plate | +| `gcode_sanity` | the toolpath contains a refused command (firmware update, EEPROM write, PID retune, cold-extrude override, …) | +| `params_present` | filament type, nozzle temperature or bed temperature is missing — or a `.3mf` carries no sliced plate, so no printer could run it | + +One failing check rejects the submission, and rejection is terminal. + +A check whose inputs do not exist reports **`not_applicable`**, with the reason, +and is never reported as a pass. That distinction is the point of the shape: the +live printers currently report no AMS tray inventory, so the filament checks +honestly say "not compared" instead of quietly approving. Declaring `limits` and +`bed_size_mm` in the profile is what turns those checks on — there are no +built-in defaults, because a guessed limit is a fabricated machine fact. + +The gcode scan is explicitly a **heuristic**, and the passing detail says so. It +is not a proof of safety. + +### What is read from an artifact + +A `.gcode` is scanned for its slicer config comments (settings, estimated time) +and its `G0`/`G1` motion (the real plate footprint, measured as an extent so +placement cannot change the answer). A `.3mf` is a zip: a *sliced plate file* +embeds `Metadata/plate_N.gcode`, which is scanned the same way, with +`project_settings.config` and `slice_info.config` filling in the rest. An +unsliced project file is reported as such rather than guessed at. + +Reads are bounded. Above `submissions.scan_max_bytes` only the head and tail of +an artifact are read — enough for a config block at either end — and the plate +footprint is then withheld rather than computed from a partial scan. XML +carrying a document type declaration is refused outright, since that is the only +place an entity expansion can be declared. + +### Queue and expected finish time + +`GET /printers/{id}/queue` is gateway-computed and side-effect free. The running +job's remaining time comes from the printer's own telemetry; each queued job's +duration is the slicer's estimate embedded in its artifact. Anything the gateway +cannot compute is `null` and `estimates_complete` is `false` — an unknown +remaining time makes every downstream estimate unknown rather than wrong. + +The running job is *not* correlated with a submission. This service never +dispatches, so a running print was started by some other route to the printer +(Bambu Studio, the handset, the cloud) and the gateway reports only what it +observes. + +### Submitting + +```bash +curl -sS -X POST http://127.0.0.1:8012/submissions \ + -F file=@plate.gcode.3mf \ + -F target_machine=bambu_x1c_01 \ + -F requested_by=alice \ + -F material=PLA +``` + +The response is the job, with its per-check verdict, at `queued` or `rejected`. +Validation runs inline (the file read happens on a worker thread), so the caller +sees the verdict immediately. + +Uploads are stored under `submissions.directory` named from the submission's +UUID — never from the client's filename, which is reduced to a basename and kept +only as display metadata. No response ever contains a stored path. Jobs are +mirrored to one JSON file each, so a restart does not empty a machine's queue. + +### Identity and approval + +`requested_by` and `approved_by` are **opaque identifiers, not authenticated +identities**. This service has no login; access is gated at the network layer by +Tailscale ACLs, exactly as for the status surface. They are recorded in the job's +history so decisions become attributable the moment a real identity provider +(`ac_auth`) is wired in. + +Approval is human-in-the-loop by design: nothing auto-approves, and a submission +that did not pass validation can never be approved. + ## Dashboard registration Add one entry per printer to `ac-organic-lab/equipment.yaml` after deploying the @@ -153,9 +311,19 @@ loopback by default so a reverse proxy or same-host dashboard is the intended client. The unit uses the FastAPI app factory so `cors_origins` from the local YAML is applied before middleware is constructed. +The unit runs with `ProtectSystem=strict`, so `submissions.directory` must stay +inside `ReadWritePaths` — the shipped units cover the whole install root, which +the default `var/submissions` sits under. Moving the directory elsewhere means +widening `ReadWritePaths` to match. + ## Dependency note The initial integration targets `bambulabs_api` 2.6.x (`>=2.6.6,<3`). It uses only `Printer.mqtt_start()`, `Printer.mqtt_stop()`, telemetry getters, and the MQTT message callback. The version cap makes a future breaking major upgrade an explicit review. + +`python-multipart` backs the submission upload form; it is Starlette's multipart +parser and is a runtime dependency only because `POST /submissions` exists. +Artifact inspection adds no dependency: `.3mf` containers are read with the +standard library's `zipfile`, `json`, and `xml.etree`. diff --git a/deploy/bambu-server.local.service b/deploy/bambu-server.local.service index dd1123b..75e4354 100644 --- a/deploy/bambu-server.local.service +++ b/deploy/bambu-server.local.service @@ -33,6 +33,9 @@ RestrictSUIDSGID=true LockPersonality=true RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX LimitNOFILE=65536 +# The submission intake writes uploaded artifacts and their metadata under +# `submissions.directory` (default `var/submissions`, relative to the config +# file). Keep that path inside ReadWritePaths or intake fails at startup. ReadWritePaths=/home/sdl2/caoyang/bambu-server SyslogIdentifier=bambu-server diff --git a/deploy/bambu-server.service b/deploy/bambu-server.service index 9e7d897..7c8c3e9 100644 --- a/deploy/bambu-server.service +++ b/deploy/bambu-server.service @@ -35,6 +35,9 @@ ProtectKernelModules=true ProtectControlGroups=true RestrictSUIDSGID=true LockPersonality=true +# The submission intake writes uploaded artifacts and their metadata under +# `submissions.directory` (default `var/submissions`, relative to the config +# file). Keep that path inside ReadWritePaths or intake fails at startup. ReadWritePaths=/opt/bambu-server [Install] diff --git a/docs/CONTROL_PLANE_DESIGN.md b/docs/CONTROL_PLANE_DESIGN.md new file mode 100644 index 0000000..00edc1d --- /dev/null +++ b/docs/CONTROL_PLANE_DESIGN.md @@ -0,0 +1,213 @@ +# Bambu Printer Gateway — Control Plane Design (proposal, for review) + +**Status:** DRAFT — no control code may ship until this design is approved. +**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. +**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`. + +--- + +## 1. Why this needs a design, not just endpoints + +The gateway is monitoring-only today (`mode: monitoring_only`, no `/control/*` +routes, camera/FTP clients never started). The third-party `bambulabs_api` +package exposes every command method, but the repo deliberately keeps them +unreachable from HTTP. Two independent reasons: + +1. **Repro safety.** A printer is a fabrication device. Start/pause/stop, + temperature changes, motion, homing, and calibration are real operations and + the request must be gated end-to-end, not reachable in one HTTP hop. +2. **Cooperative contention.** Two workflow clients (an ELN agent, a user on the + dashboard, a scheduler) could race. The lab contract solves this with + **cooperative claims** (STATUS_SPEC §5) — and claims are only meaningful if + every control endpoint honors them. + +So the plan below is the *path from monitoring-only to control-capable*, laid +out so a human can review it before any code lands. + +## 2. Layered safety (STATUS_SPEC §0 model) + +The spec defines four interlock layers. A printer gateway sits at each: + +| Layer | Owner | Bambu-specific concern | +|---|---|---| +| 1. Hardware limits | printer firmware | heat limits, door-open bed-temp cap, end stops, thermal runaway | +| 2. Device state machine | **this gateway** | refuse to start a second job while `running`; refuse any command while `unknown` | +| 3. Skill preconditions | `lab-skills` catalog | the SDK checks skill `requires_states` before dispatching | +| 4. Project plan interlocks | workflow / ELN plan | a run only starts after the plan, claims, and materials are in place | + +The gateway is responsible for **layer 2** (state machine, claims, `allowed_actions` +agreement). It does **not** re-implement layer 1, and it must not pretend to. + +## 3. Scope of control (what a printer can do, ranked by risk) + +From `bambulabs_api` 2.6.6. Grouped by risk, so the phased rollout below can +start at the safe end. + +**A. Observatory / config state (lowest risk)** +Set light on/off, set print-speed %, set part/aux/chamber fan speed, skip +objects (only affects the running job), downgrade firmware. + +**B. In-flight job control (moderate)** +Pause, resume, stop the current job. No motion is initiated that wasn't already +scheduled; these only change the state of an existing print. + +**C. New work (higher risk — the "real" control)** +Start a print from uploaded gcode/3MF, upload a file, set bed/nozzle +temperature, set the filament, home the printer, move Z, calibrate the printer, +load/unload an AMS spool, reboot. + +**D. AMS / material management** +`load_filament_spool`, `unload_filament_spool`, `set_filament_printer`, per-tray +selection. Safe enough to automate **only** with the tray inventory from the +monitoring side, and never while a job is running. + +Deliberately **excluded** until separately justified: `reboot`, +`downgrade_firmware`, `upgrade_firmware`, `manual_update`. These destabilize the +device and belong in a maintenance window, not a routine control surface. + +## 4. Request path + +``` +client (SDK / ELN agent / dashboard action) + └─ lab-skills ── validates skill preconditions (layer 3), carries plan context + └─ POST /control/ ── gateway checks claim + preconditions (layer 2) + └─ BambuLabsBackend.() ── one command, returns hardware ack +``` + +- A control request is **synchronous**: it must reach the printer, get an ack, + and update the cached status so the next `/status` reflects the new state. +- The gateway's background monitor stays authoritative for `activity`; a control + op may trigger a state refresh but must never *invent* a transition. +- **No control call is ever made from a GET handler.** Control only happens on + `/control/*` routes, always claim-gated. + +## 5. Claim protocol conformance (STATUS_SPEC §5) + +The gateway today has no claim surface. Before any `/control/` exists, it +must implement the v1.1 claim protocol: + +- `GET /status` publishes `details.claimed_by` (`ClaimedBy | null`) and a + correctly-derived `allowed_actions` list. +- `POST /control/claim` → `409` if a claim is already held by another session + (cooperative, not authenticated). +- `POST /control/heartbeat` → refreshes the claim TTL; `423` on token mismatch, + `409` if the claim has expired/reaped. +- `POST /control/release` → releases the claim; `423` on token mismatch. +- Claim expiry is reaped by a background task; an expired claim reverts to + `details.claimed_by: null` and returns its grants to `allowed_actions`. + +`allowed_actions` is the **gateway's single source of truth** for "what would the +device honor right now". It must be computed from the same predicate that the +`/control/` handler uses (STATUS_SPEC §6.2), so the adapter/UI/SDK never +sees drift between the advertised list and a real refusal. + +## 6. Preconditions and interlocks (STATUS_SPEC §6) + +### 6.1 State-machine gates — hard refusals + +Any `/control/` is refused (HTTP 412, structured body) unless **all** of: + +1. The gateway is in a determinable state: the printer is `connected` and + `data_ready` and telemetry is **not** stale, and `activity` is not `unknown`. + *Never command a printer you cannot currently observe.* +2. The caller holds the claim (or, for observability-only verbs in §3A, the + gateway may allow them claim-less — decide per-verb and mirror it in + `allowed_actions`). +3. The specific verb's gates pass — see 6.2. + +### 6.2 Per-verb gates + +| Verb | Required state gate | Notes | +|---|---|---| +| `light` / `fan` / `print_speed` / `skip_object` | any determinable state | safe whitelist | +| `pause` / `resume` / `stop` | `activity == running` on pause/stop; `pause`→`resume` only from `running` | stop is always allowed when running, even on others' behalf if they hold the claim | +| `start_print` | `activity == idle`, bed+nozzle at safe temp, claim held, plan context supplied | never auto-start a second job | +| `set_temperature` | `activity == idle`, claim held | refuse if door open warning / out of band | +| `home` / `move_z` / `calibrate` | `activity == idle`, claim held, human approval | motion | +| `load` / `unload` / `set_filament` | `activity == idle`, claim held | crosses the AMS | + +### 6.3 Failure semantics + +- A **precondition refusal** returns `412` + a body distinguishable *by shape* + (not by `message` text), with `Retry-After` when recovery is time-bounded. + A 412 **never** mutates `last_error` (STATUS_SPEC §6.3) and must not latch + `error`. +- A **hardware/command failure** (the printer accepted the command then faulted) + is an execution error: record `last_error`, set `activity`/`equipment_status` + as the printer actually reports, and do not hide it. This is the one path that + may set `error`. + +### 6.4 Clearing policy (learned from the OT-2 gateways) + +The lab already learned the hard way that a `/control/*` failure can lint a +device to `equipment_status: error` and leave a human-only recovery path +(OT-2 `/control/reconcile`). The Bambu gateway must therefore: + +- Keep the **reconcile/clear** affordance explicit and human-gated — a printer's + `error` should not be silently drained by an automated retry. +- Document the exact command that clears a latched `error` (e.g. a documented + `cancel`/`clear_error` action, or the AMS/state recovery), and **never** let an + agent invoke it without a claim and explicit approval. +- A successful control op auto-clears a *stale* `last_error` (STATUS_SPEC §6.4), + but never a *current* run-blocking fault. + +## 7. Human-approval gating + +Not every verb needs a person. Proposal (to be decided at review): + +- **Auto (with plan context + claim):** light, fan speed, print speed, pause, + resume, stop, skip object. These are low-orphaning and reversible via the plan. +- **Needs explicit approval:** start_print, set_temperature, home, move_z, + calibrate, load/unload filament, upload file. One-shot approval ticket, expired + after the operation. +- **Never automated:** reboot, firmware downgrade/upgrade. + +The ELN agent (per its own rules) is told to go direct to equipment and fail +loudly — but it must still go through this surface and hold a claim; "fail +loudly" must not mean "bypass interlocks". + +## 8. Phased rollout + +| Phase | Ship when | Content | +|---|---|---| +| 0 | already | monitoring-only (current) | +| 1 | after review | claim protocol + `allowed_actions` + `details.claimed_by` — **no real commands** yet | +| 2 | phase 1 verified | §3A observability verbs (light/fan/speed/skip) | +| 3 | phase 2 verified | §3B in-flight control (pause/resume/stop) | +| 4 | phase 3 verified | §3C start/upload/temp/filament, gate-checked | +| 5 | phase 4 verified | §3D AMS material management, claim+approval only | +| excluded | — | reboot, firmware management | + +Each phase keeps `/control/reconcile` obvious and human-gated, and each phase +ships with fake-backend tests (no hardware). + +## 9. What changes (enumerate, not implement yet) + +- `src/bambu_server/models.py` — add `ClaimedBy`, claim models, per-verb request + bodies; add control routes to the OpenAPI surface. +- `src/bambu_server/main.py` — `/control/claim`, `/control/heartbeat`, + `/control/release`, then `/control/`; wire `allowed_actions` to the + precondition helper (STATUS_SPEC §6.2). +- `src/bambu_server/backend.py` — expose a narrow, approved subset of + `bambulabs_api` command methods behind the existing adapter; keep camera/FTP + clients off unless a phase explicitly needs them. +- `src/bambu_server/monitor.py` — publish `details.claimed_by`, derive + `allowed_actions` from the same gate predicate, and reconcile `error` on + control recovery without auto-draining. +- `tests/` — fake backends; assert 409/423/412 behaviors, `allowed_actions` + agreement, and that no GET handler performs control. + +## 10. Review checklist + +- [ ] Claim protocol matches STATUS_SPEC §5 (409/423, TTL reaping). +- [ ] `allowed_actions` is derived from the *same* predicate as `/control/*`. +- [ ] Precondition refusals are `412` with shape-distinguishable bodies. +- [ ] No `GET` handler ever issues a control command. +- [ ] `error` latching + human reconcile path documented and test-covered. +- [ ] All phase-4+ verbs are plan-context + claim + approval gated. +- [ ] Reboot/firmware verbs are explicitly excluded from the surface. diff --git a/docs/SUBMISSION_PIPELINE_DESIGN.md b/docs/SUBMISSION_PIPELINE_DESIGN.md new file mode 100644 index 0000000..c1fa2e0 --- /dev/null +++ b/docs/SUBMISSION_PIPELINE_DESIGN.md @@ -0,0 +1,239 @@ +# 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`. +**Scope:** submission → model validation → per-machine queue → ETA → (approved) +dispatch. **This document is the contract the implementer builds against.** + +--- + +## 1. Goal + +Remote users submit a print design to the Lab gateway, an agent validates that +the model is safe and compatible with the **specific machine** it is destined +for, valid jobs wait in a per-machine queue, the dashboard shows the running job +and expected finish time, and only validated + approved jobs are dispatched. + +This is read/analysis until the final dispatch step. The dispatch step is the +single printer-touching action and remains behind the lab approval gate. + +## 2. End-to-end flow + +``` +user ── POST /submissions (.3mf/.gcode + metadata: target_machine, material) + │ file+metadata persisted; NO printer I/O + ▼ +[submitted] --agent validates the MODEL against the machine profile-- + │ pure analysis; NO printer I/O + │ pass -> [validated] + │ fail -> [rejected] (report why; fail loud) + ▼ +[validated] --> [queued] per machine (dashboard: running job + ETA) + │ + ▼ +[queued] --> [approved] (human or agent sign-off, per the autonomy decision) + │ + ▼ +[approved] --> get claim --> dispatch (upload + start_print) *** the gated step *** + │ + ▼ +[running] / [finished] / [failed] (monitor reflects printer state; never invented) +``` + +A job is *control-plane* only at the `approved → dispatch` boundary. Everything +above it (submit, validate, queue, ETA, approve) touches no printer. + +## 3. Machine profile — the thing users target + +A remote user must be able to pick the exact machine they'll use, without being +near it. Publish one immutable profile per printer. Source of truth is the +gateway's own configuration (`printers.local.yaml` + the resolver), exposed +through a read endpoint; the dashboard/ELN can render it. + +Fields (values a checker can validate against): + +```yaml +machine: + id: bambu_p1s_01 # stable id + name: Bambu P1S 01 + model: P1S + enclosure: "enclosed" # P1S/H2D differ from open-frame + nozzle_type: hardened_steel + nozzle_diameter_mm: 0.4 + bed_size_mm: [256, 256] # X, Y + chamber_temperature_c: null # H2D has chamber; P1S does not + ams: # optional, if trays load + filament_forbidden: [] # materials this machine won't run +``` + +`nozzle_type`/`nozzle_diameter` come live from the printer; the rest is +operator-declared on the machine record. The profile is the *checker's* target. + +## 4. Submission API (no printer I/O) + +```http +POST /submissions # multipart: file + fields + multipart: + file: + target_machine: bambu_p1s_01 + material: PLA # optional; inferred if absent + requested_by: # from ac_auth identity if wired, else opaque id + +201 -> submission (job created in [submitted]) +``` + +The gateway persists the file (hashed name by submission id; derive the +submission id from a UUID, never from the user path) plus the metadata. If +identity is wired, verify it here (`ac_auth`); if not, record the opaque owner +string. **This handler performs no printer I/O and no MQTT.** + +- `400` malformed file / unknown `target_machine` +- `404` unknown target machine +- `413` file too large + +Storage is on the gateway host, path in a gitignored directory. Never store the +file under a path derivable from a hostile filename. + +## 5. Job state machine + +``` +submitted -> validating -> validated -> queued -> approved -> dispatching -> running + \-> rejected (terminal) + any state except rejected -> failed (dispatch/run error) -> human reconcile +``` + +States the agent owns: `validating`, `validated`, `rejected`. States the queue +owns: `queued`, `approved`. States the monitor owns: `running`, `finished`, +`failed`. `failed` follows the lab's latching rule (a run error may set +`equipment_status: error`; clearing is human-gated, never auto-drained). + +## 6. Agent validation — "check the model before running it" + +Run synchronously on submission, or as an async worker; either way it is +**pure analysis** of the uploaded artifact against the target machine profile. +It must report pass/fail with a structured, per-check verdict, and never degrade +to a silent pass. Check list: + +| # | Check | Source | Fail condition | +|---|---|---|---| +| 1 | Machine exists & compatible | machine profile | target `id` unknown; nozzle diam/type mismatch | +| 2 | Material allowed | profile `ams.filament_forbidden` + tray data | material not runnable on this machine | +| 3 | Filament actually loaded | `ams_trays[].tray_type` | model material ≠ a loaded tray (when AMS data present) | +| 4 | Nozzle temp in band | tray `nozzle_temp_min/max` | model nozzle temp outside the loaded filament's range | +| 5 | Bed/chamber temp in band | machine profile + printer limits | out of the machine's safe range | +| 6 | Build fits plate | `bed_size_mm` vs model bounds | model exceeds bed size | +| 7 | G-code sanity (`.gcode`) | scan for M104/M140/etc. | disallowed/harmful sequences; junk | +| 8 | Params present | model settings | missing required print settings | + +**Verdict output** (this is the machine-readable result the queue consumes): + +```json +{ + "submission_id": "…", + "verdict": "pass|reject", + "checks": [ + {"check": "material_filament_match", "ok": true, "detail": "PLA loaded tray 1"}, + {"check": "nozzle_temp_in_band", "ok": false, "detail": "220C > tray max 210C"} + ], + "reasons": ["nozzle_temp_in_band"], + "dispatch_ready": false +} +``` + +Rules: a single failing check ⇒ `reject`; `dispatch_ready` is true only on a +full pass **and** any approval gate. For `.gcode` the checker explicitly notes +it is doing a heuristic scan, not a formal safety proof — don't overclaim. + +### Where the checker runs + +A `lab_skills` skill (e.g. `bambu.validate_model`) is the canonical home — that +keeps control routes free of validation logic and matches the lab's +skill-dispatch model (layer 3 preconditions). It takes the artifact path + the +machine profile and returns the verdict. No command methods are called here. + +## 7. Queue + expected end time (the read surface) + +Per-machine FIFO queue of `validated`/`approved` jobs. ETA is gateway-computed, +never guessed by an agent: + +- For the **running job**: the printer already reports `remaining_time` (min); ETA + = `now + remaining_time`. +- For each **queued job**: estimate duration (from the model's print time, or the + slicer-reported time embedded in the `.3mf`/`.gcode`), then a job's ETA = + now + Σ(earlier queued durations) + (current printer remaining). + +The dashboard reads a read-only endpoint (or an extension of the per-printer +`details`) for the queue; **GET never triggers printer I/O or a queue mutation**. + +```http +GET /printers/{id}/queue +-> { "running": {job_name, progress_percent, remaining_time, expected_end}, + "queued": [{job_name, estimated_duration_min, expected_end}, ...] } +``` + +## 8. Dispatch (the only gated, control-plane step) + +Cascades exactly one transition per job, always under a claim +(STATUS_SPEC §5: `/control/claim` → heartbeat → release), after checking the +state-machine preconditions (§6 of the contract) and the approval decision: + +- **Preconditions:** `activity == idle`, telemetry not stale, claim held, machine + matches the job's target, bed/nozzle at safe temp. +- **Refusals:** `412` structured body when a precondition fails; `423` on claim + token mismatch; `409` on a concurrent claim. +- **Operations** (phase-gated, via the existing adapter — never raw + `bambulabs_api` methods from HTTP): + - upload the gcode/3mf to the printer (starts the FTP client — currently + deliberately not started, so this is a phase-4 item to enable), + - `start_print` / `gcode_file`. + +**Approval decision (must be resolved before dispatch is built):** +- *Autonomous*: the agent's `dispatch_ready` **is** the approval. +- *Human-in-the-loop* (recommended start): agent validates + recommends; a person + clicks Approve → dispatch. Relax per machine once trust is established. + +## 9. Permissions & repository rules + +- **Read/analysis surface** (`/submissions`, per-machine profile, `/printers/{id}/queue`, + agent validation): allowed with this design; no printer I/O. +- **Dispatch** is control-plane: needs the approved control design + (`docs/CONTROL_PLANE_DESIGN.md`), claims, interlocks, and the chosen approval + model. Do not expose `/control/*` in this work. +- Never return access codes, serials, IPs, or raw MQTT payloads from any + endpoint. Submissions store files on the gateway host; never echo file paths + back to clients. + +## 10. Data gaps the implementer will hit + +- **`ams_trays` is currently `None` on both live printers.** The material/filament + checks (#3, #4) are only real once the library parses trays. Either wire a + tray parser or mark those checks `info` (non-blocking) until data exists. +- **`nozzle_type` is `None` on H2D** (library enum can't parse a dual-nozzle + report). The profile should carry nozzle config explicitly so machine checks + (#1) don't depend on a blank live field. + +## 11. Out of scope (for this pipeline) + +- Booking/auth/calendar gating — reverted by decision; the queue is cooperative. +- Enforcing against Bambu Studio/Cloud bypass — advisory only. +- Camera/FTP clients (except the one upload call dispatch needs) — keep off + unless a phase explicitly requires them. +- Rebooting, firmware management, motion/temperature set — excluded verbs. + +## 12. Open decisions (blockers to confirm before implementation) + +1. Approval model: agent-autonomous vs human-in-the-loop for dispatch. +2. Identity: is submission tied to `ac_auth` now, or an opaque `requested_by`? +3. Queue source: gateway-owned (recommended, gives real ETA) — confirm. +4. `.3mf` parser choice for duration/material extraction. +5. Is the agent-authorized run permitted, or must a person always approve? + +--- + +**Handoff note for the implementing agent:** build the submission intake, +the validation skill/checker (against the machine profile), the per-machine +queue + ETA read endpoint, and the job state machine. **Leave dispatch stubbed** +behind an approval hook; do not expose `/control/*`. Follow `AGENTS.md`: +`uv run ruff check .` and `uv run pytest -q` must pass; tests use fake backends +and never touch hardware. diff --git a/docs/TODO.md b/docs/TODO.md index 1279c13..6952e3d 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -14,10 +14,81 @@ The printer IP addresses and MAC addresses are intentionally not recorded here; they are site-specific configuration and belong in the gitignored local files. +## Rich read-only telemetry (done) + +- `details` now enriches a `data_ready` status with read-only observations: + `print_type`, `nozzle_type`/`nozzle_diameter`, `wifi_signal`, + `print_error_code` (appended to a failed job's `last_error.message`), + `skipped_objects`, and `ams_trays` (loaded filament inventory; tray/tag UUIDs + intentionally withheld). +- 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) + +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. + +## Submission pipeline (implemented up to the approval gate) + +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`). + +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. + +Decisions taken where §12 left them open, all following the design's own +recommendation: + +1. **Approval model — human-in-the-loop.** Nothing auto-approves; + `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. +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 + `Metadata/plate_N.gcode`, which is scanned like a bare `.gcode`; sidecar + `project_settings.config` / `slice_info.config` fill in the rest. No mesh + parsing and no new dependency, which also sidesteps the 3MF build-transform + coordinate ambiguity — the footprint is measured as an extent. +5. **Agent-authorized runs — not permitted.** Follows (1). + +Open, from the design's §10 data gaps and what the build surfaced: + +- `ams_trays` is still `None` on both live printers, so `material_filament_match` + and the tray half of `nozzle_temp_in_band` report `not_applicable`. The + backend now reads each tray's `nozzle_temp_min` / `nozzle_temp_max`, so both + checks become real the moment tray data appears — no code change needed. +- Machine profiles must be filled in per printer in `printers.local.yaml` + (`bed_size_mm`, `limits`, `ams.filament_forbidden`). Until they are, the + checks that need them report `not_applicable` rather than passing. +- `nozzle_type` is `None` on the H2D; declare it in that printer's profile so + `machine_compatible` has something to compare. +- Validation runs inline on submission. A very large artifact makes the POST + slow (the read is on a worker thread, so it does not stall status polls). If + that becomes a problem, move it to an async worker — the state machine already + has the `validating` state for it. +- No cancel/withdraw path exists for a queued job; the contract's state machine + declares none. +- No retention policy: rejected and finished jobs, and their uploaded artifacts, + stay on disk and in `GET /submissions` indefinitely. Fine at current volume, + but it needs a sweep before this runs unattended for long. + ## Test suite - `uv run ruff check .` passes. -- `uv run pytest -q` passes all 11 tests, including the FastAPI API tests. +- `uv run pytest -q` passes all 114 tests, including the FastAPI API tests and + the submission pipeline (artifact inspection, validation, store/state machine, + queue ETA, HTTP surface). Tests build their own `.3mf` and `.gcode` fixtures + and use fake backends; nothing touches hardware. - The current FastAPI/Starlette stack emits a deprecation warning because Starlette's `TestClient` still uses `httpx`; track the upstream migration to `httpx2`, but it does not currently fail or hang the suite. diff --git a/printers.example.yaml b/printers.example.yaml index 56672f6..0edadf0 100644 --- a/printers.example.yaml +++ b/printers.example.yaml @@ -7,8 +7,33 @@ stale_after_seconds: 20.0 cors_origins: - http://localhost:8000 +# Submission intake. `directory` holds uploaded artifacts and their metadata; +# a relative path is resolved against this file, not the working directory. +submissions: + directory: var/submissions + max_file_bytes: 209715200 # 200 MiB + scan_max_bytes: 67108864 # 64 MiB; above this only the head and tail of + # an artifact are read and plate fit is not + # reported rather than guessed from a partial scan + printers: - id: bambu_x1c_01 name: Bambu X1 Carbon 01 model: X1 Carbon env_prefix: BAMBU_X1C_01 + # Operator-declared machine profile. This is what a remote submitter targets + # and what their model is validated against, so every value should be a fact + # about the machine. Omit a field and the checks that need it report + # `not_applicable` -- they never fall back to a guessed limit. + profile: + enclosure: enclosed # `enclosed` or `open` + nozzle_type: hardened_steel # fallback for printers whose live field is blank + nozzle_diameter_mm: 0.4 + bed_size_mm: [256, 256] # X, Y + 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] + bed_temperature_c: [0, 110] + chamber_temperature_c: null + ams: + filament_forbidden: [] # e.g. [ABS, ASA] on an open-frame machine diff --git a/pyproject.toml b/pyproject.toml index 879514d..258a630 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "fastapi>=0.115", "pydantic>=2.7", "python-dotenv>=1.0", + "python-multipart>=0.0.9", "pyyaml>=6.0", "sdl-lab-contract", "uvicorn[standard]>=0.30", diff --git a/src/bambu_server/artifacts.py b/src/bambu_server/artifacts.py new file mode 100644 index 0000000..5fa0ff3 --- /dev/null +++ b/src/bambu_server/artifacts.py @@ -0,0 +1,639 @@ +"""Read-only inspection of a submitted print artifact. + +This module answers "what does this file say it will do?" and nothing else. It +opens no network connection, touches no printer, and never executes anything it +reads. Everything it returns is an observation about the file, so a caller can +compare those observations against a machine profile. + +Two artifact shapes are understood: + +* ``.gcode`` — a slicer's toolpath. Its ``; key = value`` comment block carries + the print settings, and its motion commands carry the real plate footprint. +* ``.3mf`` — a zip container. A *sliced* plate file (what a printer can + actually run) embeds ``Metadata/plate_N.gcode``, which is scanned exactly like + a bare ``.gcode``; the sidecar ``project_settings.config`` and + ``slice_info.config`` fill in settings and the slicer's own time estimate. + An unsliced project file is reported as ``sliced=False`` rather than guessed + at — no printer can run one. + +Everything here is bounded. Submitted files are untrusted input: reads are +capped, XML with a document type declaration is refused outright (that is the +only place an entity expansion can be declared), and a scan that hits its budget +reports ``scan_truncated`` and withholds the measurements it could not complete +rather than reporting a partial answer as a whole one. +""" + +from __future__ import annotations + +import json +import re +import zipfile +from pathlib import Path +from typing import IO, Literal +from xml.etree import ElementTree + +from pydantic import BaseModel, Field + +ArtifactKind = Literal["3mf", "gcode"] + +#: Extensions the intake accepts, mapped to the kind the inspector reports. +ARTIFACT_EXTENSIONS: dict[str, ArtifactKind] = {".3mf": "3mf", ".gcode": "gcode"} + +# Sidecar config members are small (tens of KB in practice); this is a generous +# ceiling that still refuses a zip bomb dressed up as a settings file. +_MAX_CONFIG_BYTES = 4 * 1024 * 1024 +_MAX_FINDINGS = 20 + +_PLATE_GCODE_RE = re.compile(r"^Metadata/plate_\d+\.gcode$", re.IGNORECASE) +_COMMENT_SETTING_RE = re.compile(r"^;\s*([A-Za-z_][A-Za-z0-9_.]*)\s*=\s*(.*)$") +# A slicer header typically states two times on one line: the total estimate +# (wall clock, including heating and non-printing moves) and the model printing +# time. The total is the one a queue estimate wants, so it is matched +# separately and preferred over the fallback rather than by regex ordering. +_TOTAL_TIME_RE = re.compile( + r"total estimated time\s*[:=]\s*([0-9hmsd. ]+)", re.IGNORECASE +) +_FALLBACK_TIME_RE = re.compile( + r"(?:estimated printing time[^:=]*|model printing time)\s*[:=]\s*([0-9hmsd. ]+)", + re.IGNORECASE, +) +_HMS_RE = re.compile(r"(\d+(?:\.\d+)?)\s*([dhms])", re.IGNORECASE) +_AXIS_RE = re.compile(r"([XYZ])(-?\d+(?:\.\d+)?)") +# A gcode word is a letter followed by a number (G1, M104, T0). Lines that do +# not start with one are not counted as commands, so a text file renamed to +# .gcode reports `sliced=False` instead of a toolpath it does not contain. +_COMMAND_WORD_RE = re.compile(r"^[A-Z]-?\d+(?:\.\d+)?$") +_DOCTYPE_RE = re.compile(rb" None: + self.settings: dict[str, str] = {} + self.total_minutes: float | None = None + self.fallback_minutes: float | None = None + self.command_count = 0 + self.findings: list[GcodeFinding] = [] + self.relative_positioning_seen = False + self._min = [None, None, None] # type: list[float | None] + self._max = [None, None, None] # type: list[float | None] + self._absolute = True + self._position: list[float | None] = [None, None, None] + + @property + def duration_minutes(self) -> float | None: + return self.total_minutes if self.total_minutes is not None else self.fallback_minutes + + def extent(self) -> tuple[float, float, float] | None: + if any(value is None for value in self._min + self._max): + return None + low_x, low_y, low_z = self._min + high_x, high_y, high_z = self._max + return ( + round(high_x - low_x, 3), # type: ignore[operator] + round(high_y - low_y, 3), # type: ignore[operator] + round(high_z - low_z, 3), # type: ignore[operator] + ) + + def add_finding(self, code: str, detail: str) -> None: + if len(self.findings) < _MAX_FINDINGS: + self.findings.append(GcodeFinding(code=code, detail=detail)) + + def observe_line(self, line: str) -> None: + stripped = line.strip() + if not stripped: + return + if stripped.startswith(";"): + self._observe_comment(stripped) + return + self._observe_command(stripped) + + def _observe_comment(self, comment: str) -> None: + match = _COMMENT_SETTING_RE.match(comment) + if match: + key, raw = match.group(1).lower(), match.group(2).strip() + # First writer wins: a slicer's header block precedes its config + # block, and re-reading the same key later must not clobber it. + self.settings.setdefault(key, raw) + if self.total_minutes is None: + total = _TOTAL_TIME_RE.search(comment) + if total: + self.total_minutes = parse_duration_minutes(total.group(1)) + if self.fallback_minutes is None: + fallback = _FALLBACK_TIME_RE.search(comment) + if fallback: + self.fallback_minutes = parse_duration_minutes(fallback.group(1)) + + def _observe_command(self, command_line: str) -> None: + body = command_line.split(";", 1)[0].strip() + if not body: + return + word = body.split(maxsplit=1)[0].upper() + if not _COMMAND_WORD_RE.match(word): + return + self.command_count += 1 + + if word == "G90": + self._absolute = True + return + if word == "G91": + self._absolute = False + self.relative_positioning_seen = True + return + + reason = _FORBIDDEN_COMMANDS.get(word) + if reason is not None: + self.add_finding( + "forbidden_command", f"{word} {reason}; refused in a submitted job" + ) + return + + if word in _NOZZLE_TEMP_COMMANDS or word in _BED_TEMP_COMMANDS: + self._observe_temperature_command(word, body) + return + + if word in {"G0", "G1", "G2", "G3"} and self._absolute: + self._observe_move(body) + + def _observe_temperature_command(self, word: str, body: str) -> None: + value = _parse_parameter(body, "S") + if value is None: + return + key = ( + "commanded_nozzle_temperature_c" + if word in _NOZZLE_TEMP_COMMANDS + else "commanded_bed_temperature_c" + ) + previous = self.settings.get(key) + highest = max(value, float(previous)) if previous is not None else value + # Overwrite rather than setdefault: the interesting value is the peak + # the job ever commands, not the first one it happens to command. + self.settings[key] = str(highest) + + def _observe_move(self, body: str) -> None: + for axis, raw in _AXIS_RE.findall(body.upper()): + index = "XYZ".index(axis) + try: + value = float(raw) + except ValueError: + continue + self._position[index] = value + low, high = self._min[index], self._max[index] + self._min[index] = value if low is None else min(low, value) + self._max[index] = value if high is None else max(high, value) + + +def parse_duration_minutes(text: str) -> float | None: + """Parse ``1h 2m 3s`` / ``45s`` / a bare seconds count into minutes.""" + + text = text.strip() + if not text: + return None + matches = _HMS_RE.findall(text) + if matches: + scale = {"d": 1440.0, "h": 60.0, "m": 1.0, "s": 1.0 / 60.0} + return round( + sum(float(value) * scale[unit.lower()] for value, unit in matches), 2 + ) + try: + return round(float(text) / 60.0, 2) + except ValueError: + return None + + +def _parse_parameter(body: str, letter: str) -> float | None: + match = re.search(rf"(?:^|\s){letter}(-?\d+(?:\.\d+)?)", body, re.IGNORECASE) + if match is None: + return None + try: + return float(match.group(1)) + except ValueError: + return None + + +def _first_number(raw: str | None) -> float | None: + """Take the first value of a scalar or a ``,``/``;``-separated list.""" + + if raw is None: + return None + head = re.split(r"[,;]", str(raw).strip())[0].strip() + if not head: + return None + try: + return float(head) + except ValueError: + return None + + +def _split_values(raw: str | None) -> tuple[str, ...]: + if raw is None: + return () + seen: list[str] = [] + for part in re.split(r"[,;]", str(raw)): + token = part.strip().strip('"').upper() + if token and token not in seen: + seen.append(token) + return tuple(seen) + + +def _scan_stream(stream: IO[bytes], *, budget: int) -> tuple[_Scan, bool]: + """Scan up to ``budget`` bytes of a gcode stream. + + Returns the scan and whether the stream was cut short. A truncated scan + still yields settings (slicers write their config block at the top of the + file) but its motion extents are incomplete, so the caller discards them. + """ + + scan = _Scan() + consumed = 0 + remainder = b"" + exhausted = False + while consumed < budget: + chunk = stream.read(min(1 << 20, budget - consumed)) + if not chunk: + exhausted = True + break + consumed += len(chunk) + remainder += chunk + *lines, remainder = remainder.split(b"\n") + for raw_line in lines: + scan.observe_line(raw_line.decode("latin-1")) + + # Budget reached: one more byte decides whether anything was left unread. + truncated = not exhausted and bool(stream.read(1)) + if remainder and not truncated: + scan.observe_line(remainder.decode("latin-1")) + return scan, truncated + + +def _scan_tail(path: Path, *, size: int, budget: int) -> _Scan: + """Scan the last ``budget`` bytes of a file. + + Some slicers (PrusaSlicer and its derivatives) write their configuration + block at the *end* of the file, so a head-only scan of a large artifact + would see no settings at all. + """ + + scan = _Scan() + with path.open("rb") as handle: + handle.seek(max(0, size - budget)) + # The first line is probably cut mid-way; dropping it costs nothing. + handle.readline() + for raw_line in handle: + scan.observe_line(raw_line.decode("latin-1")) + return scan + + +def _merge_settings(primary: _Scan, secondary: _Scan) -> None: + for key, value in secondary.settings.items(): + primary.settings.setdefault(key, value) + if primary.total_minutes is None: + primary.total_minutes = secondary.total_minutes + if primary.fallback_minutes is None: + primary.fallback_minutes = secondary.fallback_minutes + primary.command_count += secondary.command_count + + +def _read_member(archive: zipfile.ZipFile, name: str, *, budget: int) -> bytes | None: + try: + with archive.open(name) as member: + data = member.read(budget + 1) + except (KeyError, OSError, zipfile.BadZipFile): + return None + if len(data) > budget: + return None + return data + + +def _parse_xml(data: bytes) -> ElementTree.Element | None: + """Parse trusted-shape XML from an untrusted file. + + A document type declaration is the only place an XML entity can be defined, + and entity expansion is the standard way to turn a small XML file into an + out-of-memory condition. Bambu's config members never carry one, so refusing + the whole document is both safe and free of false negatives. + """ + + if _DOCTYPE_RE.search(data[:8192]) or _DOCTYPE_RE.search(data): + return None + try: + return ElementTree.fromstring(data) + except ElementTree.ParseError: + return None + + +def _local_name(tag: str) -> str: + return tag.rpartition("}")[2] + + +def _settings_from_project(data: bytes) -> dict[str, str]: + """Flatten ``Metadata/project_settings.config`` (JSON) into scalar strings.""" + + try: + payload = json.loads(data.decode("utf-8", errors="replace")) + except (json.JSONDecodeError, UnicodeDecodeError): + return {} + if not isinstance(payload, dict): + return {} + settings: dict[str, str] = {} + for key, value in payload.items(): + if isinstance(value, (str, int, float)): + settings[str(key).lower()] = str(value) + elif isinstance(value, list) and value and all( + isinstance(item, (str, int, float)) for item in value + ): + settings[str(key).lower()] = ",".join(str(item) for item in value) + return settings + + +def _slice_info(data: bytes) -> tuple[dict[str, str], tuple[str, ...], float | None]: + """Read ``Metadata/slice_info.config`` for plate metadata and filaments.""" + + root = _parse_xml(data) + if root is None: + return {}, (), None + + settings: dict[str, str] = {} + filaments: list[str] = [] + duration_minutes: float | None = None + 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) + 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 prediction: + duration_minutes = parse_duration_minutes(prediction) + return settings, tuple(filaments), duration_minutes + + +def _bed_temperature(settings: dict[str, str]) -> float | None: + """Pick the bed temperature for the plate the job is actually configured for. + + Bambu stores one temperature per plate type and names the selected plate in + ``curr_bed_type``; reading the wrong one would compare the machine's limit + against a temperature this job never uses. + """ + + bed_type = (settings.get("curr_bed_type") or "").lower() + preferred: str | None = None + if "textured" in bed_type: + preferred = "textured_plate_temp" + elif "eng" in bed_type: + preferred = "eng_plate_temp" + elif "supertack" in bed_type: + preferred = "supertack_plate_temp" + elif "cool" in bed_type or "pla plate" in bed_type: + preferred = "cool_plate_temp" + elif bed_type: + preferred = "hot_plate_temp" + + for key in ((preferred,) if preferred else ()) + _BED_TEMPERATURE_KEYS: + value = _first_number(settings.get(key)) + if value is not None: + return value + return _first_number(settings.get("commanded_bed_temperature_c")) + + +def _facts_from_settings( + *, + kind: ArtifactKind, + byte_size: int, + sliced: bool, + scan: _Scan, + truncated: bool, + extra_filaments: tuple[str, ...], + extra_duration: float | None, + extent_source: str | None, + notes: list[str], +) -> ArtifactFacts: + settings = scan.settings + filaments = _split_values(settings.get("filament_type")) or extra_filaments + + extent = None if truncated else scan.extent() + if truncated and scan.extent() is not None: + notes.append( + "plate footprint not reported: the artifact exceeded the scan budget " + "so its motion commands were only partly read" + ) + if scan.relative_positioning_seen: + extent = None + notes.append( + "plate footprint not reported: the toolpath uses relative positioning" + ) + + nozzle_temperature = None + for key in _NOZZLE_TEMPERATURE_KEYS: + nozzle_temperature = _first_number(settings.get(key)) + if nozzle_temperature is not None: + break + if nozzle_temperature is None: + nozzle_temperature = _first_number(settings.get("commanded_nozzle_temperature_c")) + + duration = scan.duration_minutes if scan.duration_minutes is not None else extra_duration + + return ArtifactFacts( + kind=kind, + byte_size=byte_size, + sliced=sliced, + scan_truncated=truncated, + filament_types=filaments, + nozzle_temperature_c=nozzle_temperature, + bed_temperature_c=_bed_temperature(settings), + chamber_temperature_c=_first_number(settings.get("chamber_temperature")), + nozzle_diameter_mm=_first_number(settings.get("nozzle_diameter")), + nozzle_type=(settings.get("nozzle_type") or "").strip().lower() or None, + layer_height_mm=_first_number(settings.get("layer_height")), + commanded_nozzle_temperature_c=_first_number( + settings.get("commanded_nozzle_temperature_c") + ), + commanded_bed_temperature_c=_first_number( + settings.get("commanded_bed_temperature_c") + ), + printer_model=(settings.get("printer_model") or "").strip() or None, + estimated_duration_minutes=duration, + extent_mm=extent, + extent_source=extent_source if extent is not None else None, + gcode_findings=tuple(scan.findings), + notes=tuple(notes), + ) + + +def _inspect_gcode(path: Path, *, scan_max_bytes: int) -> ArtifactFacts: + size = path.stat().st_size + notes: list[str] = [] + with path.open("rb") as handle: + scan, truncated = _scan_stream(handle, budget=scan_max_bytes) + if truncated: + # A trailing config block is the norm for some slicers, so read the tail + # before concluding that a large file declares no settings at all. + _merge_settings(scan, _scan_tail(path, size=size, budget=min(scan_max_bytes, 1 << 22))) + if scan.command_count == 0: + notes.append("no gcode commands were found in the scanned region") + return _facts_from_settings( + kind="gcode", + byte_size=size, + sliced=scan.command_count > 0, + scan=scan, + truncated=truncated, + extra_filaments=(), + extra_duration=None, + extent_source="gcode_motion", + notes=notes, + ) + + +def _inspect_3mf(path: Path, *, scan_max_bytes: int) -> ArtifactFacts: + size = path.stat().st_size + notes: list[str] = [] + try: + archive = zipfile.ZipFile(path) + except zipfile.BadZipFile as exc: # pragma: no cover - guarded at intake + raise ArtifactError("the 3mf container could not be opened") from exc + + with archive: + names = archive.namelist() + project = _read_member(archive, "Metadata/project_settings.config", budget=_MAX_CONFIG_BYTES) + slice_member = _read_member(archive, "Metadata/slice_info.config", budget=_MAX_CONFIG_BYTES) + + plate_names = sorted(name for name in names if _PLATE_GCODE_RE.match(name)) + scan = _Scan() + truncated = False + if plate_names: + if len(plate_names) > 1: + notes.append( + f"the container holds {len(plate_names)} plates; " + f"{plate_names[0]} was inspected" + ) + with archive.open(plate_names[0]) as member: + scan, truncated = _scan_stream(member, budget=scan_max_bytes) + if truncated: + notes.append( + "the embedded plate gcode exceeded the scan budget; " + "only its leading section was read" + ) + else: + notes.append( + "no sliced plate gcode is embedded, so this project file cannot " + "be run by a printer as submitted" + ) + + slice_settings: dict[str, str] = {} + slice_filaments: tuple[str, ...] = () + slice_duration: float | None = None + if slice_member is not None: + slice_settings, slice_filaments, slice_duration = _slice_info(slice_member) + elif "Metadata/slice_info.config" in names: + notes.append("slice_info.config could not be read") + + project_settings = _settings_from_project(project) if project is not None else {} + + # Precedence: the embedded gcode is what the printer will actually execute, + # so its own header wins over the sidecar configs that describe intent. + for source in (slice_settings, project_settings): + for key, value in source.items(): + scan.settings.setdefault(key, value) + + return _facts_from_settings( + kind="3mf", + byte_size=size, + sliced=bool(plate_names) and scan.command_count > 0, + scan=scan, + truncated=truncated, + extra_filaments=slice_filaments, + extra_duration=slice_duration, + extent_source="embedded_plate_gcode", + notes=notes, + ) + + +def inspect_artifact(path: Path, *, scan_max_bytes: int) -> ArtifactFacts: + """Observe a submitted artifact. Never contacts a printer.""" + + kind = ARTIFACT_EXTENSIONS.get(path.suffix.lower()) + if kind is None: + raise ArtifactError(f"unsupported artifact extension {path.suffix!r}") + if kind == "gcode": + return _inspect_gcode(path, scan_max_bytes=scan_max_bytes) + return _inspect_3mf(path, scan_max_bytes=scan_max_bytes) diff --git a/src/bambu_server/backend.py b/src/bambu_server/backend.py index 4b77672..24969c7 100644 --- a/src/bambu_server/backend.py +++ b/src/bambu_server/backend.py @@ -5,13 +5,34 @@ import threading from dataclasses import dataclass from datetime import UTC, datetime -from typing import Protocol +from typing import Protocol, TypedDict import bambulabs_api as bambu from .config import PrinterCredentials, PrinterDefinition +@dataclass(frozen=True) +class AmsTrayReading: + """A single loaded AMS filament tray. Field types mirror the third-party + ``FilamentTray`` payload, which carries some values as strings.""" + + ams_id: int | None = None + tray_id: int | None = None + tray_index: int | None = None + tray_type: str | None = None + tray_color: str | None = None + tray_weight: str | None = None + tray_diameter: str | None = None + tray_temp: str | None = None + # The filament's own nozzle-temperature window, as reported by the spool + # tag. Carried as integers because that is how the payload types them, and + # because the submission validator compares a model's configured nozzle + # temperature against this window. + nozzle_temp_min: int | None = None + nozzle_temp_max: int | None = None + + @dataclass(frozen=True) class PrinterReading: data_updated_at: datetime | None @@ -30,6 +51,26 @@ class PrinterReading: light_state: str | None = None job_name: str | None = None firmware_version: str | None = None + nozzle_type: str | None = None + nozzle_diameter: float | None = None + print_type: str | None = None + wifi_signal: str | None = None + print_error_code: int | None = None + skipped_objects: list[int] | None = None + ams_trays: list[AmsTrayReading] | None = None + + +class AdvancedReading(TypedDict): + """The optional enrichment fields, typed so ``**`` spreads against + :class:`PrinterReading`'s constructor without a static mismatch.""" + + nozzle_type: str | None + nozzle_diameter: float | None + print_type: str | None + wifi_signal: str | None + print_error_code: int | None + skipped_objects: list[int] | None + ams_trays: list[AmsTrayReading] | None class PrinterBackend(Protocol): @@ -40,6 +81,18 @@ def stop(self) -> None: ... def read(self) -> PrinterReading: ... +def _optional_int(value: object) -> int | None: + """Coerce a tray field to ``int`` or drop it. + + Tray payload fields arrive as ints or numeric strings depending on firmware, + and an unparsable value is reported as absent rather than as a temperature + the printer never stated. + """ + + number = _number(value) + return int(number) if number is not None else None + + def _number(value: object) -> int | float | None: if isinstance(value, bool): return None @@ -96,6 +149,7 @@ def read(self) -> PrinterReading: data_ready=ready, ) + advanced = self._advanced_reading() return PrinterReading( data_updated_at=data_updated_at, connected=True, @@ -113,4 +167,92 @@ def read(self) -> PrinterReading: light_state=self._printer.get_light_state(), job_name=self._printer.get_file_name() or None, firmware_version=self._printer.mqtt_client.firmware_version(), + **advanced, ) + + def _advanced_reading(self) -> AdvancedReading: + """Best-effort advanced telemetry (nozzle, print metadata, wifi, AMS). + + These are optional enrichment: a failure in any one getter must not + poison the whole reading, so each is isolated. Sentinel/default values + (0.0 nozzle diameter, empty strings, ``stainless_steel``) are collapsed + to ``None`` rather than reported as facts we did not observe. + """ + values: AdvancedReading = { + "nozzle_type": None, + "nozzle_diameter": None, + "print_type": None, + "wifi_signal": None, + "print_error_code": None, + "skipped_objects": None, + "ams_trays": None, + } + + try: + nozzle_type = self._printer.nozzle_type() + values["nozzle_type"] = str(nozzle_type) if nozzle_type else None + except Exception: + pass + try: + diameter = self._printer.nozzle_diameter() + values["nozzle_diameter"] = float(diameter) if diameter else None + except Exception: + pass + try: + print_type = self._printer.print_type() + values["print_type"] = print_type.strip() or None + except Exception: + pass + try: + wifi_signal = self._printer.wifi_signal() + values["wifi_signal"] = wifi_signal.strip() or None + except Exception: + pass + try: + error_code = self._printer.print_error_code() + values["print_error_code"] = int(error_code) if error_code is not None else None + except Exception: + pass + try: + skipped = self._printer.get_skipped_objects() + values["skipped_objects"] = [int(obj) for obj in (skipped or [])] or None + except Exception: + pass + + values["ams_trays"] = self._read_ams_trays() + return values + + def _read_ams_trays(self) -> list[AmsTrayReading] | None: + """Read loaded AMS tray inventory. Returns ``None`` on any failure so a + malformed AMS payload cannot take down the poll. Tray UUIDs and tag + UIDs are intentionally not surfaced (identifiers, not inventory).""" + try: + hub = self._printer.ams_hub() + except Exception: + return None + trays: list[AmsTrayReading] = [] + try: + for ams_id, ams in hub.ams_hub.items(): + for tray_id, tray in ams.filament_trays.items(): + index = getattr(tray, "n", None) + trays.append( + AmsTrayReading( + ams_id=int(ams_id), + tray_id=int(tray_id), + tray_index=index if index is not None else None, + tray_type=getattr(tray, "tray_type", None) or None, + tray_color=getattr(tray, "tray_color", None) or None, + tray_weight=getattr(tray, "tray_weight", None) or None, + tray_diameter=getattr(tray, "tray_diameter", None) or None, + tray_temp=getattr(tray, "tray_temp", None) or None, + nozzle_temp_min=_optional_int( + getattr(tray, "nozzle_temp_min", None) + ), + nozzle_temp_max=_optional_int( + getattr(tray, "nozzle_temp_max", None) + ), + ) + ) + except Exception: + return None + return trays or None diff --git a/src/bambu_server/config.py b/src/bambu_server/config.py index aadf0e3..bd33400 100644 --- a/src/bambu_server/config.py +++ b/src/bambu_server/config.py @@ -4,18 +4,97 @@ import os from pathlib import Path -from typing import Any +from typing import Any, Literal import yaml from dotenv import load_dotenv from pydantic import BaseModel, Field, SecretStr, field_validator, model_validator +class TemperatureBand(BaseModel): + """An inclusive operating range. + + Accepts either the explicit mapping ``{min_c: 0, max_c: 300}`` or the + shorthand sequence ``[0, 300]``, because a two-number range reads better as + a pair in YAML and both forms show up in operator-written config. + """ + + min_c: float + max_c: float + + @model_validator(mode="before") + @classmethod + def accept_pair(cls, value: Any) -> Any: + if isinstance(value, (list, tuple)): + if len(value) != 2: + raise ValueError("a temperature band must be [min_c, max_c]") + return {"min_c": value[0], "max_c": value[1]} + return value + + @model_validator(mode="after") + def validate_order(self) -> TemperatureBand: + if self.max_c <= self.min_c: + raise ValueError("max_c must exceed min_c") + return self + + def contains(self, value: float) -> bool: + return self.min_c <= value <= self.max_c + + +class MachineLimits(BaseModel): + """Operator-declared safe operating envelope for one machine. + + Every band is optional and there are deliberately no built-in defaults: a + guessed limit is a fabricated machine fact, and the validator reports an + undeclared band as *not applicable* rather than silently passing a check it + could not actually perform. + """ + + nozzle_temperature_c: TemperatureBand | None = None + bed_temperature_c: TemperatureBand | None = None + chamber_temperature_c: TemperatureBand | None = None + + +class AmsPolicy(BaseModel): + filament_forbidden: list[str] = Field(default_factory=list) + + @field_validator("filament_forbidden") + @classmethod + def normalise(cls, value: list[str]) -> list[str]: + return [item.strip().upper() for item in value if item.strip()] + + +class MachineProfileConfig(BaseModel): + """The operator-declared half of a machine profile. + + Nozzle type and diameter are also reported live by the printer, but the live + field is blank on some models (a dual-nozzle H2D reports no parsable nozzle + type), so the declared value is kept as the authoritative fallback and + machine-compatibility checking never depends on a blank live field. + """ + + enclosure: Literal["enclosed", "open"] | None = None + 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 + chamber_temperature_c: float | None = None + limits: MachineLimits = Field(default_factory=MachineLimits) + ams: AmsPolicy = Field(default_factory=AmsPolicy) + + @field_validator("bed_size_mm") + @classmethod + def validate_bed(cls, value: tuple[float, float] | None) -> tuple[float, float] | None: + if value is not None and (value[0] <= 0 or value[1] <= 0): + raise ValueError("bed_size_mm entries must be positive") + return value + + class PrinterDefinition(BaseModel): id: str = Field(pattern=r"^[a-z][a-z0-9_]*$") name: str = Field(min_length=1, max_length=120) model: str | None = Field(default=None, max_length=120) env_prefix: str = Field(pattern=r"^[A-Z][A-Z0-9_]*$") + profile: MachineProfileConfig = Field(default_factory=MachineProfileConfig) @field_validator("id", "name", "model", "env_prefix", mode="before") @classmethod @@ -23,11 +102,28 @@ def strip_text(cls, value: Any) -> Any: return value.strip() if isinstance(value, str) else value +class SubmissionSettings(BaseModel): + """Submission intake limits and storage location. + + ``directory`` holds uploaded artifacts and their metadata. Keep it out of + git: submitted models are user data, not repository content. + """ + + directory: Path = Path("var/submissions") + max_file_bytes: int = Field(default=200 * 1024 * 1024, ge=1024, le=2 * 1024**3) + # Above this size an artifact is scanned head-and-tail only: enough to read + # a slicer config block (which sits at either end of the file) but not + # enough to trust a motion-derived bounding box, so plate fit is reported + # as not applicable rather than computed from a partial scan. + scan_max_bytes: int = Field(default=64 * 1024 * 1024, ge=64 * 1024) + + 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) @model_validator(mode="after") def validate_unique_printers(self) -> Settings: @@ -77,4 +173,12 @@ def load_settings(path: str | Path | None = None) -> Settings: payload = yaml.safe_load(handle) if not isinstance(payload, dict): raise ValueError(f"configuration at {resolved} must contain a YAML mapping") - return Settings.model_validate(payload) + settings = Settings.model_validate(payload) + # A relative submission directory is resolved against the config file, not + # the process working directory, so a systemd unit and an interactive shell + # agree on where submitted artifacts live. + if not settings.submissions.directory.is_absolute(): + settings.submissions.directory = ( + resolved.parent / settings.submissions.directory + ).resolve() + return settings diff --git a/src/bambu_server/main.py b/src/bambu_server/main.py index f8f3e67..1ff8a0d 100644 --- a/src/bambu_server/main.py +++ b/src/bambu_server/main.py @@ -1,14 +1,32 @@ -"""FastAPI application for the monitoring-only Bambu printer gateway.""" +"""FastAPI application for the monitoring-only Bambu printer gateway. +Two 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. +""" + +import asyncio from collections.abc import AsyncIterator, Callable from contextlib import asynccontextmanager from datetime import UTC, datetime +from pathlib import PurePosixPath from typing import Annotated -from fastapi import Depends, FastAPI, HTTPException, Path +from fastapi import Depends, FastAPI, File, Form, HTTPException, Path, Query, UploadFile from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel, Field from . import __version__ +from .artifacts import ARTIFACT_EXTENSIONS from .backend import BambuLabsBackend, PrinterBackend from .config import ( PrinterCredentials, @@ -27,9 +45,40 @@ ProbeResponse, ) from .monitor import PrinterMonitor +from .profiles import MachineProfile +from .queueing import QueueView, build_queue_view +from .submissions import ( + ArtifactTooLarge, + InvalidTransition, + JobState, + SubmissionError, + SubmissionJob, + SubmissionStore, + run_validation, +) BackendFactory = Callable[[PrinterDefinition, PrinterCredentials], PrinterBackend] +#: Read size for streaming an upload to disk. +_UPLOAD_CHUNK_BYTES = 1 << 20 + +#: Leading bytes an artifact must start with, keyed by kind. A ``.3mf`` is a +#: zip container; anything else under that name is a malformed submission and +#: is refused at intake rather than carried through validation. +_MAGIC_PREFIXES: dict[str, bytes] = {"3mf": b"PK\x03\x04"} + + +class ApprovalRequest(BaseModel): + """Sign-off recorded against a queued submission. + + ``approved_by`` is an opaque identifier, **not** an authenticated identity: + this service has no login, and access is gated at the network layer. It is + recorded in the job's history so the decision is attributable once a real + identity provider is wired in. + """ + + approved_by: str = Field(min_length=1, max_length=120) + def create_app( *, @@ -37,11 +86,18 @@ def create_app( backend_factory: BackendFactory = BambuLabsBackend, ) -> FastAPI: monitors: dict[str, PrinterMonitor] = {} + # One-slot holder rather than a module global: `create_app` may be called + # more than once in a process (tests do), and each app owns its own store. + stores: dict[str, SubmissionStore] = {} @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: active_settings = settings or load_settings() app.state.settings = active_settings + store = SubmissionStore(active_settings.submissions) + await asyncio.to_thread(store.load) + stores["default"] = store + app.state.submissions = store for definition in active_settings.printers: credentials = resolve_credentials(definition) monitor = PrinterMonitor( @@ -59,6 +115,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: for monitor in reversed(list(monitors.values())): await monitor.stop() monitors.clear() + stores.clear() app = FastAPI( title="AC Bambu Printer Gateway", @@ -75,7 +132,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: CORSMiddleware, allow_origins=configured_origins, allow_credentials=False, - allow_methods=["GET"], + allow_methods=["GET", "POST"], allow_headers=["*"], ) @@ -87,6 +144,12 @@ def get_monitor( raise HTTPException(status_code=404, detail="printer not configured") return monitor + def get_store() -> SubmissionStore: + store = stores.get("default") + if store is None: # pragma: no cover - only outside the app lifespan + raise HTTPException(status_code=503, detail="submission store unavailable") + return store + @app.get("/", response_model=GatewayInfo, tags=["gateway"]) async def gateway_info() -> GatewayInfo: return GatewayInfo( @@ -179,6 +242,155 @@ async def printer_status( ) -> EquipmentStatus: return monitor.status() + @app.get( + "/printers/{printer_id}/profile", + response_model=MachineProfile, + tags=["submissions"], + ) + async def printer_profile( + monitor: Annotated[PrinterMonitor, Depends(get_monitor)], + ) -> MachineProfile: + """The machine a submitter targets, and what a model is checked against. + + Merges the operator-declared profile with what the printer currently + reports. Reads the monitor's cache only. + """ + + return monitor.profile() + + @app.get( + "/printers/{printer_id}/queue", + response_model=QueueView, + tags=["submissions"], + ) + async def printer_queue( + monitor: Annotated[PrinterMonitor, Depends(get_monitor)], + store: Annotated[SubmissionStore, Depends(get_store)], + ) -> QueueView: + """Running job and waiting submissions for one machine, with finish times. + + Side-effect free: it neither polls the printer nor advances the queue. + """ + + return build_queue_view( + machine=monitor.definition.id, + status=monitor.status(), + jobs=store.queue_for(monitor.definition.id), + ) + + @app.post( + "/submissions", + response_model=SubmissionJob, + status_code=201, + tags=["submissions"], + ) + async def create_submission( + store: Annotated[SubmissionStore, Depends(get_store)], + file: Annotated[UploadFile, File(description="A .3mf or .gcode artifact")], + target_machine: Annotated[str, Form(max_length=120)], + requested_by: Annotated[str, Form(max_length=120)], + material: Annotated[str | None, Form(max_length=60)] = None, + ) -> SubmissionJob: + """Accept a print artifact, validate it, and queue it if it passes. + + Performs no printer I/O. Validation runs inline so the caller gets the + per-check verdict in the response; the file read happens on a worker + thread so a large artifact does not stall the status poll loop. + """ + + monitor = monitors.get(target_machine) + if monitor is None: + raise HTTPException(status_code=404, detail="unknown target machine") + + extension = PurePosixPath(file.filename or "").suffix.lower() + kind = ARTIFACT_EXTENSIONS.get(extension) + if kind is None: + supported = ", ".join(sorted(ARTIFACT_EXTENSIONS)) + raise HTTPException( + status_code=400, detail=f"artifact must be one of: {supported}" + ) + + magic = _MAGIC_PREFIXES.get(kind) + if magic is not None: + head = await file.read(len(magic)) + await file.seek(0) + if head != magic: + raise HTTPException( + status_code=400, + detail=f"the uploaded file is not a valid {kind} container", + ) + + async def chunks() -> AsyncIterator[bytes]: + while True: + chunk = await file.read(_UPLOAD_CHUNK_BYTES) + if not chunk: + return + yield chunk + + try: + job = await store.accept( + chunks=chunks(), + extension=extension, + target_machine=target_machine, + requested_by=requested_by, + material=material, + original_filename=file.filename or "", + ) + 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 + + return await run_validation(store, job, monitor.profile()) + + @app.get("/submissions", response_model=list[SubmissionJob], tags=["submissions"]) + async def list_submissions( + store: Annotated[SubmissionStore, Depends(get_store)], + machine: Annotated[str | None, Query(max_length=120)] = None, + state: Annotated[JobState | None, Query()] = None, + limit: Annotated[int, Query(ge=1, le=500)] = 100, + ) -> list[SubmissionJob]: + return store.list(machine=machine, state=state)[:limit] + + @app.get( + "/submissions/{submission_id}", + response_model=SubmissionJob, + tags=["submissions"], + ) + async def read_submission( + store: Annotated[SubmissionStore, Depends(get_store)], + submission_id: Annotated[str, Path(pattern=r"^[0-9a-f]{32}$")], + ) -> SubmissionJob: + job = store.get(submission_id) + if job is None: + raise HTTPException(status_code=404, detail="unknown submission") + return job + + @app.post( + "/submissions/{submission_id}/approve", + response_model=SubmissionJob, + tags=["submissions"], + ) + async def approve_submission( + store: Annotated[SubmissionStore, Depends(get_store)], + submission_id: Annotated[str, Path(pattern=r"^[0-9a-f]{32}$")], + approval: ApprovalRequest, + ) -> SubmissionJob: + """Record sign-off on a queued submission. + + This is the approval gate and nothing more: it moves no hardware, starts + no print, and reaches no printer. It marks the job ``approved`` and sets + ``verdict.dispatch_ready``, which a future dispatch step would require. + """ + + job = store.get(submission_id) + if job is None: + raise HTTPException(status_code=404, detail="unknown submission") + try: + return await store.approve(job, approved_by=approval.approved_by) + except InvalidTransition as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + return app diff --git a/src/bambu_server/monitor.py b/src/bambu_server/monitor.py index faef238..b668e0d 100644 --- a/src/bambu_server/monitor.py +++ b/src/bambu_server/monitor.py @@ -20,6 +20,7 @@ ErrorInfo, MetricValue, ) +from .profiles import MachineProfile, ObservedMachineState, build_profile logger = logging.getLogger(__name__) @@ -135,6 +136,20 @@ def _observed_activity(self, now: datetime) -> Activity: return "idle" return "unknown" + def observed(self) -> ObservedMachineState: + """Machine facts read from the cached telemetry. + + Reads the poll loop's cache only -- a profile or queue request must + never cause printer I/O. + """ + + return ObservedMachineState.from_reading(self._reading) + + def profile(self) -> MachineProfile: + """The submitter-facing machine profile for this printer.""" + + return build_profile(self.definition, self.observed()) + def status(self) -> EquipmentStatus: now = datetime.now(UTC) reading = self._reading @@ -195,6 +210,35 @@ def status(self) -> EquipmentStatus: "light_state": reading.light_state, } ) + # Optional enrichment is only reported when actually observed, never + # as a bare null or an empty sentinel. + advanced: dict[str, object] = { + "print_type": reading.print_type, + "nozzle_type": reading.nozzle_type, + "nozzle_diameter": reading.nozzle_diameter, + "wifi_signal": reading.wifi_signal, + "print_error_code": reading.print_error_code, + "skipped_objects": reading.skipped_objects, + } + for key, value in advanced.items(): + if value is not None and value != []: + details[key] = value + if reading.ams_trays: + details["ams_trays"] = [ + { + "ams_id": tray.ams_id, + "tray_id": tray.tray_id, + "tray_index": tray.tray_index, + "tray_type": tray.tray_type, + "tray_color": tray.tray_color, + "tray_weight": tray.tray_weight, + "tray_diameter": tray.tray_diameter, + "tray_temp": tray.tray_temp, + "nozzle_temp_min": tray.nozzle_temp_min, + "nozzle_temp_max": tray.nozzle_temp_max, + } + for tray in reading.ams_trays + ] return EquipmentStatus( protocol_version=PROTOCOL_VERSION, @@ -230,12 +274,16 @@ def _map_state( if reading.gcode_state in _BUSY_STATES: return "busy", f"Print job is {reading.gcode_state.lower()}", None if reading.gcode_state == "FAILED": + message = "Printer reported a failed print job" + error_code = reading.print_error_code + if error_code: + message += f" (error {error_code})" return ( "error", - "Printer reported a failed print job", + message, ErrorInfo( code="print_failed", - message="Printer reported a failed print job", + message=message, severity="error", timestamp=reading.data_updated_at or now, ), diff --git a/src/bambu_server/profiles.py b/src/bambu_server/profiles.py new file mode 100644 index 0000000..1bf1ee0 --- /dev/null +++ b/src/bambu_server/profiles.py @@ -0,0 +1,169 @@ +"""Machine profiles — the target a remote submitter validates against. + +A profile is the union of two sources with different trust properties: + +* the **operator-declared** half (`MachineProfileConfig` in the local YAML): + bed size, enclosure, safe temperature envelope, forbidden materials. These + are physical facts about the machine that no telemetry field reports. +* the **observed** half, read from the monitor's cached telemetry: the nozzle + actually fitted and the AMS trays actually loaded. + +Where the two overlap (nozzle type and diameter) the observed value wins when +it exists, because it is the current truth, and the declared value is the +fallback for machines whose live field is blank. Every profile records which +source answered, so a reader can tell a measured fact from a declared one. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal, TypeVar + +from pydantic import BaseModel, Field + +from .config import AmsPolicy, MachineLimits, PrinterDefinition + +if TYPE_CHECKING: # pragma: no cover - typing only + from .backend import PrinterReading + +FieldSource = Literal["observed", "declared", "unknown"] + +_T = TypeVar("_T") + + +class LoadedTray(BaseModel): + """One loaded AMS filament tray, as the validator needs it. + + A narrower view than the monitor's telemetry record: only the fields a + compatibility check reads. Tray and tag UUIDs are identifiers, not + inventory, and are never carried here. + """ + + ams_id: int | None = None + tray_id: int | None = None + tray_index: int | None = None + tray_type: str | None = None + tray_color: str | None = None + nozzle_temp_min_c: int | None = None + nozzle_temp_max_c: int | None = None + + @property + def label(self) -> str: + location = f"AMS {self.ams_id} tray {self.tray_id}" + return f"{self.tray_type or 'unknown filament'} ({location})" + + +class ObservedMachineState(BaseModel): + """What the monitor's cached telemetry currently says about the machine.""" + + telemetry_ok: bool = False + nozzle_type: str | None = None + nozzle_diameter_mm: float | None = None + loaded_trays: list[LoadedTray] = Field(default_factory=list) + + @classmethod + def from_reading(cls, reading: PrinterReading | None) -> ObservedMachineState: + if reading is None or not (reading.connected and reading.data_ready): + return cls() + return cls( + telemetry_ok=True, + nozzle_type=reading.nozzle_type, + nozzle_diameter_mm=reading.nozzle_diameter, + loaded_trays=[ + LoadedTray( + ams_id=tray.ams_id, + tray_id=tray.tray_id, + tray_index=tray.tray_index, + tray_type=tray.tray_type, + tray_color=tray.tray_color, + nozzle_temp_min_c=tray.nozzle_temp_min, + nozzle_temp_max_c=tray.nozzle_temp_max, + ) + for tray in (reading.ams_trays or []) + ], + ) + + +class MachineProfile(BaseModel): + """The published, submitter-facing description of one machine.""" + + id: str + name: str + model: str | None = None + enclosure: Literal["enclosed", "open"] | None = None + nozzle_type: str | None = None + nozzle_type_source: FieldSource = "unknown" + nozzle_diameter_mm: float | None = None + nozzle_diameter_source: FieldSource = "unknown" + bed_size_mm: tuple[float, float] | None = None + chamber_temperature_c: float | None = None + limits: MachineLimits = Field(default_factory=MachineLimits) + ams: AmsPolicy = Field(default_factory=AmsPolicy) + observed: ObservedMachineState = Field(default_factory=ObservedMachineState) + warnings: list[str] = Field(default_factory=list) + + @property + def has_chamber_control(self) -> bool: + """True when the operator declared a chamber temperature for this machine. + + An open-frame P1S declares none; an H2D does. A submitted model that + asks for a heated chamber cannot run on a machine without one. + """ + + return self.chamber_temperature_c is not None + + +def _resolve(observed: _T | None, declared: _T | None) -> tuple[_T | None, FieldSource]: + """Prefer what the printer reports, fall back to what the operator declared.""" + + if observed is not None: + return observed, "observed" + if declared is not None: + return declared, "declared" + return None, "unknown" + + +def build_profile( + definition: PrinterDefinition, + observed: ObservedMachineState, +) -> MachineProfile: + declared = definition.profile + nozzle_type, nozzle_type_source = _resolve(observed.nozzle_type, declared.nozzle_type) + diameter, diameter_source = _resolve( + observed.nozzle_diameter_mm, declared.nozzle_diameter_mm + ) + + warnings: list[str] = [] + if ( + observed.nozzle_diameter_mm is not None + and declared.nozzle_diameter_mm is not None + and abs(observed.nozzle_diameter_mm - declared.nozzle_diameter_mm) > 1e-6 + ): + # Worth surfacing rather than silently preferring one: it means the + # declared profile no longer describes the hardware, and every + # submission validated against it inherits the discrepancy. + warnings.append( + "declared nozzle diameter " + f"{declared.nozzle_diameter_mm} mm does not match the observed " + f"{observed.nozzle_diameter_mm} mm; the observed value is used" + ) + if declared.bed_size_mm is None: + warnings.append("bed_size_mm is not declared; plate fit cannot be checked") + if not observed.telemetry_ok: + warnings.append("printer telemetry unavailable; observed fields are omitted") + + return MachineProfile( + id=definition.id, + name=definition.name, + model=definition.model, + enclosure=declared.enclosure, + nozzle_type=nozzle_type, + nozzle_type_source=nozzle_type_source, + nozzle_diameter_mm=diameter, + nozzle_diameter_source=diameter_source, + bed_size_mm=declared.bed_size_mm, + chamber_temperature_c=declared.chamber_temperature_c, + limits=declared.limits, + ams=declared.ams, + observed=observed, + warnings=warnings, + ) diff --git a/src/bambu_server/queueing.py b/src/bambu_server/queueing.py new file mode 100644 index 0000000..0a5dd0e --- /dev/null +++ b/src/bambu_server/queueing.py @@ -0,0 +1,131 @@ +"""Per-machine queue view and expected finish times. + +The queue is gateway-owned, which is what makes the estimate honest: the +running job's remaining time comes from the printer's own telemetry, and each +waiting job's duration comes from the slicer's estimate embedded in the +submitted artifact. Nothing here guesses. + +Building this view reads cached telemetry and the submission store only. It +performs no printer I/O and mutates no job, so a dashboard may poll it freely. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from pydantic import BaseModel, Field + +from .models import Activity, EquipmentStatus +from .submissions import JobState, SubmissionJob + + +class RunningJobView(BaseModel): + """The job the printer says it is running. + + It is not correlated with a submission: this service never dispatches, so a + running print was started by some other route to the printer (Bambu Studio, + the handset, the cloud) and the gateway can only report what it observes. + """ + + job_name: str | None = None + progress_percent: float | None = None + remaining_time_minutes: float | None = None + expected_end: datetime | None = None + + +class QueuedJobView(BaseModel): + submission_id: str + position: int + state: JobState + job_name: str + requested_by: str + material: str | None = None + approved: bool = False + estimated_duration_minutes: float | None = None + expected_end: datetime | None = None + + +class QueueView(BaseModel): + machine: str + generated_at: datetime + activity: Activity + running: RunningJobView | None = None + queued: list[QueuedJobView] = Field(default_factory=list) + #: False when any expected finish time could not be computed -- an unknown + #: remaining time on the running job, or a queued job whose artifact carries + #: no duration estimate. Everything after the first unknown is ``null``. + estimates_complete: bool = True + + +def _metric(status: EquipmentStatus, name: str) -> float | None: + metric = status.metrics.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) + + +def build_queue_view( + *, + machine: str, + status: EquipmentStatus, + jobs: list[SubmissionJob], + now: datetime | None = None, +) -> QueueView: + now = now or datetime.now(UTC) + + running: RunningJobView | None = None + # `cursor` is the instant the printer becomes free. It stays None while + # that instant is unknowable, which makes every downstream estimate null + # rather than wrong. + cursor: datetime | None = now + estimates_complete = True + + if status.activity == "running": + remaining = _metric(status, "remaining_time") + job_name = status.details.get("job_name") + expected_end = now + timedelta(minutes=remaining) if remaining is not None else None + running = RunningJobView( + job_name=job_name if isinstance(job_name, str) else None, + progress_percent=_metric(status, "print_progress"), + remaining_time_minutes=remaining, + expected_end=expected_end, + ) + cursor = expected_end + if expected_end is None: + estimates_complete = False + + queued: list[QueuedJobView] = [] + for position, job in enumerate(jobs, start=1): + duration = job.estimated_duration_minutes + if cursor is not None and duration is not None: + cursor = cursor + timedelta(minutes=duration) + expected_end = cursor + else: + expected_end = None + cursor = None + estimates_complete = False + queued.append( + QueuedJobView( + submission_id=job.submission_id, + position=position, + state=job.state, + job_name=job.original_filename, + requested_by=job.requested_by, + material=job.material, + approved=job.approved_at is not None, + estimated_duration_minutes=duration, + expected_end=expected_end, + ) + ) + + return QueueView( + machine=machine, + generated_at=now, + activity=status.activity, + running=running, + queued=queued, + estimates_complete=estimates_complete, + ) diff --git a/src/bambu_server/submissions.py b/src/bambu_server/submissions.py new file mode 100644 index 0000000..652fe88 --- /dev/null +++ b/src/bambu_server/submissions.py @@ -0,0 +1,492 @@ +"""Submission intake, job state machine, and durable job store. + +A *submission* is a print artifact plus the metadata naming the machine it is +destined for. This module owns its whole life up to -- and deliberately not +including -- dispatch: + +``submitted -> validating -> validated -> queued -> approved`` + +with ``rejected`` as the terminal outcome of a failed validation and ``failed`` +reachable from anything that is not already rejected. The three states past +approval (``dispatching``, ``running``, ``finished``) are declared because the +contract declares them, but **nothing in this service can enter them**: dispatch +is the one printer-touching step and it stays behind the approval gate until the +control-plane design is approved. See :func:`dispatch`. + +Nothing in this module performs printer I/O. Files land on the gateway host +under a configured directory; their paths are internal and never leave the +process, because a stored path is not something a client has any use for. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import logging +import os +import re +import uuid +from collections.abc import AsyncIterator +from datetime import UTC, datetime +from pathlib import Path +from typing import Literal, NoReturn + +from pydantic import BaseModel, Field, field_validator + +from .artifacts import ( + ARTIFACT_EXTENSIONS, + ArtifactError, + ArtifactFacts, + ArtifactKind, + inspect_artifact, +) +from .config import SubmissionSettings +from .profiles import MachineProfile +from .validation import CheckResult, ValidationVerdict, validate_model + +logger = logging.getLogger(__name__) + +JobState = Literal[ + "submitted", + "validating", + "validated", + "queued", + "approved", + "dispatching", + "running", + "finished", + "failed", + "rejected", +] + +#: Which states are waiting in a machine's queue. ``approved`` stays queued +#: because approval alone moves nothing -- only dispatch does. +QUEUED_STATES: frozenset[str] = frozenset({"queued", "approved"}) + +ALLOWED_TRANSITIONS: dict[str, frozenset[str]] = { + "submitted": frozenset({"validating", "failed"}), + "validating": frozenset({"validated", "rejected", "failed"}), + "validated": frozenset({"queued", "failed"}), + "queued": frozenset({"approved", "failed"}), + "approved": frozenset({"dispatching", "failed"}), + "dispatching": frozenset({"running", "failed"}), + "running": frozenset({"finished", "failed"}), + # Terminal. `failed` is deliberately terminal too: the contract sends it to + # human reconciliation, and an automated drain back into the queue is + # exactly the silent-recovery behaviour the lab rules forbid. + "finished": frozenset(), + "failed": frozenset(), + "rejected": frozenset(), +} + +_CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f]") + + +class SubmissionError(ValueError): + """The submission cannot be accepted as presented.""" + + +class ArtifactTooLarge(SubmissionError): + """The upload exceeded the configured size ceiling.""" + + +class InvalidTransition(RuntimeError): + """A state change the job's state machine does not permit.""" + + +class DispatchUnavailable(RuntimeError): + """Dispatch is not implemented in this service.""" + + +def clean_text(value: str | None, *, field: str, required: bool) -> str | None: + """Normalise a client-supplied text field, or refuse it. + + Called before the upload is written, so a field that cannot be accepted + fails the request instead of leaving an artifact on disk with no record + pointing at it. + """ + + if value is None: + if required: + raise SubmissionError(f"{field} is required") + return None + cleaned = _CONTROL_CHARS.sub("", value).strip() + if not cleaned: + if required: + raise SubmissionError(f"{field} must not be blank") + return None + return cleaned + + +def safe_display_name(name: str) -> str: + """Reduce a client-supplied filename to something safe to echo back. + + Only the basename survives, control characters are stripped, and the result + is capped. It is display metadata: it is never joined to a path, and the + stored artifact is named from the submission's UUID instead. + """ + + base = os.path.basename((name or "").replace("\\", "/")).strip() + base = _CONTROL_CHARS.sub("", base) + return base[:200] or "submission" + + +class StateTransition(BaseModel): + at: datetime + from_state: JobState | None = None + to_state: JobState + note: str | None = None + + +class SubmissionJob(BaseModel): + """One submitted print job. Safe to return to a client verbatim.""" + + submission_id: str + target_machine: str + requested_by: str = Field(min_length=1, max_length=120) + material: str | None = Field(default=None, max_length=60) + original_filename: str + artifact_kind: ArtifactKind + byte_size: int + sha256: str + state: JobState + created_at: datetime + updated_at: datetime + approved_by: str | None = None + approved_at: datetime | None = None + estimated_duration_minutes: float | None = None + facts: ArtifactFacts | None = None + verdict: ValidationVerdict | None = None + history: list[StateTransition] = Field(default_factory=list) + + @field_validator("requested_by", "material") + @classmethod + def clean_text(cls, value: str | None) -> str | None: + if value is None: + return None + cleaned = _CONTROL_CHARS.sub("", value).strip() + if not cleaned: + raise ValueError("must not be blank") + return cleaned + + +class SubmissionStore: + """Durable, in-process store for submissions. + + Jobs are held in memory and mirrored to one JSON file each, so a service + restart does not silently empty a machine's queue. Every mutation goes + through one lock: intake writes and state changes are concurrent by nature + (an upload and an approval can overlap) and the queue's order must not + depend on which one won a race. + """ + + def __init__(self, settings: SubmissionSettings) -> None: + self._settings = settings + self._root = settings.directory + self._jobs: dict[str, SubmissionJob] = {} + self._lock = asyncio.Lock() + + @property + def root(self) -> Path: + return self._root + + @property + def settings(self) -> SubmissionSettings: + return self._settings + + def load(self) -> None: + """Read persisted jobs from disk. Called once at startup.""" + + self._root.mkdir(parents=True, exist_ok=True) + for path in sorted(self._root.glob("*.json")): + try: + job = SubmissionJob.model_validate_json(path.read_text(encoding="utf-8")) + except Exception: + # One unreadable record must not stop the service from serving + # the rest of the queue. + logger.warning("Ignoring unreadable submission record %s", path.name) + continue + self._jobs[job.submission_id] = job + logger.info("Loaded %d submission(s) from %s", len(self._jobs), self._root) + + # -- reads ------------------------------------------------------------- + + def get(self, submission_id: str) -> SubmissionJob | None: + return self._jobs.get(submission_id) + + def list( + self, + *, + machine: str | None = None, + state: str | None = None, + ) -> list[SubmissionJob]: + jobs = [ + job + for job in self._jobs.values() + if (machine is None or job.target_machine == machine) + and (state is None or job.state == state) + ] + return sorted(jobs, key=lambda job: (job.created_at, job.submission_id)) + + def queue_for(self, machine: str) -> list[SubmissionJob]: + """Jobs waiting on one machine, in the order they were submitted.""" + + return [job for job in self.list(machine=machine) if job.state in QUEUED_STATES] + + def artifact_path(self, job: SubmissionJob) -> Path: + """Internal only -- never serialise this into a response.""" + + return self._root / f"{job.submission_id}.{job.artifact_kind}" + + # -- writes ------------------------------------------------------------ + + async def accept( + self, + *, + chunks: AsyncIterator[bytes], + extension: str, + target_machine: str, + requested_by: str, + material: str | None, + original_filename: str, + ) -> SubmissionJob: + """Persist an uploaded artifact and register it as a ``submitted`` job.""" + + kind = ARTIFACT_EXTENSIONS.get(extension.lower()) + if kind is None: + raise SubmissionError(f"unsupported artifact extension {extension!r}") + owner = clean_text(requested_by, field="requested_by", required=True) + filament = clean_text(material, field="material", required=False) + + self._root.mkdir(parents=True, exist_ok=True) + submission_id = uuid.uuid4().hex + target = self._root / f"{submission_id}.{kind}" + partial = self._root / f"{submission_id}.part" + + digest = hashlib.sha256() + size = 0 + try: + handle = await asyncio.to_thread(partial.open, "wb") + try: + async for chunk in chunks: + if not chunk: + continue + size += len(chunk) + if size > self._settings.max_file_bytes: + raise ArtifactTooLarge( + f"artifact exceeds the {self._settings.max_file_bytes} byte limit" + ) + digest.update(chunk) + await asyncio.to_thread(handle.write, chunk) + finally: + await asyncio.to_thread(handle.close) + if size == 0: + raise SubmissionError("the uploaded artifact is empty") + await asyncio.to_thread(partial.replace, target) + except BaseException: + partial.unlink(missing_ok=True) + raise + + now = datetime.now(UTC) + job = SubmissionJob( + submission_id=submission_id, + target_machine=target_machine, + requested_by=owner, # type: ignore[arg-type] + material=filament, + original_filename=safe_display_name(original_filename), + artifact_kind=kind, + byte_size=size, + sha256=digest.hexdigest(), + state="submitted", + created_at=now, + updated_at=now, + history=[StateTransition(at=now, from_state=None, to_state="submitted")], + ) + async with self._lock: + self._jobs[submission_id] = job + await self._persist(job) + return job + + async def transition( + self, job: SubmissionJob, to_state: JobState, *, note: str | None = None + ) -> SubmissionJob: + async with self._lock: + return await self._transition_locked(job.submission_id, to_state, note) + + async def record_validation( + self, + job: SubmissionJob, + facts: ArtifactFacts | None, + verdict: ValidationVerdict, + ) -> SubmissionJob: + async with self._lock: + current = self._require(job.submission_id) + updated = current.model_copy( + update={ + "facts": facts, + "verdict": verdict, + "estimated_duration_minutes": ( + facts.estimated_duration_minutes if facts else None + ), + "updated_at": datetime.now(UTC), + } + ) + self._jobs[updated.submission_id] = updated + await self._persist(updated) + return updated + + async def approve(self, job: SubmissionJob, *, approved_by: str) -> SubmissionJob: + """Record the human sign-off that gates dispatch. + + Approval is a *record*, not an action: it moves no hardware and starts + nothing. It flips ``verdict.dispatch_ready``, which is the flag a future + dispatch step would require. + """ + + async with self._lock: + current = self._require(job.submission_id) + if current.state != "queued": + raise InvalidTransition( + f"only a queued submission can be approved; {current.submission_id} " + f"is {current.state}" + ) + if current.verdict is None or current.verdict.verdict != "pass": + raise InvalidTransition( + "a submission that did not pass validation cannot be approved" + ) + now = datetime.now(UTC) + verdict = current.verdict.model_copy(update={"dispatch_ready": True}) + updated = current.model_copy( + update={ + "verdict": verdict, + "approved_by": _CONTROL_CHARS.sub("", approved_by).strip()[:120], + "approved_at": now, + } + ) + self._jobs[updated.submission_id] = updated + return await self._transition_locked( + updated.submission_id, "approved", f"approved by {updated.approved_by}" + ) + + # -- internals --------------------------------------------------------- + + def _require(self, submission_id: str) -> SubmissionJob: + job = self._jobs.get(submission_id) + if job is None: + raise InvalidTransition(f"unknown submission {submission_id}") + return job + + async def _transition_locked( + self, submission_id: str, to_state: JobState, note: str | None + ) -> SubmissionJob: + current = self._require(submission_id) + if to_state not in ALLOWED_TRANSITIONS[current.state]: + raise InvalidTransition( + f"{submission_id} cannot move from {current.state} to {to_state}" + ) + now = datetime.now(UTC) + updated = current.model_copy( + update={ + "state": to_state, + "updated_at": now, + "history": [ + *current.history, + StateTransition( + at=now, from_state=current.state, to_state=to_state, note=note + ), + ], + } + ) + self._jobs[submission_id] = updated + await self._persist(updated) + return updated + + async def _persist(self, job: SubmissionJob) -> None: + payload = job.model_dump_json(indent=2) + target = self._root / f"{job.submission_id}.json" + temporary = self._root / f"{job.submission_id}.json.tmp" + + def write() -> None: + temporary.write_text(payload, encoding="utf-8") + temporary.replace(target) + + await asyncio.to_thread(write) + + +async def run_validation( + store: SubmissionStore, + job: SubmissionJob, + profile: MachineProfile, +) -> SubmissionJob: + """Validate one submitted job against its target machine's profile. + + Inspection runs in a worker thread because it reads a potentially large file + and the event loop is also serving status polls. A submission that passes is + enqueued for its machine; one that fails is rejected with the failing checks + recorded, which is a terminal outcome. + """ + + job = await store.transition(store.get(job.submission_id) or job, "validating") + path = store.artifact_path(job) + try: + facts = await asyncio.to_thread( + inspect_artifact, path, scan_max_bytes=store.settings.scan_max_bytes + ) + except Exception as exc: + # `ArtifactError` messages are written here and carry no path, so they + # are safe to relay and are the useful half of the answer. Anything else + # is reported by exception type only: an unexpected error can quote the + # stored path, which the client never named and has no use for. + reason = ( + str(exc) if isinstance(exc, ArtifactError) else type(exc).__name__ + ) + logger.warning( + "Artifact inspection failed for %s (%s)", job.submission_id, type(exc).__name__ + ) + verdict = ValidationVerdict( + submission_id=job.submission_id, + verdict="reject", + checks=[ + CheckResult( + check="artifact_readable", + status="fail", + ok=False, + detail=f"the submitted {job.artifact_kind} could not be read: {reason}", + ) + ], + reasons=["artifact_readable"], + machine=profile.id, + ) + job = await store.record_validation(job, None, verdict) + return await store.transition(job, "rejected", note="artifact_readable") + + verdict = validate_model( + submission_id=job.submission_id, + facts=facts, + profile=profile, + requested_material=job.material, + ) + job = await store.record_validation(job, facts, verdict) + if verdict.verdict == "reject": + return await store.transition(job, "rejected", note=", ".join(verdict.reasons)) + job = await store.transition(job, "validated") + return await store.transition(job, "queued") + + +async def dispatch(job: SubmissionJob) -> NoReturn: + """The gated step. Not implemented, and deliberately unreachable. + + Dispatching means uploading the artifact to a printer and starting a print: + the single printer-touching action in the whole pipeline. Shipping it + requires the approved control-plane design (``docs/CONTROL_PLANE_DESIGN.md``) + -- the v1.1 claim protocol, per-action preconditions with structured 412 + refusals, and the audited approval model -- none of which exists yet. + + No HTTP route calls this function. It exists so the boundary has a name and + a test, not as a switch waiting to be flipped. + """ + + raise DispatchUnavailable( + f"dispatch is not implemented: submission {job.submission_id} stops at the " + "approval gate until the control-plane design ships" + ) diff --git a/src/bambu_server/validation.py b/src/bambu_server/validation.py new file mode 100644 index 0000000..7f88be8 --- /dev/null +++ b/src/bambu_server/validation.py @@ -0,0 +1,422 @@ +"""Model validation — does this artifact belong on *this* machine? + +Pure analysis. Given the observations in :class:`~bambu_server.artifacts.ArtifactFacts` +and the target's :class:`~bambu_server.profiles.MachineProfile`, produce a +per-check verdict. Nothing here reads a file, opens a socket, or touches a +printer, which is what makes it safe to run on submission and re-run later. + +Three outcomes exist per check, and the distinction is the whole point: + +``pass`` + The check ran and the model satisfied it. +``fail`` + The check ran and the model did not. One failing check rejects the + submission. +``not_applicable`` + The check could not run because an input does not exist -- no AMS tray + inventory, no declared bed size, no temperature limits. It is recorded with + the reason and does **not** count as a pass. A silent pass on missing data + is the failure mode this shape exists to prevent. + +This module is the natural body of a ``lab_skills`` skill (``bambu.validate_model``): +it takes an artifact and a machine profile and returns a verdict, so wrapping it +as a skill later needs no change to the checks themselves. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Literal + +from pydantic import BaseModel, Field + +from .artifacts import ArtifactFacts +from .config import TemperatureBand +from .profiles import LoadedTray, MachineProfile + +CheckStatus = Literal["pass", "fail", "not_applicable"] + +CHECK_ORDER = ( + "machine_compatible", + "material_allowed", + "material_filament_match", + "nozzle_temp_in_band", + "bed_chamber_temp_in_band", + "build_fits_plate", + "gcode_sanity", + "params_present", +) + + +class CheckResult(BaseModel): + check: str + status: CheckStatus + #: ``False`` only when this check blocks dispatch. A ``not_applicable`` + #: check is not "ok" in the sense of "verified" -- it is "not blocking". + ok: bool + detail: str + + +class ValidationVerdict(BaseModel): + submission_id: str + verdict: Literal["pass", "reject"] + checks: list[CheckResult] = Field(default_factory=list) + reasons: list[str] = Field(default_factory=list) + #: True only after a full pass **and** the approval gate. Validation alone + #: never sets it; approval does. + dispatch_ready: bool = False + evaluated_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + machine: str | None = None + + +def _passed(check: str, detail: str) -> CheckResult: + return CheckResult(check=check, status="pass", ok=True, detail=detail) + + +def _failed(check: str, detail: str) -> CheckResult: + return CheckResult(check=check, status="fail", ok=False, detail=detail) + + +def _skipped(check: str, detail: str) -> CheckResult: + return CheckResult(check=check, status="not_applicable", ok=True, detail=detail) + + +def _models_agree(declared: str, observed: str) -> bool: + """Compare loosely: ``P1S`` and ``Bambu Lab P1S`` name the same machine.""" + + left = declared.strip().lower() + right = observed.strip().lower() + return left in right or right in left + + +def _band_text(band: TemperatureBand) -> str: + return f"{band.min_c:g}-{band.max_c:g} C" + + +def _check_machine_compatible(facts: ArtifactFacts, profile: MachineProfile) -> CheckResult: + problems: list[str] = [] + evidence: list[str] = [] + + if profile.model and facts.printer_model: + if _models_agree(profile.model, facts.printer_model): + evidence.append(f"sliced for {facts.printer_model}") + else: + problems.append( + f"sliced for {facts.printer_model!r} but this machine is " + f"{profile.model!r}" + ) + + if profile.nozzle_diameter_mm is not None and facts.nozzle_diameter_mm is not None: + if abs(profile.nozzle_diameter_mm - facts.nozzle_diameter_mm) > 1e-6: + problems.append( + f"model needs a {facts.nozzle_diameter_mm:g} mm nozzle; this machine " + f"has {profile.nozzle_diameter_mm:g} mm " + f"({profile.nozzle_diameter_source})" + ) + else: + evidence.append(f"{facts.nozzle_diameter_mm:g} mm nozzle") + + if profile.nozzle_type and facts.nozzle_type: + if profile.nozzle_type.strip().lower() != facts.nozzle_type.strip().lower(): + problems.append( + f"model expects a {facts.nozzle_type} nozzle; this machine has " + f"{profile.nozzle_type} ({profile.nozzle_type_source})" + ) + else: + evidence.append(f"{facts.nozzle_type} nozzle") + + if problems: + return _failed("machine_compatible", "; ".join(problems)) + if evidence: + return _passed("machine_compatible", "; ".join(evidence)) + return _skipped( + "machine_compatible", + "the model declares no printer or nozzle configuration to compare against", + ) + + +def _check_material_allowed( + materials: tuple[str, ...], + requested_material: str | None, + profile: MachineProfile, +) -> CheckResult: + if not materials: + return _skipped( + "material_allowed", "no filament type is declared by the model or the request" + ) + + forbidden = [item for item in materials if item in profile.ams.filament_forbidden] + if forbidden: + return _failed( + "material_allowed", + f"{', '.join(forbidden)} is not runnable on {profile.id}", + ) + + if requested_material: + declared = requested_material.strip().upper() + if declared and declared not in materials: + return _failed( + "material_allowed", + f"the request declares {declared} but the model is sliced for " + f"{', '.join(materials)}", + ) + + return _passed("material_allowed", f"{', '.join(materials)} is permitted on {profile.id}") + + +def _matching_trays(materials: tuple[str, ...], trays: list[LoadedTray]) -> list[LoadedTray]: + return [ + tray + for tray in trays + if tray.tray_type and tray.tray_type.strip().upper() in materials + ] + + +def _check_material_filament_match( + materials: tuple[str, ...], + trays: list[LoadedTray], + matched: list[LoadedTray], +) -> CheckResult: + if not trays: + # STATUS_SPEC-conformant telemetry does not always carry AMS trays, and + # both live printers currently report none. Recorded, never assumed. + return _skipped( + "material_filament_match", + "no AMS tray inventory is reported by this printer, so the loaded " + "filament could not be compared", + ) + if not materials: + return _skipped( + "material_filament_match", "the model declares no filament type to match" + ) + if matched: + return _passed( + "material_filament_match", + f"{matched[0].label} matches the model's {', '.join(materials)}", + ) + loaded = ", ".join(sorted({tray.tray_type or "unknown" for tray in trays})) + return _failed( + "material_filament_match", + f"the model needs {', '.join(materials)} but the loaded trays hold {loaded}", + ) + + +def _check_nozzle_temp_in_band( + facts: ArtifactFacts, profile: MachineProfile, matched: list[LoadedTray] +) -> CheckResult: + temperatures = [ + ("configured", facts.nozzle_temperature_c), + ("commanded", facts.commanded_nozzle_temperature_c), + ] + observed = [(label, value) for label, value in temperatures if value is not None] + if not observed: + return _skipped( + "nozzle_temp_in_band", "the model declares no nozzle temperature" + ) + + problems: list[str] = [] + bands: list[str] = [] + + machine_band = profile.limits.nozzle_temperature_c + if machine_band is not None: + bands.append(f"machine {_band_text(machine_band)}") + for label, value in observed: + if not machine_band.contains(value): + problems.append( + f"{label} {value:g} C is outside the machine's " + f"{_band_text(machine_band)}" + ) + + tray = next( + ( + item + for item in matched + if item.nozzle_temp_min_c is not None + and item.nozzle_temp_max_c is not None + # A spool tag can report nonsense; comparing against an inverted + # window would reject every temperature, so it is ignored instead. + and item.nozzle_temp_max_c >= item.nozzle_temp_min_c + ), + None, + ) + if tray is not None: + low = float(tray.nozzle_temp_min_c) # type: ignore[arg-type] + high = float(tray.nozzle_temp_max_c) # type: ignore[arg-type] + window = f"{low:g}-{high:g} C" + bands.append(f"{tray.label} {window}") + for label, value in observed: + if not low <= value <= high: + problems.append( + f"{label} {value:g} C is outside {tray.label}'s {window}" + ) + + if problems: + return _failed("nozzle_temp_in_band", "; ".join(problems)) + if not bands: + return _skipped( + "nozzle_temp_in_band", + "neither a machine nozzle limit nor a loaded filament range is known, " + "so the nozzle temperature could not be bounded", + ) + summary = ", ".join(f"{label} {value:g} C" for label, value in observed) + return _passed("nozzle_temp_in_band", f"{summary} within {', '.join(bands)}") + + +def _check_bed_chamber_temp_in_band( + facts: ArtifactFacts, profile: MachineProfile +) -> CheckResult: + problems: list[str] = [] + evidence: list[str] = [] + + bed_band = profile.limits.bed_temperature_c + bed_values = [ + ("configured", facts.bed_temperature_c), + ("commanded", facts.commanded_bed_temperature_c), + ] + if bed_band is not None: + for label, value in bed_values: + if value is None: + continue + if bed_band.contains(value): + evidence.append(f"bed {label} {value:g} C within {_band_text(bed_band)}") + else: + problems.append( + f"bed {label} {value:g} C is outside {_band_text(bed_band)}" + ) + + chamber = facts.chamber_temperature_c + if chamber is not None and chamber > 0: + if not profile.has_chamber_control: + problems.append( + f"the model asks for a {chamber:g} C chamber but {profile.id} has no " + "chamber temperature control" + ) + else: + chamber_band = profile.limits.chamber_temperature_c + if chamber_band is None: + evidence.append( + f"chamber {chamber:g} C requested; no machine chamber limit declared" + ) + elif chamber_band.contains(chamber): + evidence.append( + f"chamber {chamber:g} C within {_band_text(chamber_band)}" + ) + else: + problems.append( + f"chamber {chamber:g} C is outside {_band_text(chamber_band)}" + ) + + if problems: + return _failed("bed_chamber_temp_in_band", "; ".join(problems)) + if evidence: + return _passed("bed_chamber_temp_in_band", "; ".join(evidence)) + return _skipped( + "bed_chamber_temp_in_band", + "no bed or chamber limit is declared for this machine, or the model " + "declares no bed temperature", + ) + + +def _check_build_fits_plate(facts: ArtifactFacts, profile: MachineProfile) -> CheckResult: + if profile.bed_size_mm is None: + return _skipped( + "build_fits_plate", f"bed_size_mm is not declared for {profile.id}" + ) + if facts.extent_mm is None: + reason = "; ".join(facts.notes) or "the artifact reports no plate footprint" + return _skipped("build_fits_plate", reason) + + width, depth, height = facts.extent_mm + bed_x, bed_y = profile.bed_size_mm + if width <= bed_x and depth <= bed_y: + return _passed( + "build_fits_plate", + f"footprint {width:g} x {depth:g} mm fits the {bed_x:g} x {bed_y:g} mm plate " + f"(height {height:g} mm)", + ) + + detail = ( + f"footprint {width:g} x {depth:g} mm exceeds the {bed_x:g} x {bed_y:g} mm plate" + ) + if width <= bed_y and depth <= bed_x: + # Worth saying: the fix is a re-slice with the plate rotated, not a + # different machine. + detail += "; it would fit rotated 90 degrees" + return _failed("build_fits_plate", detail) + + +def _check_gcode_sanity(facts: ArtifactFacts) -> CheckResult: + if not facts.sliced: + return _skipped( + "gcode_sanity", + "no toolpath was found to scan; see params_present", + ) + if facts.gcode_findings: + detail = "; ".join(finding.detail for finding in facts.gcode_findings) + return _failed("gcode_sanity", detail) + caveat = ( + "heuristic scan of the leading section only (the artifact exceeded the " + "scan budget); this is not a proof of safety" + if facts.scan_truncated + else "heuristic scan found no refused commands; this is not a proof of safety" + ) + return _passed("gcode_sanity", caveat) + + +def _check_params_present(facts: ArtifactFacts, materials: tuple[str, ...]) -> CheckResult: + missing: list[str] = [] + if facts.kind == "3mf" and not facts.sliced: + missing.append("an embedded sliced plate (the printer cannot run a project file)") + if not materials: + missing.append("filament type") + if facts.nozzle_temperature_c is None and facts.commanded_nozzle_temperature_c is None: + missing.append("nozzle temperature") + if facts.bed_temperature_c is None and facts.commanded_bed_temperature_c is None: + missing.append("bed temperature") + + if missing: + return _failed("params_present", f"missing {', '.join(missing)}") + return _passed( + "params_present", "filament type, nozzle temperature and bed temperature are declared" + ) + + +def validate_model( + *, + submission_id: str, + facts: ArtifactFacts, + profile: MachineProfile, + requested_material: str | None = None, +) -> ValidationVerdict: + """Run every check and return the machine-readable verdict.""" + + materials = facts.filament_types + if not materials and requested_material: + materials = (requested_material.strip().upper(),) + + trays = list(profile.observed.loaded_trays) + matched = _matching_trays(materials, trays) + + checks = [ + _check_machine_compatible(facts, profile), + _check_material_allowed(materials, requested_material, profile), + _check_material_filament_match(materials, trays, matched), + _check_nozzle_temp_in_band(facts, profile, matched), + _check_bed_chamber_temp_in_band(facts, profile), + _check_build_fits_plate(facts, profile), + _check_gcode_sanity(facts), + _check_params_present(facts, materials), + ] + # The declared order is part of the contract: readers render it as a + # checklist and the order should not drift with the code. + checks.sort(key=lambda result: CHECK_ORDER.index(result.check)) + + reasons = [result.check for result in checks if result.status == "fail"] + return ValidationVerdict( + submission_id=submission_id, + verdict="reject" if reasons else "pass", + checks=checks, + reasons=reasons, + dispatch_ready=False, + machine=profile.id, + ) diff --git a/tests/conftest.py b/tests/conftest.py index 608462e..39d560b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,14 +1,104 @@ from __future__ import annotations +import json +import zipfile from datetime import UTC, datetime +from pathlib import Path import pytest from fastapi.testclient import TestClient -from bambu_server.backend import PrinterReading +from bambu_server.backend import AmsTrayReading, PrinterReading from bambu_server.config import Settings from bambu_server.main import create_app +# A Bambu-shaped slicer header followed by a short toolpath. The motion spans +# X 10..110 and Y 10..60, so its footprint is 100 x 50 mm -- small enough to fit +# the test machine's 256 x 256 plate and large enough that a bed-size check has +# something real to measure. +SAMPLE_GCODE = """; HEADER_BLOCK_START +; BambuStudio 01.09.00.70 +; model printing time: 1h 2m 3s; total estimated time: 1h 10m 0s +; HEADER_BLOCK_END + +; CONFIG_BLOCK_START +; curr_bed_type = Textured PEI Plate +; textured_plate_temp = 55 +; hot_plate_temp = 60 +; chamber_temperature = 0 +; filament_type = PLA +; layer_height = 0.2 +; nozzle_diameter = 0.4 +; nozzle_temperature = 220 +; nozzle_type = hardened_steel +; printer_model = Bambu Lab X1 Carbon +; CONFIG_BLOCK_END + +G90 +M140 S55 +M104 S220 +G1 X10 Y10 Z0.2 F3000 +G1 X110 Y60 E5.0 +G1 X10 Y10 E7.5 +M104 S0 +""" + +SLICE_INFO_XML = """ + + + + + + + + +""" + + +def write_gcode(path: Path, body: str = SAMPLE_GCODE) -> Path: + path.write_text(body, encoding="utf-8") + return path + + +def write_3mf( + path: Path, + *, + plate_gcode: str | None = SAMPLE_GCODE, + project_settings: dict[str, object] | None = None, + slice_info: str | None = SLICE_INFO_XML, +) -> Path: + """Build a minimal Bambu-shaped 3mf container. + + ``plate_gcode=None`` produces an *unsliced* project file -- the shape a user + gets from "save project" rather than "export plate sliced file", which no + printer can run. + """ + + settings = { + "printer_model": "Bambu Lab X1 Carbon", + "nozzle_diameter": ["0.4"], + "nozzle_temperature": ["220"], + "curr_bed_type": "Textured PEI Plate", + "textured_plate_temp": ["55"], + "chamber_temperature": ["0"], + "filament_type": ["PLA"], + "layer_height": "0.2", + } + if project_settings is not None: + settings.update(project_settings) + + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr( + "3D/3dmodel.model", + '', + ) + archive.writestr("Metadata/project_settings.config", json.dumps(settings)) + if slice_info is not None: + archive.writestr("Metadata/slice_info.config", slice_info) + if plate_gcode is not None: + archive.writestr("Metadata/plate_1.gcode", plate_gcode) + return path + class FakeBackend: def __init__(self, reading: PrinterReading) -> None: @@ -47,11 +137,31 @@ def reading() -> PrinterReading: light_state="on", job_name="test_part.3mf", firmware_version="01.08.00.00", + nozzle_type="hardened_steel", + nozzle_diameter=0.4, + print_type="local", + wifi_signal="-42", + print_error_code=0, + skipped_objects=[3], + ams_trays=[ + AmsTrayReading( + ams_id=0, + tray_id=1, + tray_index=1, + tray_type="PLA", + tray_color="#FF0000", + tray_weight="1000", + tray_diameter="1.75", + tray_temp="220", + nozzle_temp_min=190, + nozzle_temp_max=240, + ) + ], ) @pytest.fixture -def settings(monkeypatch: pytest.MonkeyPatch) -> Settings: +def settings(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Settings: prefix = "BAMBU_TEST_01" monkeypatch.setenv(f"{prefix}_HOST", "printer.invalid") monkeypatch.setenv(f"{prefix}_ACCESS_CODE", "secret-access-code") @@ -60,12 +170,26 @@ def settings(monkeypatch: pytest.MonkeyPatch) -> Settings: { "poll_interval_seconds": 60, "stale_after_seconds": 120, + # Submissions land in a per-test directory: the intake writes real + # files and must never touch the repository or a shared path. + "submissions": {"directory": str(tmp_path / "submissions")}, "printers": [ { "id": "bambu_test_01", "name": "Bambu Test 01", "model": "X1 Carbon", "env_prefix": prefix, + "profile": { + "enclosure": "enclosed", + "nozzle_type": "hardened_steel", + "nozzle_diameter_mm": 0.4, + "bed_size_mm": [256, 256], + "limits": { + "nozzle_temperature_c": [0, 300], + "bed_temperature_c": [0, 110], + }, + "ams": {"filament_forbidden": ["ABS"]}, + }, } ], } diff --git a/tests/test_api.py b/tests/test_api.py index 0899e40..226df1e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -175,6 +175,7 @@ def test_failed_print_is_error(settings: Settings) -> None: data_ready=True, gcode_state="FAILED", activity="UNKNOWN", + print_error_code=502, ) ) app = create_app(settings=settings, backend_factory=lambda _definition, _creds: backend) @@ -182,6 +183,38 @@ def test_failed_print_is_error(settings: Settings) -> None: body = test_client.get("/printers/bambu_test_01/status").json() assert body["equipment_status"] == "error" assert body["last_error"]["code"] == "print_failed" + assert body["last_error"]["message"] == ( + "Printer reported a failed print job (error 502)" + ) + assert body["details"]["print_error_code"] == 502 + + +def test_status_surfaces_advanced_telemetry(client: TestClient) -> None: + body = client.get("/printers/bambu_test_01/status").json() + details = body["details"] + assert details["print_type"] == "local" + assert details["nozzle_type"] == "hardened_steel" + assert details["nozzle_diameter"] == 0.4 + assert details["wifi_signal"] == "-42" + assert details["print_error_code"] == 0 + assert details["skipped_objects"] == [3] + assert details["ams_trays"] == [ + { + "ams_id": 0, + "tray_id": 1, + "tray_index": 1, + "tray_type": "PLA", + "tray_color": "#FF0000", + "tray_weight": "1000", + "tray_diameter": "1.75", + "tray_temp": "220", + "nozzle_temp_min": 190, + "nozzle_temp_max": 240, + } + ] + # No AMS UUIDs or raw telemetry identifiers leak through. + serialized = body["details"]["ams_trays"][0] + assert not any("uuid" in key for key in serialized) def test_no_control_routes_are_exposed(client: TestClient) -> None: diff --git a/tests/test_artifacts.py b/tests/test_artifacts.py new file mode 100644 index 0000000..663a4de --- /dev/null +++ b/tests/test_artifacts.py @@ -0,0 +1,177 @@ +"""Artifact inspection (bambu_server.artifacts).""" + +from __future__ import annotations + +import zipfile +from pathlib import Path + +import pytest + +from bambu_server.artifacts import ( + ArtifactError, + inspect_artifact, + parse_duration_minutes, +) + +from .conftest import SAMPLE_GCODE, write_3mf, write_gcode + +SCAN_BUDGET = 1 << 20 + + +def _inspect(path: Path, budget: int = SCAN_BUDGET): + return inspect_artifact(path, scan_max_bytes=budget) + + +def test_gcode_settings_and_footprint(tmp_path: Path) -> None: + facts = _inspect(write_gcode(tmp_path / "part.gcode")) + + assert facts.kind == "gcode" + assert facts.sliced is True + assert facts.filament_types == ("PLA",) + assert facts.nozzle_temperature_c == 220 + assert facts.nozzle_diameter_mm == 0.4 + assert facts.nozzle_type == "hardened_steel" + assert facts.layer_height_mm == 0.2 + assert facts.printer_model == "Bambu Lab X1 Carbon" + # X 10..110 and Y 10..60 -- an extent, not a position, so where the slicer + # placed the part on the plate cannot change the answer. + assert facts.extent_mm == (100.0, 50.0, 0.0) + assert facts.extent_source == "gcode_motion" + assert facts.gcode_findings == () + + +def test_bed_temperature_follows_the_selected_plate(tmp_path: Path) -> None: + """`curr_bed_type` picks which plate temperature the job actually uses.""" + facts = _inspect(write_gcode(tmp_path / "part.gcode")) + + # Textured plate is selected, so 55 -- not the 60 declared for the hot plate. + assert facts.bed_temperature_c == 55 + + +def test_commanded_temperatures_are_tracked_separately(tmp_path: Path) -> None: + """A temperature command can exceed the configured setpoint after an edit.""" + body = SAMPLE_GCODE.replace("M104 S220", "M104 S400") + facts = _inspect(write_gcode(tmp_path / "edited.gcode", body)) + + assert facts.nozzle_temperature_c == 220 + assert facts.commanded_nozzle_temperature_c == 400 + + +def test_total_estimated_time_is_preferred_over_model_time(tmp_path: Path) -> None: + facts = _inspect(write_gcode(tmp_path / "part.gcode")) + assert facts.estimated_duration_minutes == 70.0 + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("1h 10m 0s", 70.0), + ("45s", 0.75), + ("2m 30s", 2.5), + ("4200", 70.0), + ("", None), + ("not a time", None), + ], +) +def test_duration_parsing(text: str, expected: float | None) -> None: + assert parse_duration_minutes(text) == expected + + +def test_forbidden_command_is_reported(tmp_path: Path) -> None: + body = SAMPLE_GCODE + "M997\nM502\n" + facts = _inspect(write_gcode(tmp_path / "risky.gcode", body)) + + codes = {finding.code for finding in facts.gcode_findings} + details = " ".join(finding.detail for finding in facts.gcode_findings) + assert codes == {"forbidden_command"} + assert "M997" in details and "M502" in details + + +def test_relative_positioning_withholds_the_footprint(tmp_path: Path) -> None: + body = SAMPLE_GCODE + "G91\nG1 X5 Y5\n" + facts = _inspect(write_gcode(tmp_path / "relative.gcode", body)) + + assert facts.extent_mm is None + assert any("relative positioning" in note for note in facts.notes) + + +def test_truncated_scan_withholds_the_footprint(tmp_path: Path) -> None: + """A partly-read toolpath must not be reported as a whole one.""" + body = SAMPLE_GCODE + "".join(f"G1 X{i % 200} Y{i % 200}\n" for i in range(20000)) + facts = _inspect(write_gcode(tmp_path / "big.gcode", body), budget=4096) + + assert facts.scan_truncated is True + assert facts.extent_mm is None + # The head-and-tail read still recovers the slicer's settings. + assert facts.nozzle_temperature_c == 220 + + +def test_non_gcode_content_is_not_sliced(tmp_path: Path) -> None: + facts = _inspect(write_gcode(tmp_path / "junk.gcode", "hello\nworld\n")) + + assert facts.sliced is False + assert any("no gcode commands" in note for note in facts.notes) + + +def test_sliced_3mf_reads_its_embedded_plate(tmp_path: Path) -> None: + facts = _inspect(write_3mf(tmp_path / "plate.3mf")) + + assert facts.kind == "3mf" + assert facts.sliced is True + assert facts.filament_types == ("PLA",) + assert facts.nozzle_temperature_c == 220 + assert facts.bed_temperature_c == 55 + assert facts.extent_mm == (100.0, 50.0, 0.0) + assert facts.extent_source == "embedded_plate_gcode" + # The embedded gcode's own header wins over slice_info's `prediction`. + assert facts.estimated_duration_minutes == 70.0 + + +def test_unsliced_3mf_is_reported_as_unrunnable(tmp_path: Path) -> None: + facts = _inspect(write_3mf(tmp_path / "project.3mf", plate_gcode=None)) + + assert facts.sliced is False + assert facts.extent_mm is None + assert any("no sliced plate gcode" in note for note in facts.notes) + # The sidecar settings are still read, so the reason for rejection is the + # missing toolpath and not a pile of missing parameters. + assert facts.nozzle_temperature_c == 220 + assert facts.filament_types == ("PLA",) + + +def test_unsliced_3mf_falls_back_to_slice_info_duration(tmp_path: Path) -> None: + facts = _inspect(write_3mf(tmp_path / "project.3mf", plate_gcode=None)) + assert facts.estimated_duration_minutes == 70.0 + + +def test_xml_with_a_doctype_is_refused(tmp_path: Path) -> None: + """A DOCTYPE is where an entity expansion would be declared, so it is refused.""" + hostile = ( + '\n' + ']>\n' + "&a;\n" + ) + facts = _inspect( + write_3mf(tmp_path / "hostile.3mf", plate_gcode=None, slice_info=hostile) + ) + + # The document is dropped whole rather than parsed; the project settings + # still answer for the fields slice_info would have. + assert facts.nozzle_temperature_c == 220 + + +def test_multiple_plates_are_reported(tmp_path: Path) -> None: + path = tmp_path / "two_plates.3mf" + write_3mf(path) + with zipfile.ZipFile(path, "a") as archive: + archive.writestr("Metadata/plate_2.gcode", SAMPLE_GCODE) + + facts = _inspect(path) + assert any("2 plates" in note for note in facts.notes) + + +def test_unsupported_extension_is_an_error(tmp_path: Path) -> None: + path = tmp_path / "model.stl" + path.write_bytes(b"solid\n") + with pytest.raises(ArtifactError): + _inspect(path) diff --git a/tests/test_backend.py b/tests/test_backend.py index e81d858..cb8ea51 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -15,6 +15,29 @@ def firmware_version(self) -> str: return "01.08.00.00" +class FakeTray: + n = 1 + tray_type = "PLA" + tray_color = "#FF0000" + tray_weight = "1000" + tray_diameter = "1.75" + tray_temp = "220" + # Firmware types these as ints; the payload has also been seen carrying + # numeric strings, which is why the adapter coerces rather than casts. + nozzle_temp_min = 190 + nozzle_temp_max = "240" + + +class FakeAMS: + def __init__(self) -> None: + self.filament_trays = {1: FakeTray()} + + +class FakeAmSHub: + def __init__(self) -> None: + self.ams_hub = {0: FakeAMS()} + + class FakePrinter: def __init__(self, host: str, access_code: str, serial: str) -> None: self.constructor_values = (host, access_code, serial) @@ -70,6 +93,27 @@ def get_light_state(self) -> str: def get_file_name(self) -> str: return "part.3mf" + def nozzle_type(self) -> str: + return "hardened_steel" + + def nozzle_diameter(self) -> float: + return 0.4 + + def print_type(self) -> str: + return "local" + + def wifi_signal(self) -> str: + return "-42" + + def print_error_code(self) -> int: + return 0 + + def get_skipped_objects(self) -> list[int]: + return [3] + + def ams_hub(self) -> FakeAmSHub: + return FakeAmSHub() + def test_backend_starts_only_mqtt_and_builds_reading(monkeypatch) -> None: fake = FakePrinter("printer.invalid", "access-secret", "serial-secret") @@ -96,3 +140,16 @@ def test_backend_starts_only_mqtt_and_builds_reading(monkeypatch) -> None: assert reading.gcode_state == "RUNNING" assert reading.progress_percent == 42 assert reading.remaining_time_minutes == 18 + assert reading.nozzle_type == "hardened_steel" + assert reading.nozzle_diameter == 0.4 + assert reading.print_type == "local" + assert reading.wifi_signal == "-42" + assert reading.print_error_code == 0 + assert reading.skipped_objects == [3] + assert reading.ams_trays is not None + assert reading.ams_trays[0].tray_type == "PLA" + assert reading.ams_trays[0].tray_index == 1 + # The spool's own nozzle window, which the submission validator checks a + # model's configured temperature against. + assert reading.ams_trays[0].nozzle_temp_min == 190 + assert reading.ams_trays[0].nozzle_temp_max == 240 diff --git a/tests/test_config.py b/tests/test_config.py index cbfd7c1..5027e29 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,9 +1,16 @@ from __future__ import annotations +from pathlib import Path + import pytest from pydantic import ValidationError -from bambu_server.config import PrinterDefinition, Settings, resolve_credentials +from bambu_server.config import ( + PrinterDefinition, + Settings, + load_settings, + resolve_credentials, +) def test_duplicate_ids_are_rejected() -> None: @@ -30,3 +37,79 @@ def test_missing_credentials_name_variables_without_leaking_values( assert "BAMBU_ONE_HOST" in str(exc_info.value) assert "BAMBU_ONE_ACCESS_CODE" in str(exc_info.value) assert "BAMBU_ONE_SERIAL" in str(exc_info.value) + + +def test_a_printer_without_a_profile_stays_valid() -> None: + """Existing configuration files predate the machine profile block.""" + settings = Settings.model_validate( + {"printers": [{"id": "bambu_one", "name": "One", "env_prefix": "BAMBU_ONE"}]} + ) + profile = settings.printers[0].profile + + assert profile.bed_size_mm is None + assert profile.limits.nozzle_temperature_c is None + assert profile.ams.filament_forbidden == [] + + +def test_temperature_bands_accept_a_pair_or_a_mapping() -> None: + settings = Settings.model_validate( + { + "printers": [ + { + "id": "bambu_one", + "name": "One", + "env_prefix": "BAMBU_ONE", + "profile": { + "bed_size_mm": [256, 256], + "limits": { + "nozzle_temperature_c": [0, 300], + "bed_temperature_c": {"min_c": 0, "max_c": 110}, + }, + "ams": {"filament_forbidden": [" abs ", "asa"]}, + }, + } + ] + } + ) + profile = settings.printers[0].profile + + assert profile.bed_size_mm == (256.0, 256.0) + assert profile.limits.nozzle_temperature_c.max_c == 300 + assert profile.limits.bed_temperature_c.min_c == 0 + # Forbidden materials are normalised so a check never fails on casing. + assert profile.ams.filament_forbidden == ["ABS", "ASA"] + + +def test_an_inverted_temperature_band_is_rejected() -> None: + with pytest.raises(ValidationError, match="max_c must exceed min_c"): + Settings.model_validate( + { + "printers": [ + { + "id": "bambu_one", + "name": "One", + "env_prefix": "BAMBU_ONE", + "profile": {"limits": {"bed_temperature_c": [110, 0]}}, + } + ] + } + ) + + +def test_a_relative_submission_directory_resolves_against_the_config_file( + tmp_path: Path, +) -> None: + """A systemd unit and an interactive shell must agree on where files land.""" + config = tmp_path / "printers.yaml" + config.write_text( + "submissions:\n" + " directory: var/submissions\n" + "printers:\n" + " - id: bambu_one\n" + " name: One\n" + " env_prefix: BAMBU_ONE\n", + encoding="utf-8", + ) + + settings = load_settings(config) + assert settings.submissions.directory == tmp_path / "var" / "submissions" diff --git a/tests/test_queueing.py b/tests/test_queueing.py new file mode 100644 index 0000000..f205850 --- /dev/null +++ b/tests/test_queueing.py @@ -0,0 +1,130 @@ +"""Per-machine queue view and expected finish times.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from bambu_server.models import EquipmentStatus, MetricValue +from bambu_server.queueing import build_queue_view +from bambu_server.submissions import StateTransition, SubmissionJob + +NOW = datetime(2026, 9, 6, 12, 0, tzinfo=UTC) + + +def _status( + *, activity: str = "idle", remaining: float | None = None, job_name: str | None = None +) -> EquipmentStatus: + metrics = {} + if remaining is not None: + metrics["remaining_time"] = MetricValue(value=remaining, unit="min") + metrics["print_progress"] = MetricValue(value=42, unit="%") + return EquipmentStatus( + equipment_id="bambu_test_01", + equipment_name="Bambu Test 01", + equipment_kind="other", + equipment_status="busy" if activity == "running" else "ready", + activity=activity, + device_time=NOW, + metrics=metrics, + details={"job_name": job_name} if job_name else {}, + ) + + +def _job(name: str, duration: float | None, *, state: str = "queued") -> SubmissionJob: + return SubmissionJob( + submission_id=name.ljust(32, "0")[:32], + target_machine="bambu_test_01", + requested_by="remote-user-1", + original_filename=f"{name}.gcode", + artifact_kind="gcode", + byte_size=1, + sha256="0" * 64, + state=state, + created_at=NOW, + updated_at=NOW, + estimated_duration_minutes=duration, + approved_at=NOW if state == "approved" else None, + history=[StateTransition(at=NOW, to_state=state)], + ) + + +def test_an_idle_machine_stacks_queued_jobs_from_now() -> None: + view = build_queue_view( + machine="bambu_test_01", + status=_status(), + jobs=[_job("a", 30.0), _job("b", 15.0)], + now=NOW, + ) + + assert view.running is None + assert view.estimates_complete is True + assert [job.position for job in view.queued] == [1, 2] + assert view.queued[0].expected_end == NOW + timedelta(minutes=30) + assert view.queued[1].expected_end == NOW + timedelta(minutes=45) + + +def test_a_running_job_pushes_the_queue_out_by_its_remaining_time() -> None: + view = build_queue_view( + machine="bambu_test_01", + status=_status(activity="running", remaining=20.0, job_name="live.3mf"), + jobs=[_job("a", 30.0)], + now=NOW, + ) + + assert view.running is not None + assert view.running.job_name == "live.3mf" + assert view.running.progress_percent == 42.0 + assert view.running.expected_end == NOW + timedelta(minutes=20) + assert view.queued[0].expected_end == NOW + timedelta(minutes=50) + assert view.estimates_complete is True + + +def test_an_unknown_remaining_time_makes_every_estimate_null() -> None: + """A printer that will not say how long it has left cannot found an ETA.""" + view = build_queue_view( + machine="bambu_test_01", + status=_status(activity="running", job_name="live.3mf"), + jobs=[_job("a", 30.0)], + now=NOW, + ) + + assert view.running.expected_end is None + assert view.queued[0].expected_end is None + assert view.estimates_complete is False + + +def test_one_job_without_a_duration_truncates_the_rest() -> None: + view = build_queue_view( + machine="bambu_test_01", + status=_status(), + jobs=[_job("a", 30.0), _job("b", None), _job("c", 10.0)], + now=NOW, + ) + + assert view.queued[0].expected_end == NOW + timedelta(minutes=30) + assert view.queued[1].expected_end is None + # Everything behind an unknown duration is unknowable too. + assert view.queued[2].expected_end is None + assert view.estimates_complete is False + + +def test_approval_is_visible_in_the_queue() -> None: + view = build_queue_view( + machine="bambu_test_01", + status=_status(), + jobs=[_job("a", 10.0, state="approved")], + now=NOW, + ) + + assert view.queued[0].state == "approved" + assert view.queued[0].approved is True + + +def test_an_unknown_activity_reports_no_running_job() -> None: + """Unreachable or stale telemetry is not evidence that a job is running.""" + view = build_queue_view( + machine="bambu_test_01", status=_status(activity="unknown"), jobs=[], now=NOW + ) + + assert view.activity == "unknown" + assert view.running is None diff --git a/tests/test_submission_api.py b/tests/test_submission_api.py new file mode 100644 index 0000000..6d07f4c --- /dev/null +++ b/tests/test_submission_api.py @@ -0,0 +1,296 @@ +"""HTTP surface for the submission pipeline.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from pathlib import Path + +from fastapi.testclient import TestClient + +from bambu_server.backend import PrinterReading +from bambu_server.config import Settings +from bambu_server.main import create_app + +from .conftest import SAMPLE_GCODE, FakeBackend, write_3mf + + +def _upload( + client: TestClient, + *, + body: bytes | None = None, + filename: str = "part.gcode", + machine: str = "bambu_test_01", + material: str | None = None, +): + payload = SAMPLE_GCODE.encode() if body is None else body + data = {"target_machine": machine, "requested_by": "remote-user-1"} + if material is not None: + data["material"] = material + return client.post( + "/submissions", + files={"file": (filename, payload, "application/octet-stream")}, + data=data, + ) + + +def test_machine_profile_merges_declared_and_observed_fields(client: TestClient) -> None: + body = client.get("/printers/bambu_test_01/profile").json() + + assert body["id"] == "bambu_test_01" + assert body["bed_size_mm"] == [256.0, 256.0] + assert body["enclosure"] == "enclosed" + # The printer reports its own nozzle, so that is what a submitter is checked + # against; the declared value is the fallback for a blank live field. + assert body["nozzle_diameter_mm"] == 0.4 + assert body["nozzle_diameter_source"] == "observed" + assert body["ams"]["filament_forbidden"] == ["ABS"] + assert body["observed"]["loaded_trays"][0]["tray_type"] == "PLA" + + +def test_profile_falls_back_to_the_declared_nozzle_when_live_is_blank( + settings: Settings, +) -> None: + """A dual-nozzle H2D reports no parsable nozzle type; the profile still answers.""" + backend = FakeBackend( + PrinterReading( + data_updated_at=datetime.now(UTC), + connected=True, + data_ready=True, + gcode_state="IDLE", + ) + ) + app = create_app(settings=settings, backend_factory=lambda _d, _c: backend) + with TestClient(app) as test_client: + body = test_client.get("/printers/bambu_test_01/profile").json() + + assert body["nozzle_diameter_mm"] == 0.4 + assert body["nozzle_diameter_source"] == "declared" + assert body["nozzle_type_source"] == "declared" + + +def test_profile_for_an_unknown_printer_is_404(client: TestClient) -> None: + assert client.get("/printers/not_configured/profile").status_code == 404 + + +def test_a_conforming_submission_is_validated_and_queued(client: TestClient) -> None: + response = _upload(client) + + assert response.status_code == 201 + job = response.json() + assert job["state"] == "queued" + assert job["target_machine"] == "bambu_test_01" + assert job["requested_by"] == "remote-user-1" + assert job["verdict"]["verdict"] == "pass" + assert job["verdict"]["reasons"] == [] + assert job["verdict"]["dispatch_ready"] is False + assert job["estimated_duration_minutes"] == 70.0 + assert job["facts"]["extent_mm"] == [100.0, 50.0, 0.0] + + +def test_a_submission_response_never_reveals_where_the_file_was_stored( + client: TestClient, settings: Settings +) -> None: + response = _upload(client) + + serialized = response.text + assert str(settings.submissions.directory) not in serialized + assert "/tmp" not in serialized + assert response.json()["original_filename"] == "part.gcode" + + +def test_a_nonconforming_submission_is_rejected_with_reasons(client: TestClient) -> None: + body = SAMPLE_GCODE.replace("; nozzle_diameter = 0.4", "; nozzle_diameter = 0.6") + response = _upload(client, body=body.encode()) + + assert response.status_code == 201 + job = response.json() + assert job["state"] == "rejected" + assert job["verdict"]["verdict"] == "reject" + assert job["verdict"]["reasons"] == ["machine_compatible"] + + +def test_a_forbidden_material_is_rejected_over_http(client: TestClient) -> None: + body = SAMPLE_GCODE.replace("; filament_type = PLA", "; filament_type = ABS") + job = _upload(client, body=body.encode()).json() + + assert job["state"] == "rejected" + assert "material_allowed" in job["verdict"]["reasons"] + + +def test_a_declared_material_that_contradicts_the_model_is_rejected( + client: TestClient, +) -> None: + job = _upload(client, material="PETG").json() + + assert job["state"] == "rejected" + assert "material_allowed" in job["verdict"]["reasons"] + + +def test_a_sliced_3mf_is_accepted(client: TestClient, tmp_path: Path) -> None: + payload = write_3mf(tmp_path / "plate.3mf").read_bytes() + job = _upload(client, body=payload, filename="plate.3mf").json() + + assert job["state"] == "queued" + assert job["artifact_kind"] == "3mf" + + +def test_an_unsliced_3mf_is_rejected(client: TestClient, tmp_path: Path) -> None: + payload = write_3mf(tmp_path / "project.3mf", plate_gcode=None).read_bytes() + job = _upload(client, body=payload, filename="project.3mf").json() + + assert job["state"] == "rejected" + assert "params_present" in job["verdict"]["reasons"] + + +def test_an_unknown_target_machine_is_404(client: TestClient) -> None: + response = _upload(client, machine="bambu_nowhere") + + assert response.status_code == 404 + assert response.json()["detail"] == "unknown target machine" + + +def test_an_unsupported_extension_is_400(client: TestClient) -> None: + response = _upload(client, filename="model.stl") + + assert response.status_code == 400 + assert ".3mf" in response.json()["detail"] + + +def test_a_3mf_that_is_not_a_container_is_400(client: TestClient) -> None: + """A malformed upload is refused at intake, not carried into validation.""" + response = _upload(client, body=b"not a zip at all", filename="fake.3mf") + + assert response.status_code == 400 + assert "not a valid 3mf container" in response.json()["detail"] + + +def test_an_oversized_upload_is_413(settings: Settings, backend: FakeBackend) -> None: + settings.submissions.max_file_bytes = 1024 + app = create_app(settings=settings, backend_factory=lambda _d, _c: backend) + with TestClient(app) as test_client: + response = _upload(test_client, body=b"G1 X1\n" * 4096) + + assert response.status_code == 413 + + +def test_submissions_can_be_listed_and_fetched(client: TestClient) -> None: + created = _upload(client).json() + + listing = client.get("/submissions").json() + assert [job["submission_id"] for job in listing] == [created["submission_id"]] + + assert client.get("/submissions", params={"state": "queued"}).json() != [] + assert client.get("/submissions", params={"state": "approved"}).json() == [] + assert client.get("/submissions", params={"machine": "elsewhere"}).json() == [] + + fetched = client.get(f"/submissions/{created['submission_id']}") + assert fetched.status_code == 200 + assert fetched.json()["submission_id"] == created["submission_id"] + + +def test_an_unknown_submission_is_404(client: TestClient) -> None: + assert client.get("/submissions/" + "0" * 32).status_code == 404 + + +def test_approval_records_sign_off_and_sets_dispatch_ready(client: TestClient) -> None: + created = _upload(client).json() + + response = client.post( + f"/submissions/{created['submission_id']}/approve", + json={"approved_by": "lab-operator"}, + ) + + assert response.status_code == 200 + job = response.json() + assert job["state"] == "approved" + assert job["approved_by"] == "lab-operator" + assert job["verdict"]["dispatch_ready"] is True + assert job["history"][-1]["note"] == "approved by lab-operator" + + +def test_approving_twice_is_a_conflict(client: TestClient) -> None: + created = _upload(client).json() + path = f"/submissions/{created['submission_id']}/approve" + client.post(path, json={"approved_by": "lab-operator"}) + + second = client.post(path, json={"approved_by": "lab-operator"}) + assert second.status_code == 409 + + +def test_a_rejected_submission_cannot_be_approved(client: TestClient) -> None: + body = SAMPLE_GCODE.replace("; nozzle_diameter = 0.4", "; nozzle_diameter = 0.6") + created = _upload(client, body=body.encode()).json() + + response = client.post( + f"/submissions/{created['submission_id']}/approve", + json={"approved_by": "lab-operator"}, + ) + assert response.status_code == 409 + + +def test_the_queue_endpoint_reports_order_and_finish_times(client: TestClient) -> None: + first = _upload(client).json() + second = _upload(client).json() + + view = client.get("/printers/bambu_test_01/queue").json() + + assert view["machine"] == "bambu_test_01" + assert view["running"] is None + assert [job["submission_id"] for job in view["queued"]] == [ + first["submission_id"], + second["submission_id"], + ] + assert view["estimates_complete"] is True + assert view["queued"][0]["expected_end"] < view["queued"][1]["expected_end"] + + +def test_the_queue_reports_the_running_print(settings: Settings) -> None: + backend = FakeBackend( + PrinterReading( + data_updated_at=datetime.now(UTC), + connected=True, + data_ready=True, + gcode_state="RUNNING", + activity="PRINTING", + remaining_time_minutes=25, + progress_percent=40, + job_name="live_part.3mf", + ) + ) + app = create_app(settings=settings, backend_factory=lambda _d, _c: backend) + with TestClient(app) as test_client: + view = test_client.get("/printers/bambu_test_01/queue").json() + + assert view["activity"] == "running" + assert view["running"]["job_name"] == "live_part.3mf" + assert view["running"]["remaining_time_minutes"] == 25.0 + assert view["running"]["expected_end"] is not None + + +def test_a_rejected_submission_never_enters_the_queue(client: TestClient) -> None: + body = SAMPLE_GCODE.replace("; nozzle_diameter = 0.4", "; nozzle_diameter = 0.6") + _upload(client, body=body.encode()) + + assert client.get("/printers/bambu_test_01/queue").json()["queued"] == [] + + +def test_the_pipeline_exposes_no_control_routes_and_no_dispatch( + client: TestClient, +) -> None: + """The submission surface must not have grown a way to reach a printer.""" + paths = client.get("/openapi.json").json()["paths"] + + assert not any("/control/" in path for path in paths) + assert not any("dispatch" in path for path in paths) + for path, operations in paths.items(): + for method in operations: + assert method.lower() in {"get", "post"}, (path, method) + + +def test_reads_never_cause_printer_io(client: TestClient, backend: FakeBackend) -> None: + before = backend.read_count + client.get("/printers/bambu_test_01/profile") + client.get("/printers/bambu_test_01/queue") + client.get("/submissions") + + assert backend.read_count == before diff --git a/tests/test_submissions.py b/tests/test_submissions.py new file mode 100644 index 0000000..16301cb --- /dev/null +++ b/tests/test_submissions.py @@ -0,0 +1,302 @@ +"""Submission store, state machine, and the dispatch boundary.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from pathlib import Path + +import pytest + +from bambu_server.config import MachineProfileConfig, PrinterDefinition, SubmissionSettings +from bambu_server.profiles import MachineProfile, ObservedMachineState, build_profile +from bambu_server.submissions import ( + ArtifactTooLarge, + DispatchUnavailable, + InvalidTransition, + SubmissionError, + SubmissionStore, + dispatch, + run_validation, + safe_display_name, +) + +from .conftest import SAMPLE_GCODE, write_3mf, write_gcode + + +@pytest.fixture +def store(tmp_path: Path) -> SubmissionStore: + store = SubmissionStore(SubmissionSettings(directory=tmp_path / "submissions")) + store.load() + return store + + +@pytest.fixture +def profile() -> MachineProfile: + definition = PrinterDefinition( + id="bambu_test_01", + name="Bambu Test 01", + model="X1 Carbon", + env_prefix="BAMBU_TEST_01", + profile=MachineProfileConfig.model_validate( + { + "nozzle_diameter_mm": 0.4, + "bed_size_mm": [256, 256], + "limits": {"nozzle_temperature_c": [0, 300], "bed_temperature_c": [0, 110]}, + } + ), + ) + return build_profile(definition, ObservedMachineState(telemetry_ok=True)) + + +async def _chunks(data: bytes, size: int = 4096) -> AsyncIterator[bytes]: + for offset in range(0, len(data), size): + yield data[offset : offset + size] + + +async def _accept(store: SubmissionStore, body: bytes = None, extension: str = ".gcode"): + payload = SAMPLE_GCODE.encode() if body is None else body + return await store.accept( + chunks=_chunks(payload), + extension=extension, + target_machine="bambu_test_01", + requested_by="remote-user-1", + material=None, + original_filename="../../etc/passwd.gcode", + ) + + +async def test_accept_stores_the_artifact_and_records_the_job( + store: SubmissionStore, +) -> None: + job = await _accept(store) + + assert job.state == "submitted" + assert job.artifact_kind == "gcode" + assert job.byte_size == len(SAMPLE_GCODE.encode()) + assert len(job.sha256) == 64 + # The stored name comes from the submission's UUID, never the client's path. + assert store.artifact_path(job).name == f"{job.submission_id}.gcode" + assert store.artifact_path(job).read_text() == SAMPLE_GCODE + assert job.original_filename == "passwd.gcode" + + +@pytest.mark.parametrize( + ("supplied", "expected"), + [ + ("../../etc/passwd", "passwd"), + ("C:\\\\Windows\\\\evil.gcode", "evil.gcode"), + ("", "submission"), + ("na\x00me.gcode", "name.gcode"), + ], +) +def test_display_names_are_reduced_to_a_basename(supplied: str, expected: str) -> None: + assert safe_display_name(supplied) == expected + + +async def test_jobs_survive_a_restart(store: SubmissionStore, tmp_path: Path) -> None: + job = await _accept(store) + + reopened = SubmissionStore(SubmissionSettings(directory=tmp_path / "submissions")) + reopened.load() + + assert reopened.get(job.submission_id) is not None + assert reopened.get(job.submission_id).state == "submitted" + + +async def test_an_oversized_upload_is_refused_and_leaves_nothing_behind( + tmp_path: Path, +) -> None: + store = SubmissionStore( + SubmissionSettings(directory=tmp_path / "submissions", max_file_bytes=1024) + ) + store.load() + + with pytest.raises(ArtifactTooLarge): + await _accept(store, body=b"x" * 4096) + + assert list((tmp_path / "submissions").iterdir()) == [] + assert store.list() == [] + + +async def test_an_empty_upload_is_refused(store: SubmissionStore) -> None: + with pytest.raises(SubmissionError): + await _accept(store, body=b"") + + +async def test_illegal_transitions_are_refused(store: SubmissionStore) -> None: + job = await _accept(store) + + with pytest.raises(InvalidTransition): + await store.transition(job, "running") + + job = await store.transition(job, "validating") + job = await store.transition(job, "rejected") + # Rejected is terminal: nothing moves out of it, not even to `failed`. + with pytest.raises(InvalidTransition): + await store.transition(job, "failed") + + +async def test_validation_queues_a_conforming_submission( + store: SubmissionStore, profile: MachineProfile +) -> None: + job = await run_validation(store, await _accept(store), profile) + + assert job.state == "queued" + assert job.verdict is not None + assert job.verdict.verdict == "pass" + assert job.verdict.dispatch_ready is False + assert job.estimated_duration_minutes == 70.0 + assert [entry.to_state for entry in job.history] == [ + "submitted", + "validating", + "validated", + "queued", + ] + + +async def test_validation_rejects_a_nonconforming_submission( + store: SubmissionStore, profile: MachineProfile +) -> None: + body = SAMPLE_GCODE.replace("; nozzle_diameter = 0.4", "; nozzle_diameter = 0.6") + job = await run_validation(store, await _accept(store, body.encode()), profile) + + assert job.state == "rejected" + assert job.verdict.reasons == ["machine_compatible"] + assert store.queue_for("bambu_test_01") == [] + + +async def test_an_unreadable_artifact_is_rejected_without_leaking_a_path( + store: SubmissionStore, profile: MachineProfile +) -> None: + job = await _accept(store, body=b"PK\x03\x04 not really a zip", extension=".3mf") + job = await run_validation(store, job, profile) + + assert job.state == "rejected" + assert job.verdict.reasons == ["artifact_readable"] + detail = job.verdict.checks[0].detail + assert "could not be opened" in detail + # The reason must never quote where the gateway stored the file. + assert str(store.root) not in detail + + +async def test_a_3mf_submission_validates_end_to_end( + store: SubmissionStore, profile: MachineProfile, tmp_path: Path +) -> None: + payload = write_3mf(tmp_path / "plate.3mf").read_bytes() + job = await run_validation( + store, await _accept(store, payload, extension=".3mf"), profile + ) + + assert job.state == "queued" + assert job.facts.kind == "3mf" + assert job.facts.sliced is True + + +async def test_approval_requires_a_queued_job_that_passed( + store: SubmissionStore, profile: MachineProfile +) -> None: + submitted = await _accept(store) + with pytest.raises(InvalidTransition): + await store.approve(submitted, approved_by="operator") + + job = await run_validation(store, submitted, profile) + approved = await store.approve(job, approved_by="operator") + + assert approved.state == "approved" + assert approved.approved_by == "operator" + assert approved.approved_at is not None + # dispatch_ready flips only once validation *and* the approval gate agree. + assert approved.verdict.dispatch_ready is True + + with pytest.raises(InvalidTransition): + await store.approve(approved, approved_by="operator") + + +async def test_a_rejected_job_can_never_be_approved( + store: SubmissionStore, profile: MachineProfile +) -> None: + body = SAMPLE_GCODE.replace("; nozzle_diameter = 0.4", "; nozzle_diameter = 0.6") + job = await run_validation(store, await _accept(store, body.encode()), profile) + + with pytest.raises(InvalidTransition): + await store.approve(job, approved_by="operator") + + +async def test_the_queue_is_first_in_first_out_and_holds_approved_jobs( + store: SubmissionStore, profile: MachineProfile +) -> None: + first = await run_validation(store, await _accept(store), profile) + second = await run_validation(store, await _accept(store), profile) + await store.approve(first, approved_by="operator") + + queued = store.queue_for("bambu_test_01") + assert [job.submission_id for job in queued] == [ + first.submission_id, + second.submission_id, + ] + assert queued[0].state == "approved" + assert queued[1].state == "queued" + + +async def test_the_queue_is_scoped_to_one_machine( + store: SubmissionStore, profile: MachineProfile +) -> None: + await run_validation(store, await _accept(store), profile) + assert store.queue_for("bambu_other") == [] + + +async def test_dispatch_is_not_implemented( + store: SubmissionStore, profile: MachineProfile +) -> None: + """The one printer-touching step stays behind the approval gate.""" + job = await run_validation(store, await _accept(store), profile) + approved = await store.approve(job, approved_by="operator") + + with pytest.raises(DispatchUnavailable): + await dispatch(approved) + + +async def test_a_corrupt_record_does_not_stop_the_store_loading( + store: SubmissionStore, tmp_path: Path +) -> None: + job = await _accept(store) + (tmp_path / "submissions" / "broken.json").write_text("{not json", encoding="utf-8") + + reopened = SubmissionStore(SubmissionSettings(directory=tmp_path / "submissions")) + reopened.load() + + assert [item.submission_id for item in reopened.list()] == [job.submission_id] + + +def test_writing_a_gcode_helper_is_deterministic(tmp_path: Path) -> None: + assert write_gcode(tmp_path / "a.gcode").read_text() == SAMPLE_GCODE + + +async def test_a_blank_owner_is_refused_before_anything_is_written( + store: SubmissionStore, tmp_path: Path +) -> None: + """Refusing after the write would leave an artifact no record points at.""" + with pytest.raises(SubmissionError, match="requested_by"): + await store.accept( + chunks=_chunks(SAMPLE_GCODE.encode()), + extension=".gcode", + target_machine="bambu_test_01", + requested_by=" ", + material=None, + original_filename="part.gcode", + ) + + assert list((tmp_path / "submissions").iterdir()) == [] + assert store.list() == [] + + +async def test_a_blank_material_is_treated_as_absent(store: SubmissionStore) -> None: + job = await store.accept( + chunks=_chunks(SAMPLE_GCODE.encode()), + extension=".gcode", + target_machine="bambu_test_01", + requested_by="remote-user-1", + material=" ", + original_filename="part.gcode", + ) + assert job.material is None diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..0573f53 --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,288 @@ +"""Model validation against a machine profile (bambu_server.validation).""" + +from __future__ import annotations + +from bambu_server.artifacts import ArtifactFacts, GcodeFinding +from bambu_server.config import MachineProfileConfig, PrinterDefinition +from bambu_server.profiles import LoadedTray, ObservedMachineState, build_profile +from bambu_server.validation import ValidationVerdict, validate_model + + +def _profile(**overrides: object): + declared = { + "enclosure": "enclosed", + "nozzle_type": "hardened_steel", + "nozzle_diameter_mm": 0.4, + "bed_size_mm": (256.0, 256.0), + "limits": { + "nozzle_temperature_c": [0, 300], + "bed_temperature_c": [0, 110], + }, + "ams": {"filament_forbidden": ["ABS"]}, + } + declared.update(overrides.pop("declared", {})) # type: ignore[arg-type] + definition = PrinterDefinition( + id="bambu_test_01", + name="Bambu Test 01", + model="X1 Carbon", + env_prefix="BAMBU_TEST_01", + profile=MachineProfileConfig.model_validate(declared), + ) + observed = overrides.pop("observed", ObservedMachineState(telemetry_ok=True)) + return build_profile(definition, observed) # type: ignore[arg-type] + + +def _facts(**overrides: object) -> ArtifactFacts: + base: dict[str, object] = { + "kind": "gcode", + "byte_size": 1024, + "sliced": True, + "filament_types": ("PLA",), + "nozzle_temperature_c": 220.0, + "bed_temperature_c": 55.0, + "chamber_temperature_c": 0.0, + "nozzle_diameter_mm": 0.4, + "nozzle_type": "hardened_steel", + "printer_model": "Bambu Lab X1 Carbon", + "extent_mm": (100.0, 50.0, 30.0), + "extent_source": "gcode_motion", + "estimated_duration_minutes": 70.0, + } + base.update(overrides) + return ArtifactFacts.model_validate(base) + + +def _run(facts: ArtifactFacts, profile=None, material: str | None = None) -> ValidationVerdict: + return validate_model( + submission_id="a" * 32, + facts=facts, + profile=profile or _profile(), + requested_material=material, + ) + + +def _check(verdict: ValidationVerdict, name: str): + return next(result for result in verdict.checks if result.check == name) + + +def test_a_matching_model_passes_every_applicable_check() -> None: + verdict = _run(_facts()) + + assert verdict.verdict == "pass" + assert verdict.reasons == [] + # Passing validation is not the same as being cleared to run. + assert verdict.dispatch_ready is False + assert _check(verdict, "machine_compatible").status == "pass" + assert _check(verdict, "build_fits_plate").status == "pass" + + +def test_checks_are_reported_in_the_declared_order() -> None: + verdict = _run(_facts()) + assert [result.check for result in verdict.checks] == [ + "machine_compatible", + "material_allowed", + "material_filament_match", + "nozzle_temp_in_band", + "bed_chamber_temp_in_band", + "build_fits_plate", + "gcode_sanity", + "params_present", + ] + + +def test_a_single_failing_check_rejects_the_submission() -> None: + verdict = _run(_facts(nozzle_diameter_mm=0.6)) + + assert verdict.verdict == "reject" + assert verdict.reasons == ["machine_compatible"] + assert "0.6 mm nozzle" in _check(verdict, "machine_compatible").detail + + +def test_a_model_sliced_for_another_printer_is_rejected() -> None: + verdict = _run(_facts(printer_model="Bambu Lab P1S")) + + assert verdict.reasons == ["machine_compatible"] + + +def test_loose_model_matching_accepts_a_qualified_name() -> None: + """`X1 Carbon` and `Bambu Lab X1 Carbon` name the same machine.""" + assert _run(_facts(printer_model="Bambu Lab X1 Carbon")).verdict == "pass" + + +def test_a_forbidden_material_is_rejected() -> None: + verdict = _run(_facts(filament_types=("ABS",))) + + assert "material_allowed" in verdict.reasons + assert "not runnable" in _check(verdict, "material_allowed").detail + + +def test_a_declared_material_must_match_the_sliced_one() -> None: + verdict = _run(_facts(), material="PETG") + + assert verdict.reasons == ["material_allowed"] + assert "sliced for PLA" in _check(verdict, "material_allowed").detail + + +def test_missing_tray_data_is_not_applicable_rather_than_a_pass() -> None: + """Both live printers report no AMS trays; that must not read as verified.""" + result = _check(_run(_facts()), "material_filament_match") + + assert result.status == "not_applicable" + assert result.ok is True + assert "no AMS tray inventory" in result.detail + + +def test_a_loaded_tray_that_does_not_match_is_rejected() -> None: + profile = _profile( + observed=ObservedMachineState( + telemetry_ok=True, + loaded_trays=[LoadedTray(ams_id=0, tray_id=1, tray_type="PETG")], + ) + ) + verdict = _run(_facts(), profile) + + assert "material_filament_match" in verdict.reasons + assert "loaded trays hold PETG" in _check(verdict, "material_filament_match").detail + + +def test_nozzle_temperature_is_checked_against_the_loaded_filament() -> None: + profile = _profile( + observed=ObservedMachineState( + telemetry_ok=True, + loaded_trays=[ + LoadedTray( + ams_id=0, + tray_id=1, + tray_type="PLA", + nozzle_temp_min_c=190, + nozzle_temp_max_c=240, + ) + ], + ) + ) + + assert _run(_facts(), profile).verdict == "pass" + + hot = _run(_facts(nozzle_temperature_c=260.0), profile) + assert hot.reasons == ["nozzle_temp_in_band"] + assert "outside" in _check(hot, "nozzle_temp_in_band").detail + + +def test_a_commanded_temperature_above_the_machine_limit_is_rejected() -> None: + """An edited toolpath can command more than its own configured setpoint.""" + verdict = _run(_facts(commanded_nozzle_temperature_c=400.0)) + + assert verdict.reasons == ["nozzle_temp_in_band"] + assert "commanded 400 C" in _check(verdict, "nozzle_temp_in_band").detail + + +def test_a_chamber_request_needs_a_machine_with_a_chamber() -> None: + verdict = _run(_facts(chamber_temperature_c=50.0)) + + assert verdict.reasons == ["bed_chamber_temp_in_band"] + assert "no chamber temperature control" in _check( + verdict, "bed_chamber_temp_in_band" + ).detail + + +def test_a_chamber_request_passes_on_a_machine_that_has_one() -> None: + profile = _profile( + declared={"chamber_temperature_c": 60.0, "limits": {"chamber_temperature_c": [0, 65]}} + ) + assert _run(_facts(chamber_temperature_c=50.0), profile).verdict == "pass" + + +def test_an_oversized_model_is_rejected_with_a_rotation_hint() -> None: + verdict = _run(_facts(extent_mm=(300.0, 100.0, 20.0))) + + assert verdict.reasons == ["build_fits_plate"] + assert "exceeds" in _check(verdict, "build_fits_plate").detail + + profile = _profile(declared={"bed_size_mm": (150.0, 350.0)}) + rotated = _run(_facts(extent_mm=(300.0, 100.0, 20.0)), profile) + assert "fit rotated" in _check(rotated, "build_fits_plate").detail + + +def test_plate_fit_is_not_applicable_without_a_declared_bed() -> None: + profile = _profile(declared={"bed_size_mm": None}) + result = _check(_run(_facts(), profile), "build_fits_plate") + + assert result.status == "not_applicable" + assert "bed_size_mm is not declared" in result.detail + + +def test_gcode_findings_reject_and_the_pass_does_not_overclaim() -> None: + verdict = _run( + _facts(gcode_findings=(GcodeFinding(code="forbidden_command", detail="M997 bad"),)) + ) + assert verdict.reasons == ["gcode_sanity"] + + clean = _check(_run(_facts()), "gcode_sanity") + assert clean.status == "pass" + assert "not a proof of safety" in clean.detail + + +def test_missing_print_settings_are_rejected() -> None: + verdict = _run( + _facts(filament_types=(), nozzle_temperature_c=None, bed_temperature_c=None) + ) + + detail = _check(verdict, "params_present").detail + assert "params_present" in verdict.reasons + assert "filament type" in detail + assert "nozzle temperature" in detail + assert "bed temperature" in detail + + +def test_an_unsliced_project_file_cannot_be_run() -> None: + verdict = _run(_facts(kind="3mf", sliced=False, extent_mm=None)) + + assert "params_present" in verdict.reasons + assert "embedded sliced plate" in _check(verdict, "params_present").detail + # Nothing to scan, so the sanity check declines rather than passing. + assert _check(verdict, "gcode_sanity").status == "not_applicable" + + +def test_a_degenerate_tray_window_is_handled_not_crashed() -> None: + """A spool tag can report min == max; a single-point window still checks.""" + profile = _profile( + observed=ObservedMachineState( + telemetry_ok=True, + loaded_trays=[ + LoadedTray( + ams_id=0, + tray_id=1, + tray_type="PLA", + nozzle_temp_min_c=220, + nozzle_temp_max_c=220, + ) + ], + ) + ) + + assert _run(_facts(), profile).verdict == "pass" + assert _run(_facts(nozzle_temperature_c=225.0), profile).reasons == [ + "nozzle_temp_in_band" + ] + + +def test_an_inverted_tray_window_is_ignored_rather_than_rejecting_everything() -> None: + profile = _profile( + observed=ObservedMachineState( + telemetry_ok=True, + loaded_trays=[ + LoadedTray( + ams_id=0, + tray_id=1, + tray_type="PLA", + nozzle_temp_min_c=240, + nozzle_temp_max_c=190, + ) + ], + ) + ) + verdict = _run(_facts(), profile) + + assert verdict.verdict == "pass" + # The machine limit still applies; only the nonsense spool window is dropped. + assert "machine 0-300 C" in _check(verdict, "nozzle_temp_in_band").detail diff --git a/uv.lock b/uv.lock index 9dc421e..8084c4e 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" [[package]] @@ -11,6 +11,7 @@ dependencies = [ { name = "fastapi" }, { name = "pydantic" }, { name = "python-dotenv" }, + { name = "python-multipart" }, { name = "pyyaml" }, { name = "sdl-lab-contract" }, { name = "uvicorn", extra = ["standard"] }, @@ -33,6 +34,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, { name = "python-dotenv", specifier = ">=1.0" }, + { name = "python-multipart", specifier = ">=0.0.9" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5" }, { name = "sdl-lab-contract", git = "https://github.com/AccelerationConsortium/sdl-lab-contract?tag=v1.2.0" }, @@ -129,7 +131,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -559,6 +561,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3"