Просмотр исходного кода

Merge pull request #2652 from maziggy/release/1.2.5

**Bambuddy 1.2.5**

**⚠ Upgrade Notes — Read Before Updating**

**About the version number.** This is the successor to **0.2.4.9** — the first digit moved from **0** to **1** (0.2.5 became 1.2.5). It is a normal next release on the same code base, not a rewrite; the jump in the leading digit is only a versioning-scheme change. The in-app Apply Update button in Settings → System → Updates works for Docker and for any native install already on the 0.2.x line.

1.2.5 folds in the whole beta cycle, so it is a large release. There are no breaking schema changes beyond auto-migrated column additions (dialect-branched for SQLite and Postgres), and every migration was run against both engines.

**Behaviour-change callouts to know about before you upgrade:**

- **Bed levelling, flow calibration, and nozzle-offset calibration are now three-way Off / Auto / On, and new prints default to Auto.** This matches Bambu Studio (Auto lets the printer skip a calibration it did recently). Your existing queued prints and saved workflow defaults are migrated automatically — anything that was "on" becomes "On (force)", anything "off" stays "Off" — so nothing changes for in-flight jobs until you opt into Auto.

- **Docker now shuts down gracefully.** Previously every docker stop / restart / image update was a SIGKILL after the full grace period — no WAL checkpoint, no MQTT disconnect, no clean teardown. That is fixed (uvicorn is now PID 1 and receives the signal). To also pick up the raised stop grace period, refresh your docker-compose.yml (it now sets stop_grace_period: 30s). systemd/launchd/Windows launchers get the equivalent timeout automatically via the installer.

- **Multi-printer farms dispatch far faster.** Uploads to different printers now run concurrently (new Settings → Workflow → Queue & Dispatch → Concurrent Uploads, default 4, up to 16 — set it to 1 for the old strictly-serial behaviour), and the scheduler re-ticks within seconds after a productive pass instead of waiting a fixed 30 s. A stuck printer is now failed after three attempts instead of retried forever.

- **PostgreSQL pool defaults raised and made configurable.** The pool is now 20 + 80 (100) by default with DB_POOL_SIZE / DB_MAX_OVERFLOW / DB_POOL_TIMEOUT / DB_POOL_RECYCLE overrides, plus a GET /api/v1/system/db-pool gauge. If you run a large farm on Postgres, review your server's max_connections headroom — see the PostgreSQL wiki page.

- **Bambu Cloud sign-in state is now honest.** A lapsed token is properly detected instead of showing "Connected" forever, and a single stray 401 no longer signs you out. If you linked your Bambu account before enabling authentication (or crossed an auth on/off transition on an older build), you may need to re-link once from the Profiles page.

- **Manual jog safety (Bambu firmware bug).** Bambu firmware does not enforce its soft endstops on G-code received over MQTT, so manual jog can overrun a travel limit. Bambuddy no longer disables the endstops globally around a jog (which previously also broke the touchscreen's limits until a power-cycle) and now shows a prominent warning on the jog panel. If your printer currently overruns even from its own touchscreen, power-cycle it once to restore the endstops an older Bambuddy build disabled.

- **P1S / P1P AMS drying is screen-only.** P1 firmware acks drying commands and discards them, so Bambuddy no longer offers Start/Stop for P1 drying — the flame button stays visible but disabled with an explanation. A cycle started at the printer still shows its live countdown.

- **REST smart-plug energy (Shelly users).** If your Energy JSON Path points at a cumulative lifetime counter (anything from a Shelly does), move it to the new Energy JSON Path (lifetime) field so Today/Yesterday/Total populate correctly.

- **Re-take your backups after upgrading.** A Postgres → SQLite backup export previously dropped NOT NULL / DEFAULT / FK / UNIQUE; that is fixed, but backups taken on an older build still carry the degraded schema.

- **Stats.** Reconciled-after-reconnect prints no longer inflate Total Print Time by hundreds of hours. Rows already inflated by the old bug are not auto-corrected (they're indistinguishable from real cancellations) — repair them by hand if needed.

Make a backup before upgrading via Settings → Backup → Create Backup. Native install with update.sh snapshots the database automatically and rolls back on failure. Docker and fully-manual paths don't.

**Docker**

    docker compose pull
    docker compose up -d

Refresh docker-compose.yml if you want the new stop_grace_period: 30s (recommended, so a slow teardown on a Pi isn't clipped).

**Native install — recommended path**

    sudo BRANCH=main /opt/bambuddy/install/update.sh

Snapshots the database first and rolls back on failure. Also carries any custom ReadWritePaths you added (e.g. for NAS backups) forward into the new systemd unit.

**Native install — manual path**

    sudo systemctl stop bambuddy
    cd /opt/bambuddy
    sudo -u bambuddy git fetch --prune --tags --force origin
    sudo -u bambuddy git checkout main
    sudo -u bambuddy git reset --hard origin/main
    sudo /opt/bambuddy/venv/bin/pip install -r requirements.txt
    cd frontend && sudo npm i
    sudo systemctl start bambuddy

**Windows install**

Download bambuddy-1.2.5-windows-x64-setup.exe from this release page (or the unversioned bambuddy-windows-x64-setup.exe alias for an always-latest link). Existing Windows installs upgrade in place via the in-app Install Update flow.

---

**Highlights**

1.2.5 is a big release that lands several long-requested features on top of a large stability and farm-scale correctness pass.

**New surfaces:**

- **Slicer Pipelines** — save a printer/process/filament/bed-type bundle once and dispatch it with a click. Full production-batch semantics: multi-copy runs, printer-class targeting, three fan-out strategies (max parallel / round robin / fill one first), a runs dashboard with cancel and retry-failed, and live updates (#1425).

- **Cam Wall** — a full-page grid of live camera tiles on the Printers page, with a per-tile print-status overlay, a bookmarkable /camwall URL, and a purpose-built read-only kiosk token for a lobby TV that exposes no serial, IP, or filename (#2531).

- **HMS error actions** — the error dialog goes from read-only to actionable: Resume / Stop / Ignore / Check Assistant and the rest now send the matching command to the printer, so you no longer have to walk to the machine to clear a pause (#1743, #1830).

- **AMS Filament Backup** — read and toggle the printer's auto-switch-to-a-second-spool state right from the card, and a paused runout now names the exact physical slot the printer is waiting for (#2587).

**Farm and stability:**

- Prints on a multi-printer farm no longer start one-by-one up to an hour apart — uploads run concurrently and the scheduler re-ticks as printers free up (#2555).

- A sustained session-hygiene and pool-sizing pass stops PostgreSQL connection-pool exhaustion on large farms, where camera streams, FTP work, and 3MF parsing had been holding DB connections across slow I/O (#2572, #2573).

- Docker finally shuts down cleanly instead of being SIGKILLed on every stop, and a run of multi-plate dispatch bugs that could map the wrong filament, log the wrong plate, or split a dispatch across two printers are fixed (#2551, #2552, #2603, #2614, #2615).

**Smaller-but-useful:** batch/mass edit on the Filament tab (#1795), structured storage-location and user-tag catalogs plus recursive search and per-folder README panels in the File Manager (#1505, #1268), continue-drying-while-printing on capable hardware, a dedicated AI Failure Detection notification event (#1794), sort Printers by ETA
(#1609), admin-configurable session lifetime (#1706), a full Russian translation (#2608, 11 languages total), and much more below.

---

**New Features**

Slicer Pipelines (#1425):
- Save and reuse a preset bundle in one click from the Slice modal (PR A).
- Run a pipeline on a file (library or archive) with one click, with pre-flight eligibility and a progress toast (PR B).
- Multi-copy batches, printer-class targeting, three fan-out strategies, a runs dashboard with cancel/retry-failed, and live WebSocket updates (PR C — completes the v3 design).

Cam Wall (#2531):
- Cam Wall view on the Printers page — a responsive grid of live camera tiles with on-screen/live-budget scheduling so it stays sustainable on a Pi.
- Per-tile print/printer status overlay (Off / Compact / Full).
- Bookmarkable /camwall URL plus a purpose-built read-only kiosk token that serves only what a tile draws — no serial, IP, access code, or filename.

Printer control and status:
- HMS error actions on the dashboard — Resume / Stop / Check Assistant etc. now send the matching MQTT command (#1743, contributed by @Ichicoro).
- AMS Filament Backup status + control on the printer card, read from the print.cfg bit and toggled over MQTT.
- A paused AMS runout now names the physical slot the printer is actually waiting for, with a pulsing highlight on the AMS graphic (#2587).
- AMS drying badge shows the active cycle's filament and target temperature.
- Live print progress for Virtual Printers in Bambu Studio / OrcaSlicer while keeping the Send button enabled (#1887).

Inventory:
- Batch / mass edit on the Filament tab — bulk edit / print labels / reset usage / archive / delete across both built-in and Spoolman modes (#1795).
- Structured storage-locations catalog (shelves, drawers, dryboxes) with Spoolman parity (#1505, closing #1004, contributed by @Poltavtcev).
- By-tag spool lookup readable with a Manage-Inventory API key, for scanner-driven integrations (#1700 closing #1663, contributed by @bambuman).
- Spoolman weight tracking for no-3MF "Untitled" prints, closing a parity gap with built-in inventory (#1820).

File Manager (#1268):
- User-authored tags for cross-cutting file filtering, independent of folders.
- Recursive search inside the selected folder, and a per-folder markdown README panel (collapsible right-hand rail).
- Page-wide drag-and-drop upload (#1510), and sort the folder tree by recent activity (#1770).

Drying:
- Continue auto-drying while a print is running, on capable hardware (opt-in, temperature-capped).

Notifications:
- Dedicated "AI Failure Detection" notification event so Obico detections stop riding the multiplexed Printer Error toggle (#1794).
- Inline finish-photo embed in failure-event emails via the {finish_photo_url} template variable, plus user_print_* template disambiguation (#1792).

Accounts, API keys, and layout:
- QR code on API-key creation that encodes server URL + key together for one-scan mobile setup (#1677, contributed by @bambuman).
- Admin-configurable session lifetime (24h / 7d / 30d, default 24h) (#1706).
- Centralised sidebar layout with per-page hide toggles and an admin default order (#1673, contributed by @EdwardChamberlain).
- Per-VP G-code injection toggle for Studio Send / FTP uploads (#1516, contributed by @phieb).

Other:
- Slice as designed — keep a MakerWorld author's own embedded settings when slicing server-side, when your printer matches the design's target (#2611).
- Sort the Printers page by ETA (#1609).
- Unified print dispatch through the queue scheduler, so every print is queueable, cancellable, and attributable (#1625, by @EdwardChamberlain).
- Sticky upload-progress toast restored for scheduler-driven dispatch (#1625 follow-up).
- Sponsor surfaces now ask a print farm (5+ printers) a commercial question instead of the hobbyist donation ask.
- Russian (Русский) UI translation — 11 languages total (#2608, contributed by @pterodaktil02).
- Appliance endpoints for NTP-gate state and locale/hostname/timezone defaults.

---

**Fixed**

Dispatch, scheduler, and farm scale:
- Prints on a multi-printer farm started one-by-one, up to an hour apart — uploads now run concurrently (#2555).
- A printer that accepted a file but never started was retried forever — now failed after three attempts (#2555).
- Every job waited up to 30 s after a printer freed up — the scheduler now re-ticks fast after a productive pass (#2555).
- PostgreSQL connection-pool exhaustion on large farms, plus a sustained session-hygiene pass so camera streams, cover/snapshot/timelapse, the print-start and finish-photo handlers, notification snapshots, FTP helpers, and SMTP no longer hold a DB connection across slow I/O (#2572).
- Startup connected printers serially (~100 s to first response on a 93-printer farm) — now concurrent (#2572).
- Queue polling re-parsed every 3MF on each poll — now a single combined parse cached by file revision (#2573).
- Large prints uploaded twice at once and never landed — deadline now scales with file size and actually cancels a too-slow transfer, with a per-printer upload lock (#2529).
- queue_max_concurrent_uploads behaved as a per-batch cap instead of a refillable pool (#2602).
- Reassigning a queue item mid-dispatch split it across two printers (#2615), a start dispatched to an already-busy printer could cancel the running job (#2598), and an unresolved AMS mapping silently dispatched to the empty external spool (#2589).

Multi-plate and filament mapping:
- Skip Objects listed the wrong plate's objects (#2522), and single-plate object lists could crash dispatch.
- Queueing several plates of one file mapped them all through the first plate's filaments (#2551), Filament Override vanished for a multi-plate selection (#2552), and Force color match made every plate wait for every colour (#2551).
- A single plate of a multi-plate 3MF recorded the whole file's filament in statistics (#2614), and multi-plate queue prints lost the selected plate in Print History (#2603).
- A print mapped to a different filament than it was sliced for was logged under the sliced material, not the one used (#2563).
- Multi-nozzle prints no longer collapse all filaments onto one nozzle (#1825), and nozzle sizes other than 0.4 mm are fully supported in the AMS slot picker + a pre-dispatch guard (#1899).

AMS and filament:
- A2L "AMS Lite" slots showed empty and never deducted filament (unit id 16 normalisation).
- The HT-A (AMS-HT) spool vanished a few seconds after power-on (#2594).
- External spool kept its old inventory filament after a type change (#2575), and configuring a built-in/generic filament reverted a moment later (#2604).
- An AMS slot with a non-Bambu (no-RFID) spool showed "Empty" instead of "?" (#2527), and the drying "Rotate spool" toggle is no longer offered when a tray is threaded out.

Camera:
- P1/A1 camera stayed black on load until a ~20-minute self-heal — late-subscriber priming + teardown discipline (#2521, #2521 follow-ups).
- Cam Wall no longer kills shared streams when one viewer closes, and offline tiles show OFF rather than LIVE.
- P2S RTSP timeout could leave the fan-out stream permanently stalled (#2580, diagnosed by @ronaldheft, fix shape from PR #2581).

Cloud, MakerWorld, and connectivity:
- Bambu Cloud dropped to "sign-in expired" and forced constant re-logins (any-401 now narrowed to the documented token-expiry response) (#2530-related, and the #2562 follow-up).
- Enabling authentication silently disconnected Bambu Cloud — the token now migrates onto the admin (and back) across the auth transition (#2530).
- "Please login." during MakerWorld import while showing Connected — cloud status is now authoritative.
- MakerWorld import on Windows failed with a certificate error (S3 hop now verifies against certifi) (#2562).
- MakerWorld import/resolve/status failed under API-key auth even with a valid owner cloud login (#1777).
- H2C prints intermittently recorded no filament — the H2C now gets the TLS 1.2 FTP profile (#2582), and H2C prints now deduct from inventory (#2582).

Shutdown and launchers:
- Docker never shut down gracefully — every stop/restart/update was a SIGKILL (exec uvicorn as PID 1).
- systemctl restart could hang 90 s and end in SIGKILL when a camera stream was open — every launcher now bounds the graceful-shutdown wait.

Smart plugs and energy:
- Energy Summary stuck at zero for Yesterday and Total on REST smart plugs, plus the whole smart-plug subsystem was broken on Postgres (naive-vs-aware datetimes) (#2539).
- Switching off an accessory smart plug at print end knocked the printer into "Unknown" and stalled the queue — plugs now carry a "Powers the printer" flag (#2629).

Other fixes:
- Reconnect/restart inflated Stats → Total Print Time by hundreds of hours (#2592).
- Scheduled backups to a NAS failed with EROFS because of our own systemd ProtectSystem sandbox — installers now carry custom ReadWritePaths forward and the UI diagnoses the real cause (#2544).
- Postgres → SQLite backup dropped NOT NULL / DEFAULT / FK / UNIQUE (#2526).
- Every SpoolBuddy screen crashed when a text field was focused (CJS interop) (#2616).
- The streaming overlay (/overlay) was blank in OBS with login enabled — now a token-authenticated kiosk surface (#2613).
- The AMS slot popup covered the filament dialog it opened, on touch devices (#2631).
- Slicing a single plate failed on a filament slot the plate doesn't use, and a profile could be auto-picked for a printer it doesn't belong to (#2628 and follow-up).
- Pushover Emergency priority (2) was rejected by the API (#2586); progress notification ran off-screen in the iPhone PWA (#2612).
- "Remember Me" appeared broken — an authenticated visit to /login now redirects (#1889).
- Packaging floors that permitted un-runnable resolutions: FastAPI < 0.116 204-route crash, sqlalchemy floor raised to 2.0.38, ruff pinned exactly; printer FTPS/MQTT now declare a TLS 1.2 floor explicitly.

(This is a condensed list — see CHANGELOG.md for the full detail on every entry, including tests and scope.)

---

**Security**

- Bumped linkify-it and dompurify to their patched releases (both build/production-tree hygiene; neither reachable path was exposed).
- Raised the Docker image's pip floor to 26.1.2 (PYSEC-2026-196) — build tooling only.
- Bumped two frontend dev-tooling dependencies (brace-expansion, js-yaml) with denial-of-service advisories — lint/build-time only, not in the shipped app.

---

**Merged community PRs in this release**

Thank you to everyone who contributed:

- #1625 @EdwardChamberlain — Unify print dispatch through the scheduler
- #1743 @Ichicoro — HMS error actions
- #1673 @EdwardChamberlain — Centralised sidebar ordering + page visibility
- #1516 @phieb — Per-VP G-code injection toggle
- #1700 @bambuman — By-tag spool lookup for Manage-Inventory keys (#1663)
- #1677 @bambuman — QR code on API-key creation
- #1505 @Poltavtcev — Structured storage locations catalog (#1004)
- #2608 @pterodaktil02 — Russian localization
- #2596 @Sawtaytoes — Virtual Printer "Any [model]" dispatch diagnosis (#2595)
- #2581 @ronaldheft — P2S RTSP stream-timeout fix shape (#2580)
- #2633 @dependabot — js-yaml security bump

---
**Sponsors**

Bambuddy is sustainable thanks to people who put their money where their use is. If this release saved you time or kept your farm running, the project runs on recurring contributions — there's no paid tier, no telemetry, no upsell, just sustainable maintenance.

- GitHub Sponsors (recurring, 5 tiers from $5/mo to $300/mo) — https://github.com/sponsors/maziggy
- Ko-fi (one-time or recurring) — https://ko-fi.com/maziggy
MartinNYHC 1 месяц назад
Родитель
Сommit
7f06c869d7
100 измененных файлов с 10556 добавлено и 1755 удалено
  1. 4 1
      .github/workflows/ci.yml
  2. 12 0
      CHANGELOG.md
  3. 16 2
      Dockerfile
  4. 1 1
      README.md
  5. 136 68
      backend/app/api/routes/archives.py
  6. 55 5
      backend/app/api/routes/auth.py
  7. 64 20
      backend/app/api/routes/camera.py
  8. 95 0
      backend/app/api/routes/camwall.py
  9. 294 45
      backend/app/api/routes/cloud.py
  10. 27 0
      backend/app/api/routes/inventory.py
  11. 52 9
      backend/app/api/routes/library.py
  12. 6 1
      backend/app/api/routes/library_tags.py
  13. 14 0
      backend/app/api/routes/local_backup.py
  14. 24 4
      backend/app/api/routes/makerworld.py
  15. 152 148
      backend/app/api/routes/orca_cloud.py
  16. 18 5
      backend/app/api/routes/pipeline_runs.py
  17. 133 40
      backend/app/api/routes/print_queue.py
  18. 293 70
      backend/app/api/routes/printers.py
  19. 12 2
      backend/app/api/routes/projects.py
  20. 24 43
      backend/app/api/routes/settings.py
  21. 12 7
      backend/app/api/routes/slice_jobs.py
  22. 63 8
      backend/app/api/routes/slicer_presets.py
  23. 19 11
      backend/app/api/routes/smart_plugs.py
  24. 47 12
      backend/app/api/routes/support.py
  25. 18 0
      backend/app/api/routes/system.py
  26. 162 19
      backend/app/core/auth.py
  27. 25 1
      backend/app/core/config.py
  28. 442 4
      backend/app/core/database.py
  29. 549 257
      backend/app/main.py
  30. 8 0
      backend/app/models/archive.py
  31. 24 4
      backend/app/models/print_queue.py
  32. 16 0
      backend/app/models/smart_plug.py
  33. 8 0
      backend/app/models/user.py
  34. 1 0
      backend/app/schemas/archive.py
  35. 4 0
      backend/app/schemas/cloud.py
  36. 4 0
      backend/app/schemas/makerworld.py
  37. 4 0
      backend/app/schemas/notification.py
  38. 27 35
      backend/app/schemas/orca_cloud.py
  39. 45 17
      backend/app/schemas/print_queue.py
  40. 19 0
      backend/app/schemas/printer.py
  41. 7 0
      backend/app/schemas/project.py
  42. 22 8
      backend/app/schemas/settings.py
  43. 15 0
      backend/app/schemas/slicer.py
  44. 12 0
      backend/app/schemas/smart_plug.py
  45. 47 29
      backend/app/services/archive.py
  46. 200 0
      backend/app/services/backup_path.py
  47. 213 14
      backend/app/services/bambu_cloud.py
  48. 229 21
      backend/app/services/bambu_ftp.py
  49. 647 30
      backend/app/services/bambu_mqtt.py
  50. 106 9
      backend/app/services/camera_fanout.py
  51. 51 0
      backend/app/services/filament_requirements.py
  52. 28 9
      backend/app/services/ftp_profiles.py
  53. 30 34
      backend/app/services/local_backup.py
  54. 37 8
      backend/app/services/long_lived_tokens.py
  55. 77 8
      backend/app/services/makerworld.py
  56. 57 17
      backend/app/services/notification_service.py
  57. 235 279
      backend/app/services/orca_cloud.py
  58. 119 0
      backend/app/services/plug_energy_history.py
  59. 18 3
      backend/app/services/print_log.py
  60. 689 48
      backend/app/services/print_scheduler.py
  61. 24 4
      backend/app/services/printer_diagnostic.py
  62. 164 14
      backend/app/services/printer_manager.py
  63. 45 22
      backend/app/services/rest_smart_plug.py
  64. 6 0
      backend/app/services/slice_dispatch.py
  65. 33 12
      backend/app/services/slicer_3mf_convert.py
  66. 49 29
      backend/app/services/smart_plug_manager.py
  67. 3 0
      backend/app/services/spool_assignment_notifications.py
  68. 109 32
      backend/app/services/spoolman_tracking.py
  69. 56 0
      backend/app/services/usage_tracker.py
  70. 64 5
      backend/app/services/virtual_printer/manager.py
  71. 101 26
      backend/app/services/virtual_printer/mqtt_server.py
  72. 112 0
      backend/app/utils/local_time.py
  73. 73 0
      backend/app/utils/printer_models.py
  74. 180 132
      backend/app/utils/threemf_tools.py
  75. 16 0
      backend/tests/conftest.py
  76. 26 0
      backend/tests/integration/test_camera_api.py
  77. 203 0
      backend/tests/integration/test_camwall_api.py
  78. 13 2
      backend/tests/integration/test_cloud_auth.py
  79. 277 0
      backend/tests/integration/test_cloud_token_auth_migration.py
  80. 70 0
      backend/tests/integration/test_drying_screen_only.py
  81. 98 0
      backend/tests/integration/test_external_folders_api.py
  82. 83 0
      backend/tests/integration/test_inventory_assign.py
  83. 97 0
      backend/tests/integration/test_library_slice_api.py
  84. 3 3
      backend/tests/integration/test_makerworld_apikey_auth.py
  85. 70 39
      backend/tests/integration/test_oidc_icon_blob_roundtrip.py
  86. 176 0
      backend/tests/integration/test_orca_cloud_device.py
  87. 216 0
      backend/tests/integration/test_overlay_status_api.py
  88. 365 0
      backend/tests/integration/test_ownership_permissions.py
  89. 510 30
      backend/tests/integration/test_print_queue_api.py
  90. 213 1
      backend/tests/integration/test_printers_api.py
  91. 95 0
      backend/tests/integration/test_projects_api.py
  92. 32 14
      backend/tests/integration/test_settings_api.py
  93. 66 0
      backend/tests/integration/test_stats_reconciled_duration_2592.py
  94. 137 0
      backend/tests/integration/test_timelapse_scan_session.py
  95. 3 3
      backend/tests/integration/test_webhook_start_print.py
  96. 153 0
      backend/tests/unit/services/test_backup_path.py
  97. 193 0
      backend/tests/unit/services/test_bambu_ftp.py
  98. 317 31
      backend/tests/unit/services/test_bambu_mqtt.py
  99. 245 0
      backend/tests/unit/services/test_camera_fanout.py
  100. 92 0
      backend/tests/unit/services/test_external_spool_change.py

+ 4 - 1
.github/workflows/ci.yml

@@ -42,7 +42,10 @@ jobs:
           python-version: ${{ env.PYTHON_VERSION }}
 
       - name: Install ruff
-        run: pip install ruff
+        # Install the exact pin from requirements-dev.txt rather than the latest
+        # release, so CI and contributors run the same linter. `pip install ruff`
+        # silently drifted ahead of every local venv.
+        run: pip install "$(grep -E '^ruff==' requirements-dev.txt)"
 
       - name: Run ruff check
         run: ruff check backend/

Разница между файлами не показана из-за своего большого размера
+ 12 - 0
CHANGELOG.md


+ 16 - 2
Dockerfile

@@ -54,7 +54,7 @@ RUN setcap cap_net_bind_service=+ep "$(readlink -f /usr/local/bin/python3)"
 # wheels (so a hostile wheel could hijack stdlib imports during install).
 COPY requirements.txt ./
 RUN --mount=type=cache,target=/root/.cache/pip \
-    pip install --root-user-action=ignore --upgrade 'pip>=26.1' \
+    pip install --root-user-action=ignore --upgrade 'pip>=26.1.2' \
  && pip install --root-user-action=ignore -r requirements.txt
 
 # Copy backend
@@ -150,5 +150,19 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
 # Port is configurable via PORT (default 8000); bind address via HOST (default
 # 0.0.0.0). Set HOST=127.0.0.1 to bind loopback only, e.g. when a reverse proxy
 # on the same host fronts the app.
+#
+# `exec` is load-bearing, not style. Without it the shell stays as PID 1 and
+# uvicorn runs as its child; dash does not forward signals, so `docker stop`
+# SIGTERMs the shell and uvicorn never hears about it. Every stop then ran to
+# the end of the grace period and died on SIGKILL (exit 137) — no WAL
+# checkpoint, no MQTT disconnect, no virtual-printer teardown, on every restart
+# and every image update. With `exec`, uvicorn *is* PID 1 and gets the signal.
+#
+# --timeout-graceful-shutdown caps the wait on in-flight requests. Uvicorn's
+# default is to wait forever, and an MJPEG camera stream is a response that
+# never completes, so a single open camera tile would otherwise pin the process
+# past Docker's 10s grace and back into SIGKILL. On timeout uvicorn cancels the
+# request tasks; the camera generators already unwind cleanly on CancelledError.
+ENV UVICORN_TIMEOUT_GRACEFUL_SHUTDOWN=5
 ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
-CMD ["sh", "-c", "uvicorn backend.app.main:app --host ${HOST:-0.0.0.0} --port ${PORT:-8000} --loop asyncio"]
+CMD ["sh", "-c", "exec uvicorn backend.app.main:app --host ${HOST:-0.0.0.0} --port ${PORT:-8000} --loop asyncio --timeout-graceful-shutdown ${UVICORN_TIMEOUT_GRACEFUL_SHUTDOWN}"]

+ 1 - 1
README.md

@@ -373,7 +373,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 
 ## 📸 Screenshots
 
-> **Refreshed printer card in 0.2.5b2** — tighter layout, popovers for all controls (temperature setpoints, fan speeds, jog), and a bottom-aligned power row. The screenshots below predate the refresh.
+> **Refreshed printer card in 1.2.5b2** — tighter layout, popovers for all controls (temperature setpoints, fan speeds, jog), and a bottom-aligned power row. The screenshots below predate the refresh.
 
 <details>
 <summary><strong>Click to expand screenshots</strong></summary>

+ 136 - 68
backend/app/api/routes/archives.py

@@ -593,8 +593,12 @@ async def list_archives_slim(
                 # print_time_seconds (slicer estimate) for non-completed
                 # events would diverge from Quick Stats — so expose the
                 # measured value here unconditionally.
+                #
+                # Trust an explicit 0 (reconciled aborts store it deliberately;
+                # their real end time is unknown) instead of recomputing the
+                # multi-day disconnect gap from the timestamps (#2592).
                 r.duration_seconds
-                if r.duration_seconds and r.duration_seconds > 0
+                if r.duration_seconds is not None
                 else (
                     int((r.completed_at - r.started_at).total_seconds())
                     if r.started_at and r.completed_at and (r.completed_at - r.started_at).total_seconds() > 0
@@ -1071,7 +1075,12 @@ async def get_archive_stats(
     )
     total_seconds = 0
     for duration_seconds, started_at, completed_at in time_rows.all():
-        if duration_seconds:
+        # Trust an explicitly stored duration, INCLUDING 0: a reconciled abort
+        # stores 0 on purpose because its real end time is unknown, and the
+        # started_at→completed_at fallback would otherwise bank the whole
+        # multi-day disconnect gap as print time (#2592). Only rows with a NULL
+        # duration (legacy entries that never recorded one) fall back.
+        if duration_seconds is not None:
             total_seconds += duration_seconds
         elif started_at and completed_at:
             elapsed = (completed_at - started_at).total_seconds()
@@ -1277,6 +1286,13 @@ async def _sum_snapshot_deltas(
     """
     from backend.app.models.smart_plug import SmartPlug
     from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
+    from backend.app.utils.local_time import to_naive_utc
+
+    # ``recorded_at`` is a naive column holding UTC. Binding an aware datetime
+    # against it raises DataError on asyncpg (SQLite silently drops the offset),
+    # which took the whole date-filtered energy figure down on Postgres.
+    dt_from = to_naive_utc(dt_from)
+    dt_to = to_naive_utc(dt_to)
 
     plug_ids_result = await db.execute(select(SmartPlug.id))
     plug_ids = [row[0] for row in plug_ids_result.all()]
@@ -1651,13 +1667,17 @@ async def update_archive(
 async def toggle_favorite(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_UPDATE_ALL,
+            Permission.ARCHIVES_UPDATE_OWN,
+        )
+    ),
 ):
     """Toggle favorite status for an archive."""
+    user, can_modify_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = result.scalar_one_or_none()
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all)
 
     archive.is_favorite = not archive.is_favorite
     await db.commit()
@@ -2206,13 +2226,17 @@ async def get_timelapse(
 async def delete_timelapse(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_OWN),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_DELETE_ALL,
+            Permission.ARCHIVES_DELETE_OWN,
+        )
+    ),
 ):
     """Remove the timelapse video from an archive."""
+    user, can_modify_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = result.scalar_one_or_none()
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all)
 
     if not archive.timelapse_path:
         raise HTTPException(404, "No timelapse attached to this archive")
@@ -2232,10 +2256,10 @@ async def delete_timelapse(
 @router.post("/{archive_id}/timelapse/scan")
 async def scan_timelapse(
     archive_id: int,
-    db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
 ):
     """Scan printer for timelapse matching this archive and attach it."""
+    from backend.app.core.database import async_session
     from backend.app.models.printer import Printer
     from backend.app.services.bambu_ftp import (
         download_file_bytes_async,
@@ -2244,22 +2268,27 @@ async def scan_timelapse(
         with_ftp_retry,
     )
 
-    service = ArchiveService(db)
-    archive = await service.get_archive(archive_id)
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    # Read the archive + printer in a short session and release the pooled DB
+    # connection BEFORE the FTP scan/download below — a timelapse pull walks
+    # several directories and fetches a 100MB+ video, so holding Depends(get_db)
+    # across it pinned one connection idle-in-transaction for minutes (#2572).
+    # Scalar columns stay readable on the detached rows (expire_on_commit=False);
+    # the attach at the end runs in its own fresh short session.
+    async with async_session() as db:
+        archive = await ArchiveService(db).get_archive(archive_id)
+        if not archive:
+            raise HTTPException(404, "Archive not found")
 
-    if archive.timelapse_path:
-        return {"status": "exists", "message": "Timelapse already attached"}
+        if archive.timelapse_path:
+            return {"status": "exists", "message": "Timelapse already attached"}
 
-    if not archive.printer_id:
-        raise HTTPException(400, "Archive has no associated printer")
+        if not archive.printer_id:
+            raise HTTPException(400, "Archive has no associated printer")
 
-    # Get printer
-    result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
-    printer = result.scalar_one_or_none()
-    if not printer:
-        raise HTTPException(404, "Printer not found")
+        result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
+        printer = result.scalar_one_or_none()
+        if not printer:
+            raise HTTPException(404, "Printer not found")
 
     # Get base name from archive filename (without .3mf extension)
     base_name = Path(archive.filename).stem
@@ -2398,8 +2427,9 @@ async def scan_timelapse(
     if not timelapse_data:
         raise HTTPException(500, "Failed to download timelapse")
 
-    # Attach timelapse to archive
-    success = await service.attach_timelapse(archive_id, timelapse_data, matching_file["name"])
+    # Attach in a fresh short session (the read session was released before FTP).
+    async with async_session() as db:
+        success = await ArchiveService(db).attach_timelapse(archive_id, timelapse_data, matching_file["name"])
 
     if not success:
         raise HTTPException(500, "Failed to attach timelapse")
@@ -2415,10 +2445,10 @@ async def scan_timelapse(
 async def select_timelapse(
     archive_id: int,
     filename: str = Query(..., description="Timelapse filename to attach"),
-    db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
 ):
     """Manually select a timelapse from the printer to attach."""
+    from backend.app.core.database import async_session
     from backend.app.models.printer import Printer
     from backend.app.services.bambu_ftp import (
         download_file_bytes_async,
@@ -2427,18 +2457,21 @@ async def select_timelapse(
         with_ftp_retry,
     )
 
-    service = ArchiveService(db)
-    archive = await service.get_archive(archive_id)
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    # Read the archive + printer in a short session and release the pooled DB
+    # connection BEFORE the FTP scan/download below (#2572); scalars stay
+    # readable after close (expire_on_commit=False), the attach reopens one.
+    async with async_session() as db:
+        archive = await ArchiveService(db).get_archive(archive_id)
+        if not archive:
+            raise HTTPException(404, "Archive not found")
 
-    if not archive.printer_id:
-        raise HTTPException(400, "Archive has no associated printer")
+        if not archive.printer_id:
+            raise HTTPException(400, "Archive has no associated printer")
 
-    result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
-    printer = result.scalar_one_or_none()
-    if not printer:
-        raise HTTPException(404, "Printer not found")
+        result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
+        printer = result.scalar_one_or_none()
+        if not printer:
+            raise HTTPException(404, "Printer not found")
 
     # Find the file on the printer
     files = []
@@ -2487,7 +2520,9 @@ async def select_timelapse(
     if not timelapse_data:
         raise HTTPException(500, "Failed to download timelapse")
 
-    success = await service.attach_timelapse(archive_id, timelapse_data, filename)
+    # Attach in a fresh short session (the read session was released before FTP).
+    async with async_session() as db:
+        success = await ArchiveService(db).attach_timelapse(archive_id, timelapse_data, filename)
     if not success:
         raise HTTPException(500, "Failed to attach timelapse")
 
@@ -2720,13 +2755,17 @@ async def upload_photo(
     archive_id: int,
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_UPDATE_ALL,
+            Permission.ARCHIVES_UPDATE_OWN,
+        )
+    ),
 ):
     """Upload a photo of the printed result."""
+    user, can_modify_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = result.scalar_one_or_none()
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all)
 
     if not file.filename or not file.filename.lower().endswith((".jpg", ".jpeg", ".png", ".webp")):
         raise HTTPException(400, "File must be an image (.jpg, .jpeg, .png, .webp)")
@@ -2810,13 +2849,17 @@ async def delete_photo(
     archive_id: int,
     filename: str,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_OWN),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_DELETE_ALL,
+            Permission.ARCHIVES_DELETE_OWN,
+        )
+    ),
 ):
     """Delete a photo."""
+    user, can_modify_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = result.scalar_one_or_none()
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all)
 
     if not archive.photos or filename not in archive.photos:
         raise HTTPException(404, "Photo not found")
@@ -3940,8 +3983,12 @@ async def slice_archive(
     )
 
     archive = await db.get(PrintArchive, archive_id)
-    if archive is None:
-        raise HTTPException(status_code=404, detail="Archive not found")
+    # Per-row ownership gate — mirror the archive read routes. LIBRARY_UPLOAD
+    # alone let a READ_OWN caller slice another user's archive by raw id even
+    # though GET on that id returned 404. API-key / auth-disabled callers
+    # (current_user is None) keep can_read_all=True — no per-row identity.
+    can_read_all = current_user is None or current_user.has_permission(Permission.ARCHIVES_READ_ALL.value)
+    archive = _ensure_archive_visible(archive, current_user, can_read_all)
 
     src_relative = archive.source_3mf_path or archive.file_path
     if not src_relative:
@@ -4012,6 +4059,7 @@ async def slice_archive(
         kind="archive",
         source_id=archive.id,
         source_name=archive.print_name or archive.filename or f"archive {archive.id}",
+        owner_id=user_id,
         run=_run,
     )
     return {
@@ -4084,15 +4132,19 @@ async def update_project_page(
     archive_id: int,
     update_data: dict,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_UPDATE_ALL,
+            Permission.ARCHIVES_UPDATE_OWN,
+        )
+    ),
 ):
     """Update project page metadata in the 3MF file."""
     from backend.app.services.archive import ProjectPageParser
 
+    user, can_modify_all = auth_result
     service = ArchiveService(db)
-    archive = await service.get_archive(archive_id)
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_modify_all)
 
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
@@ -4202,13 +4254,17 @@ async def upload_source_3mf(
     archive_id: int,
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_UPDATE_ALL,
+            Permission.ARCHIVES_UPDATE_OWN,
+        )
+    ),
 ):
     """Upload the original source 3MF project file for an archive."""
+    user, can_modify_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = result.scalar_one_or_none()
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all)
 
     if not file.filename or not file.filename.endswith(".3mf"):
         raise HTTPException(400, "File must be a .3mf file")
@@ -4463,13 +4519,17 @@ async def upload_source_3mf_by_name(
 async def delete_source_3mf(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_OWN),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_DELETE_ALL,
+            Permission.ARCHIVES_DELETE_OWN,
+        )
+    ),
 ):
     """Delete the source 3MF project file from an archive."""
+    user, can_modify_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = result.scalar_one_or_none()
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all)
 
     if not archive.source_3mf_path:
         raise HTTPException(404, "No source 3MF attached to this archive")
@@ -4496,13 +4556,17 @@ async def upload_f3d(
     archive_id: int,
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_UPDATE_ALL,
+            Permission.ARCHIVES_UPDATE_OWN,
+        )
+    ),
 ):
     """Upload a Fusion 360 design file for an archive."""
+    user, can_modify_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = result.scalar_one_or_none()
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all)
 
     if not file.filename or not file.filename.endswith(".f3d"):
         raise HTTPException(400, "File must be a .f3d file")
@@ -4576,13 +4640,17 @@ async def download_f3d(
 async def delete_f3d(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_OWN),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_DELETE_ALL,
+            Permission.ARCHIVES_DELETE_OWN,
+        )
+    ),
 ):
     """Delete the Fusion 360 design file from an archive."""
+    user, can_modify_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = result.scalar_one_or_none()
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all)
 
     if not archive.f3d_path:
         raise HTTPException(404, "No F3D file attached to this archive")

+ 55 - 5
backend/app/api/routes/auth.py

@@ -189,9 +189,15 @@ async def set_advanced_auth_enabled(db: AsyncSession, enabled: bool) -> None:
 
 async def set_auth_enabled(db: AsyncSession, enabled: bool) -> None:
     """Set authentication enabled status."""
+    from backend.app.core.auth import invalidate_auth_enabled_cache
     from backend.app.core.db_dialect import upsert_setting
 
     await upsert_setting(db, Settings, "auth_enabled", "true" if enabled else "false")
+    # Drop the cached auth-enabled flag so the change takes effect immediately
+    # instead of after the TTL (issue #2572). Safe pre-commit: only enabled=True
+    # is ever cached, and the newly-enabled True isn't visible to other sessions
+    # until this transaction commits, so no stale value can be re-cached here.
+    invalidate_auth_enabled_cache()
     # Note: Don't commit here - let get_db handle it or commit explicitly in the route
 
 
@@ -295,6 +301,38 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
                         detail="Failed to create admin user",
                     )
 
+        if request.auth_enabled:
+            # Enabling auth flips cloud-credential storage from the global
+            # Settings rows to User.cloud_token. Carry any token linked while
+            # auth was off across to the owning admin, or /cloud/* silently
+            # degrades to local presets with no indication anything broke
+            # (#2530). Only migrate when there is exactly one obvious owner:
+            # handing another admin's session a Bambu credential is not a
+            # guess worth making.
+            from backend.app.api.routes.cloud import (
+                get_stored_token,
+                migrate_global_cloud_token_to_user,
+            )
+
+            if admin_created:
+                cloud_owner = admin_user
+            elif len(existing_admin_users) == 1:
+                cloud_owner = existing_admin_users[0]
+            else:
+                cloud_owner = None
+
+            if cloud_owner is not None:
+                if await migrate_global_cloud_token_to_user(db, cloud_owner):
+                    logger.info("Migrated global Bambu Cloud credentials to admin '%s'", cloud_owner.username)
+            else:
+                global_token, _, _ = await get_stored_token(db, None)
+                if global_token:
+                    logger.warning(
+                        "A Bambu Cloud account is linked globally but %s admins exist; "
+                        "leaving it unassigned. Re-link the account from Settings after login.",
+                        len(existing_admin_users),
+                    )
+
         # Set auth enabled and mark setup as completed
         await set_auth_enabled(db, request.auth_enabled)
         await set_setup_completed(db, True)
@@ -349,6 +387,14 @@ async def disable_auth(
         )
 
     try:
+        # Mirror of the migration in setup_auth: with auth off the cloud routes
+        # read the global Settings rows and never look at User.cloud_token, so
+        # hand this admin's credential over rather than stranding it (#2530).
+        from backend.app.api.routes.cloud import migrate_user_cloud_token_to_global
+
+        if await migrate_user_cloud_token_to_global(db, user):
+            logger.info("Migrated Bambu Cloud credentials from admin '%s' to global storage", user.username)
+
         await set_auth_enabled(db, False)
         await db.commit()
         logger.info("Authentication disabled by admin user: %s", user.username)
@@ -619,7 +665,7 @@ async def get_current_user_info(
                     headers={"WWW-Authenticate": "Bearer"},
                 )
             jti: str | None = payload.get("jti")
-            if not jti or await is_jti_revoked(jti):  # B1: logout bypass fix
+            if not jti or await is_jti_revoked(jti, db):  # B1: logout bypass fix
                 raise HTTPException(
                     status_code=status.HTTP_401_UNAUTHORIZED,
                     detail="Could not validate credentials",
@@ -1577,10 +1623,14 @@ async def provision_ldap_user(
 # =============================================================================
 # Long-lived camera-stream tokens (#1108)
 # =============================================================================
-# Camera-only V1. Issue scope: a token a user can paste into Home Assistant /
-# Frigate / a kiosk and have it keep working for days/weeks rather than
-# refreshing the 60-minute ephemeral token. Permission gate: CAMERA_VIEW
-# (same blast radius as the existing 60-min token-mint endpoint).
+# A token a user can paste into Home Assistant / Frigate / a kiosk and have it
+# keep working for days/weeks rather than refreshing the 60-minute ephemeral
+# token. Permission gate: CAMERA_VIEW (same blast radius as the existing 60-min
+# token-mint endpoint).
+#
+# Two scopes, both minted here — see ALLOWED_SCOPES in services/long_lived_tokens
+# for what each one reaches: "camera_stream" (video only) and "camwall" (video
+# plus the Cam Wall's read-only tile metadata, #2531).
 
 
 def _long_lived_token_to_response(record, *, plaintext: str | None = None) -> dict:

+ 64 - 20
backend/app/api/routes/camera.py

@@ -12,6 +12,7 @@ from fastapi.responses import Response, StreamingResponse
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core import database
 from backend.app.core.auth import (
     RequireCameraStreamTokenIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
@@ -44,6 +45,14 @@ from backend.app.services.camera_profiles import get_camera_profile
 logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/printers", tags=["camera"])
 
+# Upper bound on waiting for a SIGKILLed ffmpeg to be reaped (#2580). A killed
+# ffmpeg stuck in uninterruptible I/O on a dead RTSP socket can take arbitrarily
+# long to exit — an unbounded post-kill wait() parked the fan-out stream
+# coroutine for 12 hours on a P2S, leaving every viewer attached to a stalled
+# broadcaster. Abandoning the wait is safe: cleanup_orphaned_streams' /proc scan
+# reaps any Bambu ffmpeg not attached to an active stream on its next pass.
+_FFMPEG_KILL_TIMEOUT = 2.0
+
 # Track active ffmpeg processes for cleanup
 _active_streams: dict[str, asyncio.subprocess.Process] = {}
 
@@ -242,7 +251,17 @@ async def _terminate_ffmpeg(process: asyncio.subprocess.Process, stream_id: str
         except TimeoutError:
             logger.warning("ffmpeg didn't terminate gracefully, killing (stream_id=%s)", stream_id)
             process.kill()
-            await process.wait()
+            try:
+                await asyncio.wait_for(process.wait(), timeout=_FFMPEG_KILL_TIMEOUT)
+            except TimeoutError:
+                # Do NOT keep waiting (#2580): the caller is the stream
+                # generator, and blocking here pins the fan-out pump forever.
+                # The orphan janitor reaps the process later.
+                logger.error(
+                    "ffmpeg did not exit within %.1fs of SIGKILL; abandoning wait (stream_id=%s)",
+                    _FFMPEG_KILL_TIMEOUT,
+                    stream_id,
+                )
     except ProcessLookupError:
         pass  # Already dead
     except OSError as e:
@@ -609,7 +628,6 @@ async def camera_stream(
     printer_id: int,
     request: Request,
     fps: int = 10,
-    db: AsyncSession = Depends(get_db),
     _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
     """Stream live video from printer camera as MJPEG.
@@ -628,7 +646,22 @@ async def camera_stream(
         printer_id: Printer ID
         fps: Target frames per second (default: 10, max: 30)
     """
-    printer = await get_printer_or_404(printer_id, db)
+    # Fetch the printer in a short-lived session so the pooled DB connection is
+    # released BEFORE we start streaming. A live MJPEG stream runs for as long
+    # as the browser tab stays open (potentially hours); holding the
+    # Depends(get_db) session across it pinned one pooled connection per open
+    # camera tab per printer — a top contributor to pool exhaustion on large
+    # farms (issue #2572). expire_on_commit=False keeps the printer's already-
+    # loaded columns readable after the session closes, and everything below
+    # reads only scalar attributes (model, ip_address, access_code,
+    # external_camera_*) — no lazy loads.
+    #
+    # Reference async_session via the module (not a top-level import binding) so
+    # the session maker is looked up at call time — that keeps it in sync with
+    # reinitialize_database() and lets the test harness's patch of
+    # backend.app.core.database.async_session take effect here.
+    async with database.async_session() as db:
+        printer = await get_printer_or_404(printer_id, db)
 
     # Check for external camera first
     if printer.external_camera_enabled and printer.external_camera_url:
@@ -814,20 +847,13 @@ async def stop_camera_stream(
             if event:
                 event.set()
             if process.returncode is None:
-                try:
-                    process.terminate()
-                    try:
-                        await asyncio.wait_for(process.wait(), timeout=2.0)
-                    except TimeoutError:
-                        logger.warning("ffmpeg didn't terminate gracefully, killing (stream_id=%s)", stream_id)
-                        process.kill()
-                        await process.wait()
-                    stopped += 1
-                    logger.info("Terminated ffmpeg process for stream %s", stream_id)
-                except ProcessLookupError:
-                    pass  # Process already dead
-                except OSError as e:
-                    logger.warning("Error stopping stream %s: %s", stream_id, e)
+                # Shared helper, not an inline copy: it bounds the post-kill
+                # wait (#2580) — a killed-but-unreaped ffmpeg used to hang this
+                # request forever, exactly when the user hit Stop to recover a
+                # stuck stream.
+                await _terminate_ffmpeg(process, stream_id)
+                stopped += 1
+                logger.info("Terminated ffmpeg process for stream %s", stream_id)
             _spawned_ffmpeg_pids.pop(process.pid, None)
 
     for stream_id in to_remove:
@@ -863,7 +889,6 @@ async def stop_camera_stream(
 @router.get("/{printer_id}/camera/snapshot")
 async def camera_snapshot(
     printer_id: int,
-    db: AsyncSession = Depends(get_db),
     _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
     """Capture a single frame from the printer camera.
@@ -875,7 +900,15 @@ async def camera_snapshot(
     import tempfile
     from pathlib import Path
 
-    printer = await get_printer_or_404(printer_id, db)
+    # Fetch the printer in a short-lived session and release the pooled DB
+    # connection BEFORE the camera capture below (up to 15s, longer under a
+    # saturated FTP/camera pool). Holding a Depends(get_db) session across the
+    # grab pinned one connection per snapshot — and the cam wall polls this
+    # per tile every 8s — so overlapping captures could pile up connections on
+    # a large farm (issue #2572, sibling of the camera_stream fix). Everything
+    # below reads only already-loaded scalar columns (expire_on_commit=False).
+    async with database.async_session() as db:
+        printer = await get_printer_or_404(printer_id, db)
 
     # Check for external camera first
     if printer.external_camera_enabled and printer.external_camera_url:
@@ -1600,9 +1633,20 @@ async def cleanup_orphaned_streams():
                 event.set()
             try:
                 proc.kill()
-                await proc.wait()
+                # Bounded (#2580): an unreaped SIGKILLed ffmpeg must not hang
+                # the periodic cleanup loop — this janitor is the safety net
+                # that recovers stalled streams, so it can least afford to
+                # block. The /proc scan above retries the kill next pass.
+                await asyncio.wait_for(proc.wait(), timeout=_FFMPEG_KILL_TIMEOUT)
             except (ProcessLookupError, OSError):
                 pass
+            except TimeoutError:
+                logger.error(
+                    "ffmpeg (pid=%d) did not exit within %.1fs of SIGKILL; abandoning wait (stream_id=%s)",
+                    proc.pid,
+                    _FFMPEG_KILL_TIMEOUT,
+                    sid,
+                )
             _active_streams.pop(sid, None)
             _disconnect_events.pop(sid, None)
             _stream_last_frame_times.pop(sid, None)

+ 95 - 0
backend/app/api/routes/camwall.py

@@ -0,0 +1,95 @@
+"""Read-only Cam Wall feed for token-authenticated kiosk displays (#2531).
+
+The Cam Wall inside the SPA runs on the ordinary printers API, behind a JWT. A
+wall pinned to a TV has no login, so it authenticates with a long-lived
+``camwall``-scoped token carried in the URL — and a URL on a lobby screen is
+about as private as a sticky note.
+
+That is why this endpoint exists instead of letting a token through to
+``GET /printers``: the printer list carries ``serial_number`` and
+``ip_address`` (see ``schemas/printer.py``), and neither belongs on a screen in
+a shared room. What a wall tile actually draws is the whole payload here — a
+name, a connection flag, a state, a progress bar.
+
+Notably absent is the print filename. A token wall renders the compact status
+overlay, so the part being printed is never named to the room; the field simply
+isn't served rather than being served and then hidden client-side.
+"""
+
+import logging
+
+from fastapi import APIRouter, Depends
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import RequireCamWallTokenIfAuthEnabled
+from backend.app.core.database import get_db
+from backend.app.models.printer import Printer
+from backend.app.services.printer_manager import printer_manager
+
+_logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/camwall", tags=["camwall"])
+
+
+@router.get("/printers")
+async def list_camwall_printers(
+    _: None = RequireCamWallTokenIfAuthEnabled,
+    db: AsyncSession = Depends(get_db),
+) -> list[dict]:
+    """Every printer plus the handful of status fields a Cam Wall tile draws.
+
+    One call for the whole wall rather than one per printer: a kiosk polls this
+    on a fixed interval with no WebSocket to invalidate it, and N+1 requests
+    every few seconds is a poor trade for a screen nobody is interacting with.
+
+    Ordered by name so tile positions stay put across polls — a wall that
+    reshuffles itself is unusable to watch.
+    """
+    result = await db.execute(select(Printer).order_by(Printer.name))
+    printers = list(result.scalars().all())
+
+    payload: list[dict] = []
+    for printer in printers:
+        state = printer_manager.get_status(printer.id)
+        entry: dict = {
+            "id": printer.id,
+            "name": printer.name,
+            "camera_rotation": printer.camera_rotation or 0,
+            # Mirrors get_printer_status(): no state object at all means the
+            # printer was never connected this run; a state object still has
+            # to be asked whether its link is currently up.
+            "connected": bool(state and state.connected),
+            "state": None,
+            "progress": None,
+            "remaining_time": None,
+            "layer_num": None,
+            "total_layers": None,
+            # Codes only — enough for the client to run the same
+            # filterKnownHMSErrors() it uses on the authenticated wall, so the
+            # error chip means the same thing in both modes.
+            "hms_errors": [],
+        }
+        if state is not None:
+            entry.update(
+                {
+                    "state": state.state,
+                    "progress": state.progress,
+                    "remaining_time": state.remaining_time,
+                    "layer_num": state.layer_num,
+                    "total_layers": state.total_layers,
+                    "hms_errors": [
+                        {
+                            "code": e.code,
+                            "attr": e.attr,
+                            "module": e.module,
+                            "severity": e.severity,
+                            "actions": e.actions or [],
+                        }
+                        for e in (state.hms_errors or [])
+                    ],
+                }
+            )
+        payload.append(entry)
+
+    return payload

+ 294 - 45
backend/app/api/routes/cloud.py

@@ -4,14 +4,16 @@ Bambu Lab Cloud API Routes
 Handles authentication and profile management with Bambu Cloud.
 """
 
+import asyncio
 import json
 import logging
+from datetime import datetime, timezone
 from pathlib import Path
 from typing import Literal
 
 from fastapi import APIRouter, Body, Depends, Header, HTTPException, Request
 from fastapi.security import HTTPAuthorizationCredentials
-from sqlalchemy import select
+from sqlalchemy import select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import (
@@ -21,7 +23,7 @@ from backend.app.core.auth import (
     require_permission_if_auth_enabled,
     security,
 )
-from backend.app.core.database import get_db
+from backend.app.core.database import async_session, get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.api_key import APIKey
 from backend.app.models.settings import Settings
@@ -46,6 +48,7 @@ from backend.app.services.bambu_cloud import (
     BambuCloudAuthError,
     BambuCloudError,
     BambuCloudService,
+    invalidate_validation_cache,
 )
 from backend.app.utils.filament_ids import filament_id_to_setting_id
 
@@ -167,6 +170,9 @@ router = APIRouter(prefix="/cloud", tags=["cloud"], dependencies=[Depends(_cloud
 CLOUD_TOKEN_KEY = "bambu_cloud_token"
 CLOUD_EMAIL_KEY = "bambu_cloud_email"
 CLOUD_REGION_KEY = "bambu_cloud_region"
+# Global (auth-disabled) counterpart of ``User.cloud_token_invalid_at``. Stores
+# an ISO timestamp; absent/empty means "not known to be dead".
+CLOUD_TOKEN_INVALID_KEY = "bambu_cloud_token_invalid_at"
 
 
 def _normalise_region(region: str | None) -> str:
@@ -174,6 +180,63 @@ def _normalise_region(region: str | None) -> str:
     return region if region in ("global", "china") else "global"
 
 
+async def is_cloud_token_invalid(db: AsyncSession, user: User | None = None) -> bool:
+    """Whether the stored Bambu token is known to have been rejected.
+
+    Set by :func:`mark_cloud_token_invalid` the first time Bambu answers 401,
+    cleared on a fresh login/logout. This is the only durable record we have:
+    Bambu's access token is opaque (no readable expiry) and Bambuddy does not
+    persist the refresh token, so without this flag a dead credential looks
+    exactly like a live one.
+    """
+    if user is not None:
+        return user.cloud_token_invalid_at is not None
+    result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
+    row = result.scalar_one_or_none()
+    return bool(row and row.value)
+
+
+async def mark_cloud_token_invalid(user_id: int | None) -> None:
+    """Record that Bambu rejected the stored token.
+
+    Opens its own session on purpose. This runs from
+    ``BambuCloudService._on_auth_failure``, i.e. in the middle of a route that
+    is about to fail — writing through that route's session would tie the flag
+    to a transaction the route may still roll back, and the fact that the
+    credential is dead is true regardless of how the request ends.
+
+    Best-effort: a bookkeeping failure must never replace the 401 the caller
+    actually needs to see.
+    """
+    now = datetime.now(timezone.utc)
+    try:
+        async with async_session() as db:
+            if user_id is not None:
+                await db.execute(update(User).where(User.id == user_id).values(cloud_token_invalid_at=now))
+            else:
+                result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
+                row = result.scalar_one_or_none()
+                if row:
+                    row.value = now.isoformat()
+                else:
+                    db.add(Settings(key=CLOUD_TOKEN_INVALID_KEY, value=now.isoformat()))
+            await db.commit()
+        logger.warning("Bambu Cloud rejected the stored token (user_id=%s) — marking the sign-in as expired", user_id)
+    except Exception:
+        logger.exception("Could not record the Bambu Cloud token as invalid")
+
+
+async def _clear_cloud_token_invalid(db: AsyncSession, user: User | None) -> None:
+    """Clear the rejected-token flag — called on every fresh login and logout."""
+    if user is not None:
+        await db.execute(update(User).where(User.id == user.id).values(cloud_token_invalid_at=None))
+        return
+    result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
+    row = result.scalar_one_or_none()
+    if row:
+        await db.delete(row)
+
+
 async def get_stored_token(db: AsyncSession, user: User | None = None) -> tuple[str | None, str | None, str]:
     """Get stored cloud token, email, and region.
 
@@ -202,15 +265,19 @@ async def store_token(db: AsyncSession, token: str, email: str, region: str, use
 
     When a user is provided (auth enabled), stores on the user record.
     When user is None (auth disabled), stores in global Settings table.
+
+    Always clears the rejected-token flag: this is a *fresh* credential, and
+    leaving the flag set would report the new sign-in as expired.
     """
     region = _normalise_region(region)
+    invalidate_validation_cache(token)
     if user is not None:
         # User object is from the auth dependency's session (detached),
         # so use a direct UPDATE via the route's db session.
-        from sqlalchemy import update
-
         await db.execute(
-            update(User).where(User.id == user.id).values(cloud_token=token, cloud_email=email, cloud_region=region)
+            update(User)
+            .where(User.id == user.id)
+            .values(cloud_token=token, cloud_email=email, cloud_region=region, cloud_token_invalid_at=None)
         )
         await db.commit()
         return
@@ -223,6 +290,7 @@ async def store_token(db: AsyncSession, token: str, email: str, region: str, use
             setting.value = value
         else:
             db.add(Settings(key=key, value=value))
+    await _clear_cloud_token_invalid(db, None)
     await db.commit()
 
 
@@ -231,25 +299,98 @@ async def clear_token(db: AsyncSession, user: User | None = None) -> None:
 
     When a user is provided (auth enabled), clears that user's credentials.
     When user is None (auth disabled), clears from global Settings table.
+
+    The rejected-token flag goes with the token: once there is no credential,
+    "the credential is dead" is not a state worth remembering, and leaving it
+    behind would make the next login look expired the moment it is stored.
     """
-    if user is not None:
-        from sqlalchemy import update
+    token, _email, _region = await get_stored_token(db, user)
+    if token:
+        invalidate_validation_cache(token)
 
+    if user is not None:
         await db.execute(
-            update(User).where(User.id == user.id).values(cloud_token=None, cloud_email=None, cloud_region=None)
+            update(User)
+            .where(User.id == user.id)
+            .values(cloud_token=None, cloud_email=None, cloud_region=None, cloud_token_invalid_at=None)
         )
         await db.commit()
         return
 
     # Fallback: global storage (auth disabled)
     result = await db.execute(
-        select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY]))
+        select(Settings).where(
+            Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY, CLOUD_TOKEN_INVALID_KEY])
+        )
     )
     for setting in result.scalars().all():
         await db.delete(setting)
     await db.commit()
 
 
+async def migrate_global_cloud_token_to_user(db: AsyncSession, user: User) -> bool:
+    """Move a globally-stored cloud token onto ``user`` (auth being enabled).
+
+    ``get_stored_token`` reads the global ``Settings`` rows when auth is off and
+    ``User.cloud_token`` when it's on. Enabling auth therefore switches which
+    column the cloud routes consult — without this migration the token linked
+    before setup is stranded in ``Settings``, ``build_authenticated_cloud``
+    returns ``None``, and every ``/cloud/*`` route silently degrades (#2530).
+
+    The global rows are deleted after the copy so the credential isn't left at
+    rest in a table nothing reads any more. Does **not** commit — the caller
+    owns the transaction. Returns True when a token was actually migrated.
+    """
+    token, email, region = await get_stored_token(db, None)
+    if not token:
+        return False
+
+    user.cloud_token = token
+    user.cloud_email = email
+    user.cloud_region = _normalise_region(region)
+
+    result = await db.execute(
+        select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY]))
+    )
+    for setting in result.scalars().all():
+        await db.delete(setting)
+    return True
+
+
+async def migrate_user_cloud_token_to_global(db: AsyncSession, user: User) -> bool:
+    """Move ``user``'s cloud token into global storage (auth being disabled).
+
+    The mirror of :func:`migrate_global_cloud_token_to_user`: once auth is off,
+    ``get_stored_token`` stops consulting ``User.cloud_token`` entirely, so the
+    admin who turns auth off would otherwise lose their own cloud link.
+
+    Refuses to overwrite an existing global token — a stale row from a previous
+    no-auth stint is still someone's credential, and clobbering it silently is
+    worse than leaving this admin to re-link. Does **not** commit. Returns True
+    when a token was actually migrated.
+    """
+    if not user.cloud_token:
+        return False
+
+    existing, _, _ = await get_stored_token(db, None)
+    if existing:
+        return False
+
+    for key, value in [
+        (CLOUD_TOKEN_KEY, user.cloud_token),
+        (CLOUD_EMAIL_KEY, user.cloud_email),
+        (CLOUD_REGION_KEY, _normalise_region(user.cloud_region)),
+    ]:
+        if value is None:
+            continue
+        db.add(Settings(key=key, value=value))
+
+    user.cloud_token = None
+    user.cloud_email = None
+    user.cloud_region = None
+    return True
+
+
 def _assert_api_key_can_access_cloud(api_key: APIKey) -> None:
     """Reject API keys that aren't authorised to read cloud data.
 
@@ -284,11 +425,17 @@ async def build_authenticated_cloud(db: AsyncSession, user: User | None) -> Bamb
 
     Returns ``None`` when no token is stored, so callers can 401 without constructing
     (and then closing) a useless client. Caller is responsible for ``await cloud.close()``.
+
+    The service is wired to persist a rejected-token flag the moment Bambu
+    answers 401, so every route that builds a client this way makes the whole
+    app agree the sign-in is dead — rather than each feature discovering it
+    separately and reporting Bambu's own opaque "Please login." at the user.
     """
     token, _email, region = await get_stored_token(db, user)
     if not token:
         return None
-    cloud = BambuCloudService(region=region)
+    user_id = user.id if user is not None else None
+    cloud = BambuCloudService(region=region, on_auth_failure=lambda: mark_cloud_token_invalid(user_id))
     cloud.set_token(token)
     return cloud
 
@@ -300,27 +447,55 @@ async def get_auth_status(
 ):
     """Get current cloud authentication status.
 
-    Reads the stored credentials in one DB round-trip (we used to call
-    ``get_stored_token`` twice — once here and once inside
-    ``build_authenticated_cloud``). ``region`` is exposed so the frontend can
-    show "Connected (China)" after a reload without relying on local state.
+    "We hold a token" is not the same claim as "Bambu accepts it", and this
+    endpoint used to make the former while reporting the latter: it asked
+    ``cloud.is_authenticated``, which was a string-presence check behind a
+    self-renewing expiry, so it answered ``true`` for as long as any token
+    existed — including tokens Bambu had been rejecting for months (#2562
+    follow-up). It now asks Bambu.
+
+    The verdict is cached for five minutes inside the service, so the several
+    components polling this endpoint don't each pay a round-trip. When Bambu
+    can't be reached the answer is ``None`` and we report the last known state
+    rather than signing the user out over a transient outage.
+
+    ``region`` is exposed so the frontend can show "Connected (China)" after a
+    reload without relying on local state.
     """
     token, email, region = await get_stored_token(db, current_user)
     if not token:
-        return CloudAuthStatus(is_authenticated=False, email=None, region=None)
+        return CloudAuthStatus(is_authenticated=False, email=None, region=None, sign_in_expired=False)
+
+    known_invalid = await is_cloud_token_invalid(db, current_user)
 
-    cloud = BambuCloudService(region=region)
+    user_id = current_user.id if current_user is not None else None
+    cloud = BambuCloudService(region=region, on_auth_failure=lambda: mark_cloud_token_invalid(user_id))
     cloud.set_token(token)
     try:
-        authenticated = cloud.is_authenticated
-        return CloudAuthStatus(
-            is_authenticated=authenticated,
-            email=email if authenticated else None,
-            region=region if authenticated else None,
-        )
+        if known_invalid:
+            # Already recorded as dead. Don't re-ask Bambu on every poll — only a
+            # new login can change this, and that clears the flag.
+            accepted: bool | None = False
+        else:
+            accepted = await cloud.validate_token()
     finally:
         await cloud.close()
 
+    if accepted is None:
+        # Bambu unreachable / 5xx / Cloudflare challenge. Report what we last
+        # knew — a cloud outage must not present as "your sign-in expired".
+        accepted = not known_invalid
+
+    return CloudAuthStatus(
+        is_authenticated=bool(accepted),
+        email=email if accepted else None,
+        region=region if accepted else None,
+        # Distinguishes "you were signed in and the token died" from "you never
+        # signed in" — the UI shows the same login form either way, but only the
+        # former deserves an explanation for why it reappeared.
+        sign_in_expired=not accepted,
+    )
+
 
 @router.post("/login", response_model=CloudLoginResponse)
 async def login(
@@ -563,6 +738,92 @@ _filament_cache: dict[str, dict] = {}
 _filament_cache_time: float = 0
 FILAMENT_CACHE_TTL = 300  # 5 minutes
 
+# In-flight cloud lookups, keyed by setting_id (#2572). The printer overview
+# mounts one filament-info request per printer card, so at farm scale several
+# browsers ask for the same uncached preset within the same instant. Without
+# coalescing each request issues its own Bambu Cloud round-trip for the same id
+# (a thundering herd against a rate-limited API). The first caller to miss a
+# given id becomes the leader and resolves it; concurrent callers await its
+# future and reuse the result instead of duplicating the call.
+_filament_inflight: dict[str, asyncio.Future] = {}
+
+
+async def _fetch_one_cloud_filament(setting_id: str, cloud: BambuCloudService) -> dict | None:
+    """Fetch a single filament preset from Bambu Cloud.
+
+    Returns ``{"name", "k"}`` on success (name may be empty when the preset
+    resolves but carries no display name), or ``None`` when the lookup fails.
+    Never raises — a 400 is the expected answer for many bare preset IDs and is
+    logged at DEBUG; anything else is a real fault logged at WARNING.
+    """
+    try:
+        api_setting_id = _filament_id_to_setting_id(setting_id)
+        data = await cloud.get_setting_detail(api_setting_id)
+        setting = data.get("setting", {})
+        name = data.get("name", "")
+        k_value = setting.get("pressure_advance")
+        if k_value is not None:
+            try:
+                k_value = float(k_value)
+            except (ValueError, TypeError):
+                k_value = None
+        return {"name": name, "k": k_value}
+    except Exception as e:
+        # A 400 here is the *expected* answer, not a fault, and the local-preset
+        # fallback (Phase 3) exists to handle it (#2530). Two routine causes:
+        #   * Many official presets are only addressable with a printer variant
+        #     suffix — "GFSA00" resolves, "GFSL05" does not, only "GFSL05_07"
+        #     (@BBL A1) does. The bare ID is all the AMS reports, so the lookup
+        #     legitimately misses.
+        #   * Personal presets ("P…") belong to the Bambu account that sliced the
+        #     file; another account will never resolve them.
+        # Logging those at WARNING on every AMS tooltip refresh trains users to
+        # ignore the log. Anything else — expired token, 5xx, a connection
+        # failure — stays at WARNING because it is a fault.
+        expected_miss = isinstance(e, BambuCloudError) and e.status_code == 400
+        logger.log(
+            logging.DEBUG if expected_miss else logging.WARNING,
+            "Failed to get cloud preset %s (API ID: %s): %s",
+            setting_id,
+            _filament_id_to_setting_id(setting_id),
+            e,
+        )
+        return None
+
+
+async def _resolve_cloud_filament(setting_id: str, cloud: BambuCloudService) -> dict | None:
+    """Resolve one preset via Bambu Cloud, single-flighting concurrent misses (#2572).
+
+    Concurrent callers for the same ``setting_id`` share one cloud round-trip:
+    the first caller resolves it while the rest await the shared future. Returns
+    the info dict (also populating ``_filament_cache``) or ``None`` on failure.
+    """
+    if setting_id in _filament_cache:
+        return _filament_cache[setting_id]
+
+    existing = _filament_inflight.get(setting_id)
+    if existing is not None:
+        # Another request is already fetching this id — reuse its result.
+        # shield() so our own cancellation can't cancel the shared leader.
+        try:
+            return await asyncio.shield(existing)
+        except Exception:
+            return None
+
+    fut: asyncio.Future = asyncio.get_event_loop().create_future()
+    _filament_inflight[setting_id] = fut
+    info: dict | None = None
+    try:
+        info = await _fetch_one_cloud_filament(setting_id, cloud)
+        return info
+    finally:
+        if info is not None:
+            _filament_cache[setting_id] = info
+        if not fut.done():
+            fut.set_result(info)
+        _filament_inflight.pop(setting_id, None)
+
+
 # Built-in filament ID → name mapping (fallback when cloud API and local profiles
 # don't have the entry). Based on Bambu Lab's known filament catalogue.
 _BUILTIN_FILAMENT_NAMES: dict[str, str] = {
@@ -775,35 +1036,23 @@ async def get_filament_info(
     # Phase 2: Try cloud for uncached IDs
     if unresolved_ids:
         cloud = await build_authenticated_cloud(db, current_user)
+        # Release the request's DB transaction before the sequential Bambu Cloud
+        # round-trips below (#2572). build_authenticated_cloud has read the
+        # stored token — the only DB access this phase needs — and nothing until
+        # Phase 3 touches the DB again. Without this the session sat "idle in
+        # transaction" for the full duration of N external HTTP calls, pinning a
+        # pooled connection per in-flight request. Phase 3's read transparently
+        # opens a fresh transaction on the same still-open session.
+        await db.rollback()
         if cloud is not None and cloud.is_authenticated:
             try:
                 still_unresolved: list[str] = []
                 for setting_id in unresolved_ids:
-                    try:
-                        api_setting_id = _filament_id_to_setting_id(setting_id)
-                        data = await cloud.get_setting_detail(api_setting_id)
-                        setting = data.get("setting", {})
-                        name = data.get("name", "")
-                        k_value = setting.get("pressure_advance")
-                        if k_value is not None:
-                            try:
-                                k_value = float(k_value)
-                            except (ValueError, TypeError):
-                                k_value = None
-
-                        info = {"name": name, "k": k_value}
-                        _filament_cache[setting_id] = info
+                    info = await _resolve_cloud_filament(setting_id, cloud)
+                    if info is not None:
                         result[setting_id] = info
-
-                        if not name:
-                            still_unresolved.append(setting_id)
-                    except Exception as e:
-                        logger.warning(
-                            f"Failed to get cloud preset {setting_id} "
-                            f"(API ID: {_filament_id_to_setting_id(setting_id)}): {e}"
-                        )
+                    if info is None or not info.get("name"):
                         still_unresolved.append(setting_id)
-
                 unresolved_ids = still_unresolved
             finally:
                 await cloud.close()

+ 27 - 0
backend/app/api/routes/inventory.py

@@ -287,6 +287,21 @@ async def apply_spool_to_slot_via_mqtt(
             spool.id,
         )
 
+    # Register a read-back verification so the next AMS pushes can confirm the
+    # tray actually accepted this assignment (#2582). We record the same
+    # effective filament id we pushed plus the cali_idx we selected (or -1 for
+    # the Default-K reset above), and the client fires on_assignment_verified
+    # on match/timeout. Colour is informational only — the match keys on the
+    # filament id the slicer echoes back.
+    verify_cali_idx = matching_kp.cali_idx if (matching_kp and matching_kp.cali_idx is not None) else -1
+    client.register_assignment_verification(
+        ams_id=ams_id,
+        tray_id=tray_id,
+        tray_info_idx=effective_tray_info_idx,
+        tray_color=tray_color,
+        cali_idx=verify_cali_idx,
+    )
+
     # Persist slot preset mapping for UI display (preset_name on hover card).
     # Shared with the RFID auto-assign path — both must keep this row in sync
     # with the currently-assigned spool, otherwise the slot card surfaces the
@@ -1803,6 +1818,18 @@ async def assign_spool(
             )
         except Exception as e:
             logger.warning("MQTT auto-configure failed for spool %d: %s", spool.id, e)
+        else:
+            # Nudge a fresh pushall so the read-back verification registered in
+            # apply_spool_to_slot_via_mqtt (#2582) has current tray telemetry to
+            # compare against within its window, instead of waiting for the next
+            # idle push. Best-effort — the periodic push is the fallback.
+            if configured:
+                try:
+                    client = printer_manager.get_client(data.printer_id)
+                    if client:
+                        client.request_status_update()
+                except Exception:
+                    pass
     # pending_config is the "config not landed yet" UI marker. True when the
     # firmware said empty, OR when MQTT couldn't actually publish (printer
     # offline, no client, transient failure). on_ams_change replay re-fires

+ 52 - 9
backend/app/api/routes/library.py

@@ -1353,6 +1353,7 @@ _SCANNABLE_EXTENSIONS = {
     ".gif",
     ".webp",
     ".svg",
+    ".md",
 }
 
 
@@ -1720,9 +1721,17 @@ async def scan_external_folder(
             db.add(db_file)
             added += 1
 
-    # Remove DB entries for files that no longer exist on disk
+    # Remove DB entries for files that no longer exist on disk.
+    #
+    # Gate on actual disk presence, NOT merely absence from found_paths:
+    # found_paths only collects extensions in _SCANNABLE_EXTENSIONS, so a
+    # record for any other file the upload path admitted (e.g. a .md README,
+    # #2520) would otherwise be treated as "deleted from disk" and purged on
+    # every scan even though the file is still there. os.path.exists keeps
+    # such records; genuinely-deleted files (absent from disk) are still
+    # cleaned up. External file_path is the absolute on-disk path.
     for path_str, db_file in existing_files.items():
-        if path_str not in found_paths:
+        if path_str not in found_paths and not os.path.exists(path_str):
             # Clean up thumbnail if we generated one
             if db_file.thumbnail_path:
                 try:
@@ -3519,6 +3528,12 @@ async def _run_slicer_with_fallback(
         presets["process"] = _patch_process_support_settings(presets["process"], primary_bytes)
 
     used_embedded_settings = False
+    # "Slice as designed" (#2611): honour the file's embedded
+    # project_settings.config instead of the picked profile triplet. Only
+    # meaningful for a 3MF that actually carries embedded settings; the UI
+    # gates the toggle on the picked printer matching the design's target,
+    # so this path never re-targets across printer models.
+    embedded_mode = bool(request.use_embedded_settings and is_3mf)
     service = SlicerApiService(api_url)
 
     # #1493: cross-nozzle-class re-slice (single <-> dual). Without
@@ -3575,9 +3590,11 @@ async def _run_slicer_with_fallback(
     # (e.g. ABS in slot 2 next to a PLA in the used slot 1) makes
     # BambuStudio reject the slice with "the temperature difference of
     # the filaments used is too large" (exit 194) even though the G-code
-    # never touches the unused slot. Replace unused-slot entries with the
-    # slot-1 selection before the real slice so the loaded-filament set
-    # is materially homogeneous.
+    # never touches the unused slot; a default scoped to another printer
+    # gets it rejected with "filament preset (slot N) is not compatible
+    # with printer …" (#2628). Replace unused-slot entries with the
+    # plate's lowest used slot before the real slice so the loaded set is
+    # materially homogeneous and printer-correct.
     if is_3mf and request.plate is not None:
         from backend.app.services.slicer_3mf_convert import substitute_unused_plate_filaments
 
@@ -3597,7 +3614,22 @@ async def _run_slicer_with_fallback(
 
     try:
         try:
-            if use_cross_class_slice_all:
+            if embedded_mode:
+                # No --load-settings: feed the CLI the file's own
+                # project_settings.config untouched so the designer's tweaks
+                # (walls, infill, etc.) drive the slice. primary_bytes is
+                # already sentinel-sanitised above, the same bytes the
+                # crash-fallback uses. The resolved presets go unused here.
+                result = await service.slice_without_profiles(
+                    model_bytes=primary_bytes,
+                    model_filename=model_filename,
+                    plate=request.plate,
+                    export_3mf=request.export_3mf,
+                    request_id=progress_request_id,
+                    on_progress=progress_callback,
+                )
+                used_embedded_settings = True
+            elif use_cross_class_slice_all:
                 from backend.app.services.slicer_3mf_convert import (
                     count_plates_in_3mf,
                     merge_plate_3mfs,
@@ -3699,7 +3731,11 @@ async def _run_slicer_with_fallback(
                 # (e.g. re-slicing an H2D model for an X1C: the object is off
                 # the smaller bed). Surface the slicer's reason instead.
                 raise HTTPException(status_code=400, detail=rejection) from exc
-            if not is_3mf:
+            if not is_3mf or embedded_mode:
+                # embedded_mode already sliced with the file's own settings —
+                # there is nothing to fall back TO, so surface the server
+                # error (the outer handler turns it into a 502) instead of
+                # re-running the same embedded slice.
                 raise
             logger.warning(
                 "Slicer CLI failed on the --load-settings path for %s (%s); retrying with embedded settings",
@@ -4124,8 +4160,14 @@ async def slice_library_file(
 
     src_result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
     lib_file = src_result.scalar_one_or_none()
-    if not lib_file:
-        raise HTTPException(status_code=404, detail="File not found")
+    # Per-row ownership gate. LIBRARY_UPLOAD alone let a READ_OWN caller (e.g. the
+    # built-in Operators group) slice another user's model by raw id even though
+    # GET on that id returned 404 — the sliced output was then attributed to and
+    # downloadable by the requester. Enforce the same visibility the read routes
+    # use before reading the source off disk. API-key / auth-disabled callers
+    # (current_user is None) keep can_read_all=True — no per-row identity.
+    can_read_all = current_user is None or current_user.has_permission(Permission.LIBRARY_READ_ALL.value)
+    lib_file = _ensure_library_file_visible(lib_file, current_user, can_read_all)
 
     src_lower = (lib_file.filename or "").lower()
     if not (
@@ -4197,6 +4239,7 @@ async def slice_library_file(
         kind="library_file",
         source_id=lib_file.id,
         source_name=lib_file.filename,
+        owner_id=user_id,
         run=_run,
     )
     return {

+ 6 - 1
backend/app/api/routes/library_tags.py

@@ -186,7 +186,12 @@ async def update_tag(
     )
 
 
-@router.delete("/{tag_id}", status_code=204)
+# response_model=None is load-bearing under `from __future__ import annotations`:
+# the `-> None` return annotation reaches FastAPI as the string "None", which it
+# resolves to NoneType — a truthy class — and then asserts a 204 may carry no
+# response body. fastapi >= 0.116 special-cases NoneType; on the 0.109-0.115
+# releases requirements.txt still allows, the app fails at import without this.
+@router.delete("/{tag_id}", status_code=204, response_model=None)
 async def delete_tag(
     tag_id: int,
     db: AsyncSession = Depends(get_db),

+ 14 - 0
backend/app/api/routes/local_backup.py

@@ -39,6 +39,20 @@ async def get_status(
     }
 
 
+@router.get("/path-check")
+async def check_path(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
+):
+    """Check that the configured output directory can actually be written to.
+
+    Writes and removes a probe file. A path the service cannot write to — a NAS
+    share outside the systemd unit's ReadWritePaths, say — otherwise only shows
+    up as a failed backup hours later (#2544).
+    """
+    settings = await local_backup_service._load_settings()
+    return local_backup_service.check_path(settings["path"])
+
+
 @router.post("/run")
 async def trigger_backup(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),

+ 24 - 4
backend/app/api/routes/makerworld.py

@@ -21,7 +21,12 @@ from fastapi.responses import Response
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.api.routes.cloud import get_stored_token, resolve_api_key_cloud_owner
+from backend.app.api.routes.cloud import (
+    get_stored_token,
+    is_cloud_token_invalid,
+    mark_cloud_token_invalid,
+    resolve_api_key_cloud_owner,
+)
 from backend.app.api.routes.library import save_3mf_bytes_to_library
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
@@ -58,10 +63,16 @@ async def _build_service(db: AsyncSession, user: User | None) -> MakerWorldServi
     stored Bambu Cloud bearer token when available.
 
     Mirrors ``cloud.build_authenticated_cloud`` — the token is entirely
-    optional; anonymous calls (metadata, URL resolution) still work.
+    optional; anonymous calls (metadata, URL resolution) still work — and,
+    like it, records a rejected token so the whole app agrees the sign-in is
+    dead rather than each feature failing on its own.
     """
     token, _email, _region = await get_stored_token(db, user)
-    return MakerWorldService(auth_token=token)
+    user_id = user.id if user is not None else None
+    return MakerWorldService(
+        auth_token=token,
+        on_auth_failure=lambda: mark_cloud_token_invalid(user_id),
+    )
 
 
 def _canonical_url(model_id: int, profile_id: int | None = None) -> str:
@@ -156,7 +167,16 @@ async def get_status(
     cloud_token_user = current_user or api_key_cloud_owner
     token, _email, _region = await get_stored_token(db, cloud_token_user)
     has_token = bool(token)
-    return MakerWorldStatus(has_cloud_token=has_token, can_download=has_token)
+    # A token Bambu has already rejected downloads nothing. ``can_download``
+    # used to be a bare alias for ``has_cloud_token``, so the import button
+    # stayed enabled against a dead credential and the user found out via a
+    # 401 toast (#2562 follow-up).
+    expired = has_token and await is_cloud_token_invalid(db, cloud_token_user)
+    return MakerWorldStatus(
+        has_cloud_token=has_token,
+        can_download=has_token and not expired,
+        sign_in_expired=expired,
+    )
 
 
 @router.post("/resolve", response_model=MakerWorldResolvedModel)

+ 152 - 148
backend/app/api/routes/orca_cloud.py

@@ -1,31 +1,34 @@
 """
 Orca Cloud API Routes
 
-PKCE-based connect/disconnect + profile sync endpoints for the
-Orca Cloud (Supabase) profile-sync surface.
+Device-pairing (RFC 8628) connect/disconnect + profile sync endpoints for the
+Orca Cloud external-app surface.
 
 Auth shape (see :mod:`backend.app.services.orca_cloud` for the deep dive):
 
-    POST /orca-cloud/auth/start
-        Generate PKCE + state, persist them (TTL 10 min), return the auth URL.
-    POST /orca-cloud/auth/finish
-        Parse the pasted callback URL, validate state for CSRF, exchange the
-        code for tokens, persist them atomically.
+    POST /orca-cloud/device/start
+        Request a device code, persist it server-side (TTL 10 min), return the
+        user_code + verification URIs + poll interval.
+    POST /orca-cloud/device/poll
+        One poll of the token endpoint. Returns an in-progress status while the
+        user approves; on approval, persists the token pair and reports
+        connected. The frontend calls this every ``interval`` seconds.
     GET  /orca-cloud/status
-        Connected/disconnected + email + user_id.
+        Connected/disconnected + user_id.
     POST /orca-cloud/logout
-        Clear stored tokens (no Supabase-side revocation — token still
-        survives until its 1h expiry, but Bambuddy has no way to use it).
+        Clear stored tokens (Bambuddy then has no token to use; the user can
+        also disconnect from Orca Cloud's own settings to revoke server-side).
     GET  /orca-cloud/profiles
-        Paginated list of the user's Orca Cloud profiles. JIT-refreshes the
-        access token if it's within the 5-min leeway of expiry.
+        List of the user's Orca Cloud profiles, grouped by type. JIT-refreshes
+        the access token if it's within the refresh leeway of expiry.
     GET  /orca-cloud/profiles/{id}
         Single profile's full content.
 
-Storage shape mirrors the Bambu Cloud surface: per-user columns on
-``users`` when auth is enabled, fallback to global ``settings`` keys when
-auth is disabled. The transient PKCE state (verifier, state, pending_at)
-is stored alongside the tokens — same dual-mode pattern.
+Storage shape mirrors the Bambu Cloud surface: per-user columns on ``users``
+when auth is enabled, fallback to global ``settings`` keys when auth is
+disabled. The transient pending device-code state (device_code, interval,
+started_at) reuses the ``orca_cloud_pending_*`` columns — same dual-mode
+pattern; no schema change from the previous PKCE flow.
 """
 
 from __future__ import annotations
@@ -33,7 +36,7 @@ from __future__ import annotations
 import logging
 from datetime import datetime, timezone
 
-from fastapi import APIRouter, Depends, HTTPException
+from fastapi import APIRouter, Depends, HTTPException, Request
 from sqlalchemy import select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 
@@ -43,23 +46,19 @@ from backend.app.core.permissions import Permission
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
 from backend.app.schemas.orca_cloud import (
-    OrcaAuthFinishRequest,
-    OrcaAuthPasswordRequest,
-    OrcaAuthStartRequest,
-    OrcaAuthStartResponse,
     OrcaAuthStatusResponse,
+    OrcaDevicePollResponse,
+    OrcaDeviceStartResponse,
     OrcaProfileDetail,
     OrcaProfileListResponse,
     OrcaProfileMeta,
 )
 from backend.app.services.orca_cloud import (
-    PENDING_PKCE_TTL,
+    DEVICE_CODE_TTL,
+    DevicePoll,
     OrcaCloudAuthError,
     OrcaCloudError,
     OrcaCloudService,
-    build_authorize_url,
-    generate_pkce,
-    parse_callback_url,
 )
 
 logger = logging.getLogger(__name__)
@@ -90,9 +89,9 @@ _ORCA_TYPE_TO_BAMBU = {
 
 
 def _orca_to_setting(orca_profile: dict) -> OrcaProfileMeta | None:
-    """Normalize one Orca ``ProfileUpsert`` (``{id, name, content, ...}``)
-    into a ``SlicerSetting``-shaped row. Returns ``None`` if the content
-    isn't a dict or the type isn't one we render."""
+    """Normalize one Orca profile (``{id, name, content, ...}``) into a
+    ``SlicerSetting``-shaped row. Returns ``None`` if the content isn't a dict
+    or the type isn't one we render."""
     content = orca_profile.get("content") or {}
     if not isinstance(content, dict):
         return None
@@ -130,15 +129,16 @@ def _str_or_none(value: object) -> str | None:
 
 # Settings table keys for the auth-disabled fallback. Mirrors the Bambu Cloud
 # pattern (``bambu_cloud_token`` etc.) so administrators inspecting the
-# settings table see a consistent prefix.
+# settings table see a consistent prefix. The ``pending_*`` keys hold the
+# transient device-code state (device_code / interval / started_at).
 _SETTINGS_KEYS = {
     "token": "orca_cloud_token",
     "refresh_token": "orca_cloud_refresh_token",
     "expires_at": "orca_cloud_expires_at",  # ISO 8601 UTC string
     "email": "orca_cloud_email",
     "user_id": "orca_cloud_user_id",
-    "pending_verifier": "orca_cloud_pending_verifier",
-    "pending_state": "orca_cloud_pending_state",
+    "pending_device_code": "orca_cloud_pending_verifier",  # reused column
+    "pending_interval": "orca_cloud_pending_state",  # reused column
     "pending_at": "orca_cloud_pending_at",  # ISO 8601 UTC string
 }
 
@@ -184,7 +184,11 @@ def _parse_iso(value: str | None) -> datetime | None:
 class _OrcaCredentials:
     """Lightweight bag for stored Orca Cloud credentials. We use a class
     rather than a dataclass so the helpers can mutate it as needed during
-    JIT-refresh without rebuilding the whole object."""
+    JIT-refresh without rebuilding the whole object.
+
+    ``pending_device_code`` / ``pending_interval`` / ``pending_at`` hold the
+    in-flight device-code pairing state (reusing the ``orca_cloud_pending_*``
+    columns that the old PKCE flow used for its verifier/state)."""
 
     __slots__ = (
         "token",
@@ -192,8 +196,8 @@ class _OrcaCredentials:
         "expires_at",
         "email",
         "user_id",
-        "pending_verifier",
-        "pending_state",
+        "pending_device_code",
+        "pending_interval",
         "pending_at",
     )
 
@@ -203,8 +207,8 @@ class _OrcaCredentials:
         self.expires_at: datetime | None = None
         self.email: str | None = None
         self.user_id: str | None = None
-        self.pending_verifier: str | None = None
-        self.pending_state: str | None = None
+        self.pending_device_code: str | None = None
+        self.pending_interval: str | None = None
         self.pending_at: datetime | None = None
 
 
@@ -227,8 +231,8 @@ async def _load_credentials(db: AsyncSession, user: User | None) -> _OrcaCredent
         creds.expires_at = _as_utc(user.orca_cloud_expires_at)
         creds.email = user.orca_cloud_email
         creds.user_id = user.orca_cloud_user_id
-        creds.pending_verifier = user.orca_cloud_pending_verifier
-        creds.pending_state = user.orca_cloud_pending_state
+        creds.pending_device_code = user.orca_cloud_pending_verifier
+        creds.pending_interval = user.orca_cloud_pending_state
         creds.pending_at = _as_utc(user.orca_cloud_pending_at)
         return creds
 
@@ -239,27 +243,28 @@ async def _load_credentials(db: AsyncSession, user: User | None) -> _OrcaCredent
     creds.expires_at = _parse_iso(raw.get(_SETTINGS_KEYS["expires_at"]))
     creds.email = raw.get(_SETTINGS_KEYS["email"])
     creds.user_id = raw.get(_SETTINGS_KEYS["user_id"])
-    creds.pending_verifier = raw.get(_SETTINGS_KEYS["pending_verifier"])
-    creds.pending_state = raw.get(_SETTINGS_KEYS["pending_state"])
+    creds.pending_device_code = raw.get(_SETTINGS_KEYS["pending_device_code"])
+    creds.pending_interval = raw.get(_SETTINGS_KEYS["pending_interval"])
     creds.pending_at = _parse_iso(raw.get(_SETTINGS_KEYS["pending_at"]))
     return creds
 
 
-async def _persist_pending_pkce(
+async def _persist_pending_device(
     db: AsyncSession,
     user: User | None,
-    verifier: str,
-    state: str,
+    device_code: str,
+    interval: int,
     when: datetime,
 ) -> None:
-    """Store the transient PKCE state used by ``/auth/start`` -> ``/auth/finish``."""
+    """Store the transient device-code state used by ``/device/start`` ->
+    ``/device/poll``. The device_code is a secret kept server-side."""
     if user is not None:
         await db.execute(
             update(User)
             .where(User.id == user.id)
             .values(
-                orca_cloud_pending_verifier=verifier,
-                orca_cloud_pending_state=state,
+                orca_cloud_pending_verifier=device_code,
+                orca_cloud_pending_state=str(interval),
                 orca_cloud_pending_at=when,
             )
         )
@@ -268,13 +273,38 @@ async def _persist_pending_pkce(
     await _upsert_settings(
         db,
         {
-            _SETTINGS_KEYS["pending_verifier"]: verifier,
-            _SETTINGS_KEYS["pending_state"]: state,
+            _SETTINGS_KEYS["pending_device_code"]: device_code,
+            _SETTINGS_KEYS["pending_interval"]: str(interval),
             _SETTINGS_KEYS["pending_at"]: _iso(when),
         },
     )
 
 
+async def _clear_pending_device(db: AsyncSession, user: User | None) -> None:
+    """Wipe just the pending device-code state (on terminal poll outcomes),
+    leaving any existing tokens untouched."""
+    if user is not None:
+        await db.execute(
+            update(User)
+            .where(User.id == user.id)
+            .values(
+                orca_cloud_pending_verifier=None,
+                orca_cloud_pending_state=None,
+                orca_cloud_pending_at=None,
+            )
+        )
+        await db.commit()
+        return
+    await _upsert_settings(
+        db,
+        {
+            _SETTINGS_KEYS["pending_device_code"]: None,
+            _SETTINGS_KEYS["pending_interval"]: None,
+            _SETTINGS_KEYS["pending_at"]: None,
+        },
+    )
+
+
 async def _persist_tokens(
     db: AsyncSession,
     user: User | None,
@@ -285,8 +315,8 @@ async def _persist_tokens(
     user_id: str | None,
 ) -> None:
     """Atomically write the new access/refresh pair to whichever backing store
-    the deployment uses. Also clears the pending PKCE state on the same write,
-    since by this point the handshake is complete."""
+    the deployment uses. Also clears the pending device-code state on the same
+    write, since by this point the pairing is complete."""
     if user is not None:
         await db.execute(
             update(User)
@@ -312,8 +342,8 @@ async def _persist_tokens(
             _SETTINGS_KEYS["expires_at"]: _iso(expires_at),
             _SETTINGS_KEYS["email"]: email,
             _SETTINGS_KEYS["user_id"]: user_id,
-            _SETTINGS_KEYS["pending_verifier"]: None,
-            _SETTINGS_KEYS["pending_state"]: None,
+            _SETTINGS_KEYS["pending_device_code"]: None,
+            _SETTINGS_KEYS["pending_interval"]: None,
             _SETTINGS_KEYS["pending_at"]: None,
         },
     )
@@ -327,7 +357,7 @@ async def _persist_rotated_tokens(
     expires_at: datetime | None,
 ) -> None:
     """Persist tokens after a refresh — does NOT touch email/user_id and does
-    NOT touch the pending PKCE state (refresh happens long after the handshake)."""
+    NOT touch the pending state (refresh happens long after pairing)."""
     if user is not None:
         await db.execute(
             update(User)
@@ -405,7 +435,12 @@ async def _build_authenticated_service(
     """Construct an :class:`OrcaCloudService` pre-populated with stored
     credentials. If the access token is within the refresh-leeway of expiry,
     proactively refresh and persist the new pair BEFORE returning, so the
-    next API call doesn't time out mid-flight on an expired token."""
+    next API call doesn't time out mid-flight on an expired token.
+
+    We don't lock around the refresh: Orca tolerates concurrent refreshes for
+    ~60s (each racer gets its own valid pair on the same connection rather than
+    a revoke), so a lost race here is harmless — last-write-wins on the stored
+    pair, and whichever pair we keep is valid."""
     creds = await _load_credentials(db, user)
     if not creds.token:
         raise HTTPException(status_code=401, detail="Orca Cloud is not connected — sign in first.")
@@ -439,128 +474,97 @@ async def _build_authenticated_service(
 # ---------------------------------------------------------------------------
 
 
-@router.post("/auth/start", response_model=OrcaAuthStartResponse)
-async def auth_start(
-    payload: OrcaAuthStartRequest = OrcaAuthStartRequest(),
-    db: AsyncSession = Depends(get_db),
-    current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
-):
-    """Generate PKCE state and return the Supabase authorize URL for the
-    requested OAuth provider (google / apple / github). The frontend opens
-    the URL in a new tab; after sign-in the user pastes the callback URL
-    back into ``/auth/finish``.
-
-    ``state`` is generated but NOT sent to Supabase (it would clash with
-    GoTrue's internal redirect_to-tracking state). We still persist it so
-    a future flow change can re-introduce state-based CSRF if needed; CSRF
-    protection today comes from the PKCE verifier itself, which is
-    single-use, server-side, and bound to the caller's user row."""
-    verifier, challenge, state = generate_pkce()
-    await _persist_pending_pkce(db, current_user, verifier, state, datetime.now(timezone.utc))
-    return OrcaAuthStartResponse(auth_url=build_authorize_url(challenge, provider=payload.provider))
-
-
-@router.post("/auth/password", response_model=OrcaAuthStatusResponse)
-async def auth_password(
-    payload: OrcaAuthPasswordRequest,
+@router.post("/device/start", response_model=OrcaDeviceStartResponse)
+async def device_start(
+    request: Request,
     db: AsyncSession = Depends(get_db),
     current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
 ):
-    """Direct email+password sign-in. No browser redirect, no paste flow —
-    Bambuddy POSTs the credentials to Supabase and stores the returned
-    tokens. Whether this succeeds depends on Orca's Supabase project
-    accepting the password grant; if it rejects (the SDK refuses passwords
-    by design, the backend may follow suit), the caller falls back to an
-    OAuth provider via ``/auth/start``."""
+    """Begin device pairing. Requests a device code from Orca, stores it
+    server-side (the device_code is a secret and never leaves the backend),
+    and returns the user_code + verification URIs + poll interval for the
+    frontend to display and poll against."""
     svc = OrcaCloudService()
+    # instance_url/label are display-only anti-phishing context on the approval
+    # card. base_url may be off behind a reverse proxy, but it's harmless if so.
+    instance_url = str(request.base_url).rstrip("/") or None
     try:
-        await svc.password_login(payload.email, payload.password)
+        data = await svc.request_device_code(instance_url=instance_url, instance_label="Bambuddy")
     except OrcaCloudAuthError as e:
-        raise HTTPException(status_code=400, detail=f"Orca Cloud rejected the sign-in: {e}") from e
+        # invalid_client etc. — an operator misconfiguration, not user error.
+        raise HTTPException(status_code=502, detail=f"Orca Cloud pairing is misconfigured: {e}") from e
     except OrcaCloudError as e:
         raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e
 
-    email: str | None = None
-    user_id: str | None = None
-    try:
-        user_info = await svc.get_user_info()
-        if isinstance(user_info, dict):
-            email = user_info.get("email")
-            user_id = user_info.get("id")
-    except OrcaCloudError as e:
-        logger.warning("Orca Cloud user-info fetch failed after successful password auth: %s", e)
-
-    await _persist_tokens(db, current_user, svc.access_token, svc.refresh_token, svc.token_expiry, email, user_id)
-    return OrcaAuthStatusResponse(connected=True, email=email, user_id=user_id)
+    device_code = data.get("device_code")
+    user_code = data.get("user_code")
+    if not device_code or not user_code:
+        raise HTTPException(status_code=502, detail="Orca Cloud returned an incomplete device-code response.")
+
+    interval = int(data.get("interval") or 5)
+    expires_in = int(data.get("expires_in") or DEVICE_CODE_TTL.total_seconds())
+    await _persist_pending_device(db, current_user, device_code, interval, datetime.now(timezone.utc))
+
+    return OrcaDeviceStartResponse(
+        user_code=user_code,
+        verification_uri=str(data.get("verification_uri") or ""),
+        verification_uri_complete=str(data.get("verification_uri_complete") or ""),
+        interval=interval,
+        expires_in=expires_in,
+    )
 
 
-@router.post("/auth/finish", response_model=OrcaAuthStatusResponse)
-async def auth_finish(
-    payload: OrcaAuthFinishRequest,
+@router.post("/device/poll", response_model=OrcaDevicePollResponse)
+async def device_poll(
     db: AsyncSession = Depends(get_db),
     current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
 ):
-    """Complete the PKCE handshake — parse the pasted callback URL, validate
-    state (CSRF), exchange the code for tokens, persist."""
+    """Poll the token endpoint once for the in-flight pairing. Returns an
+    in-progress status while the user approves; on approval persists the token
+    pair (clearing the pending state) and reports connected."""
     creds = await _load_credentials(db, current_user)
-    if not creds.pending_verifier or not creds.pending_state or not creds.pending_at:
+    if not creds.pending_device_code or not creds.pending_at:
         raise HTTPException(
             status_code=400,
-            detail="No pending Orca Cloud sign-in. Click Connect first to start the flow.",
+            detail="No pending Orca Cloud pairing. Click Connect first to start the flow.",
         )
 
     # creds.pending_at is already tz-aware UTC after _load_credentials' _as_utc
-    # normalization. Subtracting two aware UTC datetimes gives a real wall-clock
-    # delta with no local-offset shift.
+    # normalization. Subtracting two aware UTC datetimes gives a real delta.
     age = datetime.now(timezone.utc) - creds.pending_at
-    if age > PENDING_PKCE_TTL:
-        # Don't leave the stale state in the DB — clear it so the user has to
-        # restart fresh, which forces a new verifier/state pair.
-        await _persist_pending_pkce(db, current_user, "", "", datetime.fromtimestamp(0, tz=timezone.utc))
-        raise HTTPException(
-            status_code=400,
-            detail=(
-                f"The Orca Cloud sign-in flow expired after {PENDING_PKCE_TTL.total_seconds() / 60:.0f} minutes. "
-                "Click Connect again to start over."
-            ),
-        )
-
-    code, _callback_state = parse_callback_url(payload.callback_url)
-    if not code:
-        raise HTTPException(
-            status_code=400,
-            detail="No `code` parameter in the pasted callback URL. Copy the full URL from your browser's address bar.",
-        )
-    # We do NOT validate ``state`` here: Supabase doesn't echo back a state we
-    # don't send (see :func:`build_authorize_url` for why we can't send one).
-    # CSRF is protected by PKCE: the verifier is server-side and single-use,
-    # so an attacker can't complete the exchange with a code they obtained
-    # separately. ``pending_state`` is still stored for forward compatibility
-    # if Supabase ever supports a client-passed state alongside redirect_to.
+    if age > DEVICE_CODE_TTL:
+        await _clear_pending_device(db, current_user)
+        return OrcaDevicePollResponse(status=DevicePoll.EXPIRED, connected=False)
 
     svc = OrcaCloudService()
     try:
-        await svc.exchange_code(code, creds.pending_verifier)
-    except OrcaCloudAuthError as e:
-        raise HTTPException(status_code=400, detail=f"Orca Cloud rejected the sign-in: {e}") from e
+        status, token_data = await svc.poll_token(creds.pending_device_code)
     except OrcaCloudError as e:
         raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e
 
-    # Fetch user info so we can show the connected email in the UI.
-    email: str | None = None
+    if status in DevicePoll.ONGOING:
+        return OrcaDevicePollResponse(status=status, connected=False)
+
+    if status in DevicePoll.TERMINAL:
+        # access_denied / expired_token — the attempt is dead; clear it so the
+        # user starts fresh next time.
+        await _clear_pending_device(db, current_user)
+        return OrcaDevicePollResponse(status=status, connected=False)
+
+    # COMPLETE — tokens issued and applied to svc. Introspect for the user_id
+    # (the external API's /me doesn't return an email, so email stays None).
     user_id: str | None = None
     try:
-        user_info = await svc.get_user_info()
-        if isinstance(user_info, dict):
-            email = user_info.get("email")
-            user_id = user_info.get("id")
+        info = await svc.introspect()
+        if isinstance(info, dict):
+            user_id = _str_or_none(info.get("user_id"))
     except OrcaCloudError as e:
-        # Don't fail the whole connect flow just because the user-info side
-        # call hiccuped — we have valid tokens, that's the load-bearing part.
-        logger.warning("Orca Cloud user-info fetch failed after successful auth: %s", e)
+        # Don't fail the whole pairing over the side introspection call — we
+        # have valid tokens, which is the load-bearing part.
+        logger.warning("Orca Cloud introspection failed after successful pairing: %s", e)
 
-    await _persist_tokens(db, current_user, svc.access_token, svc.refresh_token, svc.token_expiry, email, user_id)
-    return OrcaAuthStatusResponse(connected=True, email=email, user_id=user_id)
+    await _persist_tokens(db, current_user, svc.access_token, svc.refresh_token, svc.token_expiry, None, user_id)
+    return OrcaDevicePollResponse(status=DevicePoll.COMPLETE, connected=True, email=None, user_id=user_id)
 
 
 @router.get("/status", response_model=OrcaAuthStatusResponse)
@@ -583,9 +587,9 @@ async def logout(
     db: AsyncSession = Depends(get_db),
     current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
 ):
-    """Clear stored Orca Cloud credentials. Does not call Supabase's
-    ``/logout`` endpoint (the token would still survive its 1h expiry there
-    either way, and Bambuddy will no longer have it to use)."""
+    """Clear stored Orca Cloud credentials. Does not call Orca's disconnect
+    endpoint (the user can revoke server-side from Orca Cloud's own settings;
+    Bambuddy will no longer have the token to use either way)."""
     await _clear_credentials(db, current_user)
     return {"success": True}
 

+ 18 - 5
backend/app/api/routes/pipeline_runs.py

@@ -374,11 +374,21 @@ async def _resolve_source(
     *,
     library_file_id: int | None,
     archive_id: int | None,
+    user: User | None,
 ) -> tuple[SourceKind, int, str, Path]:
+    # Per-row ownership gate (IDOR fix): a caller may only run a pipeline on a
+    # source they can see. Without this a READ_OWN caller could reference
+    # another user's library file / archive by raw id and have it sliced (and,
+    # via /run, printed) even though a direct GET on that id returned 404.
+    # Auth-disabled and API-key callers (user is None) keep can_read_all=True —
+    # no per-row identity, matching the library/archive read helpers.
+    from backend.app.api.routes.archives import _ensure_archive_visible
+    from backend.app.api.routes.library import _ensure_library_file_visible
+
     if library_file_id is not None:
         lib = (await db.execute(select(LibraryFile).where(LibraryFile.id == library_file_id))).scalar_one_or_none()
-        if lib is None:
-            raise HTTPException(404, "Source library file not found")
+        can_read_all = user is None or user.has_permission(Permission.LIBRARY_READ_ALL.value)
+        lib = _ensure_library_file_visible(lib, user, can_read_all)
         src_path = (
             Path(app_settings.base_dir) / lib.file_path
         )  # SEC-PATH-OK: lib.file_path is a LibraryFile DB column set only by the upload route, which writes a UUID-named file under base_dir/library_files/.
@@ -388,8 +398,8 @@ async def _resolve_source(
 
     assert archive_id is not None
     arc = (await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))).scalar_one_or_none()
-    if arc is None:
-        raise HTTPException(404, "Source archive not found")
+    can_read_all = user is None or user.has_permission(Permission.ARCHIVES_READ_ALL.value)
+    arc = _ensure_archive_visible(arc, user, can_read_all)
     rel = arc.source_3mf_path or arc.file_path
     if not rel:
         raise HTTPException(400, "Archive has no source file to slice")
@@ -625,7 +635,7 @@ def _make_orchestration_callable(
 async def check_eligibility(
     pipeline_id: int,
     body: CheckEligibilityRequest,
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
     db: AsyncSession = Depends(get_db),
 ):
     pipeline = await _load_pipeline(db, pipeline_id)
@@ -633,6 +643,7 @@ async def check_eligibility(
         db,
         library_file_id=body.source_library_file_id,
         archive_id=body.source_archive_id,
+        user=current_user,
     )
     if pipeline.target_kind == "printer_class" and pipeline.target_printer_id is None:
         report = await check_pipeline_eligibility(db, pipeline, status_lookup=_make_status_lookup())
@@ -662,6 +673,7 @@ async def run_pipeline(
         db,
         library_file_id=body.source_library_file_id,
         archive_id=body.source_archive_id,
+        user=current_user,
     )
 
     # Cap copies against the configured ceiling.
@@ -732,6 +744,7 @@ async def run_pipeline(
         kind="library_file" if src_kind == "library_file" else "archive",
         source_id=src_id,
         source_name=src_filename,
+        owner_id=current_user.id if current_user else None,
         run=orchestrate,
     )
 

+ 133 - 40
backend/app/api/routes/print_queue.py

@@ -35,11 +35,15 @@ from backend.app.schemas.print_queue import (
     PrintQueueReorder,
 )
 from backend.app.services.filament_deficit import compute_deficit_for_queue_item
+from backend.app.services.filament_requirements import overrides_for_plate
 from backend.app.services.notification_service import notification_service
-from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id
+from backend.app.utils.printer_models import (
+    is_gcode_compatible,
+    normalize_printer_model,
+    normalize_printer_model_id,
+)
 from backend.app.utils.threemf_tools import (
-    extract_bed_type_from_3mf,
-    extract_filament_usage_from_3mf,
+    extract_plate_metadata_from_3mf,
     extract_print_time_from_3mf,
 )
 
@@ -115,6 +119,22 @@ def _extract_filament_types_from_3mf(file_path: Path, plate_id: int | None = Non
 _extract_print_time_from_3mf = extract_print_time_from_3mf
 
 
+async def _resolve_source_path(db: AsyncSession, item: PrintQueueItem) -> Path | None:
+    """Resolve an existing queue item's source 3MF on disk, or None."""
+    if item.archive_id:
+        result = await db.execute(select(PrintArchive).where(PrintArchive.id == item.archive_id))
+        archive = result.scalar_one_or_none()
+        if archive:
+            return settings.base_dir / archive.file_path
+    elif item.library_file_id:
+        result = await db.execute(LibraryFile.active().where(LibraryFile.id == item.library_file_id))
+        library_file = result.scalar_one_or_none()
+        if library_file:
+            lib_path = Path(library_file.file_path)
+            return lib_path if lib_path.is_absolute() else settings.base_dir / library_file.file_path
+    return None
+
+
 def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
     """Add nested archive/printer/library_file info to response."""
     # Parse ams_mapping from JSON string BEFORE model_validate
@@ -233,17 +253,14 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
             if item.plate_id:
                 archive_path = settings.base_dir / item.archive.file_path
                 if archive_path.exists():
-                    plate_time = _extract_print_time_from_3mf(archive_path, item.plate_id)
-                    plate_weight = sum(
-                        f["used_g"] for f in extract_filament_usage_from_3mf(archive_path, item.plate_id)
-                    )
-                    plate_bed = extract_bed_type_from_3mf(archive_path, item.plate_id)
-                    if plate_time is not None:
-                        response.print_time_seconds = plate_time
-                    if plate_weight > 0:
-                        response.filament_used_grams = plate_weight
-                    if plate_bed:
-                        response.bed_type = plate_bed
+                    # One cached parse for all three per-plate overrides (#2573).
+                    plate_meta = extract_plate_metadata_from_3mf(archive_path, item.plate_id)
+                    if plate_meta.print_time_seconds is not None:
+                        response.print_time_seconds = plate_meta.print_time_seconds
+                    if plate_meta.filament_used_grams > 0:
+                        response.filament_used_grams = plate_meta.filament_used_grams
+                    if plate_meta.bed_type:
+                        response.bed_type = plate_meta.bed_type
     if item.library_file:
         response.library_file_name = (
             item.library_file.file_metadata.get("print_name") if item.library_file.file_metadata else None
@@ -265,17 +282,14 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
             lib_path = Path(item.library_file.file_path)
             library_file_path = lib_path if lib_path.is_absolute() else settings.base_dir / item.library_file.file_path
             if library_file_path.exists():
-                plate_time = _extract_print_time_from_3mf(library_file_path, item.plate_id)
-                plate_weight = sum(
-                    f["used_g"] for f in extract_filament_usage_from_3mf(library_file_path, item.plate_id)
-                )
-                plate_bed = extract_bed_type_from_3mf(library_file_path, item.plate_id)
-                if plate_time is not None:
-                    response.print_time_seconds = plate_time
-                if plate_weight > 0:
-                    response.filament_used_grams = plate_weight
-                if plate_bed:
-                    response.bed_type = plate_bed
+                # One cached parse for all three per-plate overrides (#2573).
+                plate_meta = extract_plate_metadata_from_3mf(library_file_path, item.plate_id)
+                if plate_meta.print_time_seconds is not None:
+                    response.print_time_seconds = plate_meta.print_time_seconds
+                if plate_meta.filament_used_grams > 0:
+                    response.filament_used_grams = plate_meta.filament_used_grams
+                if plate_meta.bed_type:
+                    response.bed_type = plate_meta.bed_type
     if item.printer:
         response.printer_name = item.printer.name
     return response
@@ -449,11 +463,27 @@ async def add_to_queue(
         except InvalidFilenameError as e:
             raise HTTPException(400, str(e)) from e
 
+    # Cross-model safety gate (#2578): a G-code 3MF sliced for one model must
+    # not be queued for dispatch to an incompatible model. The UI can no longer
+    # produce such rows, but API-created rows must be rejected here too — the
+    # scheduler assigns model-based items to hardware with no human in the loop.
+    if target_model_norm:
+        sliced_for = None
+        if archive:
+            sliced_for = archive.sliced_for_model
+        elif library_file and library_file.file_metadata:
+            sliced_for = library_file.file_metadata.get("sliced_for_model")
+        if not is_gcode_compatible(sliced_for, target_model_norm):
+            raise HTTPException(
+                400,
+                f"File was sliced for {sliced_for} and cannot be dispatched to {target_model_norm} printers",
+            )
+
     # Extract filament types for model-based assignment (used by scheduler for validation)
     required_filament_types = None
+    file_path = None
     if target_model_norm:
         # Get file path from archive or library file
-        file_path = None
         if archive:
             file_path = settings.base_dir / archive.file_path
         elif library_file:
@@ -469,15 +499,17 @@ async def add_to_queue(
     # If filament overrides are provided, update required_filament_types to match override types
     filament_overrides_json = None
     if data.filament_overrides and target_model_norm:
-        filament_overrides_json = json.dumps(data.filament_overrides)
-        # Update required_filament_types from overrides so scheduler validates against overridden types
-        override_types = sorted({o["type"] for o in data.filament_overrides if "type" in o})
-        if override_types:
-            # Merge with existing types (overrides may only cover some slots)
-            existing_types = set(json.loads(required_filament_types)) if required_filament_types else set()
-            # Replace types for overridden slots, keep others
-            all_types = existing_types | set(override_types)
-            required_filament_types = json.dumps(sorted(all_types))
+        plate_overrides = overrides_for_plate(data.filament_overrides, file_path, data.plate_id)
+        if plate_overrides:
+            filament_overrides_json = json.dumps(plate_overrides)
+            # Update required_filament_types from overrides so scheduler validates against overridden types
+            override_types = sorted({o["type"] for o in plate_overrides if "type" in o})
+            if override_types:
+                # Merge with existing types (overrides may only cover some slots)
+                existing_types = set(json.loads(required_filament_types)) if required_filament_types else set()
+                # Replace types for overridden slots, keep others
+                all_types = existing_types | set(override_types)
+                required_filament_types = json.dumps(sorted(all_types))
 
     # Validate quantity
     quantity = max(1, data.quantity)
@@ -742,7 +774,10 @@ async def bulk_update_queue_items(
     skipped_count = 0
 
     for item in items:
-        if item.status != "pending":
+        # Skip non-pending rows and rows a dispatch worker has claimed (#2615) —
+        # editing a claimed row mid-upload would split it from the in-flight
+        # dispatch, so it's excluded from the bulk change (cancel to move it).
+        if item.status != "pending" or item.dispatching_at is not None:
             skipped_count += 1
             continue
 
@@ -1050,6 +1085,14 @@ async def update_queue_item(
     if item.status != "pending":
         raise HTTPException(400, "Can only update pending items")
 
+    # Dispatch claim (#2615): the row is pending but a scheduler worker has
+    # already claimed it and is uploading to its printer. Editing now (e.g.
+    # reassigning printer_id) would split the queue row from the in-flight
+    # archive/expected-print/physical command. Reject until dispatch finishes;
+    # to move it, cancel first (the coordinated escape) and re-queue.
+    if item.dispatching_at is not None:
+        raise HTTPException(409, "Item is being dispatched — cancel it first to make changes")
+
     update_data = data.model_dump(exclude_unset=True)
 
     # Normalize target_model if being updated
@@ -1080,15 +1123,39 @@ async def update_queue_item(
         if not result.scalars().first():
             raise HTTPException(400, f"No active printers for model: {update_data['target_model']}")
 
+        # Cross-model safety gate (#2578) — same check as the create route, so
+        # a mismatched target can't be introduced by editing either.
+        sliced_for = None
+        if item.archive_id:
+            result = await db.execute(select(PrintArchive.sliced_for_model).where(PrintArchive.id == item.archive_id))
+            sliced_for = result.scalar_one_or_none()
+        elif item.library_file_id:
+            result = await db.execute(select(LibraryFile).where(LibraryFile.id == item.library_file_id))
+            lib = result.scalar_one_or_none()
+            if lib and lib.file_metadata:
+                sliced_for = lib.file_metadata.get("sliced_for_model")
+        if not is_gcode_compatible(sliced_for, update_data["target_model"]):
+            raise HTTPException(
+                400,
+                f"File was sliced for {sliced_for} and cannot be dispatched to {update_data['target_model']} printers",
+            )
+
     # Serialize ams_mapping to JSON for TEXT column storage
     if "ams_mapping" in update_data:
         update_data["ams_mapping"] = json.dumps(update_data["ams_mapping"]) if update_data["ams_mapping"] else None
 
-    # Serialize filament_overrides to JSON for TEXT column storage
+    # Serialize filament_overrides to JSON for TEXT column storage, keeping only
+    # the slots this item's plate actually prints (#2551 — same shared-override
+    # list the create path narrows).
     if "filament_overrides" in update_data:
-        update_data["filament_overrides"] = (
-            json.dumps(update_data["filament_overrides"]) if update_data["filament_overrides"] else None
-        )
+        overrides = update_data["filament_overrides"]
+        if overrides:
+            overrides = overrides_for_plate(
+                overrides,
+                await _resolve_source_path(db, item),
+                update_data.get("plate_id", item.plate_id),
+            )
+        update_data["filament_overrides"] = json.dumps(overrides) if overrides else None
 
     # Serialize H2C rack-swap nozzle pick (#1780) to JSON for TEXT column
     # storage; same Text-as-opaque-blob convention as ams_mapping above.
@@ -1097,6 +1164,16 @@ async def update_queue_item(
             json.dumps(update_data["nozzle_mapping"]) if update_data["nozzle_mapping"] else None
         )
 
+    # Re-check the dispatch claim right before mutating (#2615). Several awaited
+    # validations ran since the guard above, and a scheduler worker may have
+    # claimed the row in that gap. A fresh read (item isn't dirty yet, so no
+    # autoflush races the check) narrows the window to effectively nothing.
+    claimed = (
+        await db.execute(select(PrintQueueItem.dispatching_at).where(PrintQueueItem.id == item_id))
+    ).scalar_one_or_none()
+    if claimed is not None:
+        raise HTTPException(409, "Item is being dispatched — cancel it first to make changes")
+
     for field, value in update_data.items():
         setattr(item, field, value)
 
@@ -1318,6 +1395,22 @@ async def stop_queue_item(
     item.status = "cancelled"
     item.completed_at = datetime.now(timezone.utc)
     item.error_message = "Stopped by user" if stop_sent else "Stopped by user (printer was offline)"
+
+    # Reconcile the linked archive when the printer is offline (#2603). When the
+    # stop command reaches the printer it later reports the stop over MQTT and
+    # on_print_complete flips the archive to cancelled/failed. When the printer is
+    # offline no such event ever arrives, so the archive would stay "printing"
+    # forever (queue row cancelled, archive still printing — the reporter's
+    # archive 436). Close it out here, mirroring what the MQTT path would have
+    # done. Only touch a still-"printing" archive so we never overwrite a real
+    # completion that raced in.
+    if not stop_sent and item.archive_id:
+        archive = await db.get(PrintArchive, item.archive_id)
+        if archive and archive.status == "printing":
+            archive.status = "cancelled"
+            archive.completed_at = datetime.now(timezone.utc)
+            archive.failure_reason = "Stopped by user (printer was offline)"
+
     await db.commit()
 
     logger.info("Stopped printing queue item %s (stop command sent: %s)", item_id, stop_sent)

+ 293 - 70
backend/app/api/routes/printers.py

@@ -8,8 +8,10 @@ from fastapi.responses import Response
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core import database
 from backend.app.core.auth import (
     RequireCameraStreamTokenIfAuthEnabled,
+    RequireOverlayTokenIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     is_auth_enabled,
 )
@@ -50,14 +52,17 @@ from backend.app.services.bambu_ftp import (
 )
 from backend.app.services.printer_diagnostic import run_connection_diagnostic
 from backend.app.services.printer_manager import (
+    drying_screen_only,
     get_derived_status_name,
     printer_manager,
+    resolve_expected_tray,
     resolve_plate_id,
     supports_chamber_heater,
     supports_chamber_temp,
     supports_drying,
     supports_drying_while_printing,
 )
+from backend.app.utils.filament_ids import filament_id_to_setting_id
 from backend.app.utils.http import build_content_disposition
 
 logger = logging.getLogger(__name__)
@@ -535,6 +540,7 @@ async def get_printer_status(
                         drying_temp=tray_data.get("drying_temp"),
                         drying_time=tray_data.get("drying_time"),
                         state=tray_data.get("state"),
+                        exists=tray_data.get("exists"),
                     )
                 )
             # Prefer humidity_raw (percentage) over humidity (index 1-5)
@@ -763,6 +769,26 @@ async def get_printer_status(
         ams_mapping=ams_mapping,
         ams_extruder_map=ams_extruder_map,
         tray_now=tray_now,
+        # Runout guidance (#2587): resolve the firmware's target/previous slot to a
+        # global tray ID, but only while PAUSED — the moment the operator needs it.
+        expected_tray=(
+            resolve_expected_tray(
+                state.tray_tar,
+                [(u.id, u.is_ams_ht) for u in ams_units],
+                raw_data.get("mapping"),
+            )
+            if state.state == "PAUSE"
+            else None
+        ),
+        previous_tray=(
+            resolve_expected_tray(
+                state.tray_pre,
+                [(u.id, u.is_ams_ht) for u in ams_units],
+                raw_data.get("mapping"),
+            )
+            if state.state == "PAUSE"
+            else None
+        ),
         ams_status_main=state.ams_status_main,
         ams_status_sub=state.ams_status_sub,
         mc_print_sub_stage=state.mc_print_sub_stage,
@@ -778,6 +804,7 @@ async def get_printer_status(
         awaiting_plate_clear=printer_manager.is_awaiting_plate_clear(printer_id),
         supports_drying=supports_drying(printer.model, state.firmware_version),
         supports_drying_while_printing=supports_drying_while_printing(printer.model, state.firmware_version),
+        drying_screen_only=drying_screen_only(printer.model),
         supports_chamber_heater=supports_chamber_heater(printer.model),
         current_archive_id=current_archive_id,
         current_plate_id=current_plate_id,
@@ -795,6 +822,70 @@ async def get_printer_status(
     )
 
 
+@router.get("/{printer_id}/overlay-status")
+async def get_overlay_status(
+    printer_id: int,
+    _: None = RequireOverlayTokenIfAuthEnabled,
+    db: AsyncSession = Depends(get_db),
+) -> dict:
+    """Everything the streaming overlay (#2613) draws for one printer.
+
+    A token-authenticated sibling of ``get_printer_status`` for embeds with no
+    login session — OBS loads ``/overlay/{id}?token=...`` and this feeds it.
+    Deliberately flat and minimal (name, camera rotation, live print state, and
+    the one setting the overlay reads) rather than the full ``PrinterStatus``:
+    a token holder gets exactly the fields the overlay renders, nothing more.
+
+    Unlike the Cam Wall feed this *includes the print filename* — the overlay
+    names the part on screen — which is why it sits behind its own ``overlay``
+    scope rather than ``camwall``.
+    """
+    from backend.app.api.routes.settings import get_setting
+
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    time_format = await get_setting(db, "time_format") or "system"
+    state = printer_manager.get_status(printer_id)
+
+    if not state:
+        # Never connected this run — mirror get_printer_status()'s disconnected
+        # shape so the overlay renders its offline state rather than erroring.
+        return {
+            "id": printer_id,
+            "name": printer.name,
+            "camera_rotation": printer.camera_rotation or 0,
+            "connected": False,
+            "state": None,
+            "current_print": None,
+            "gcode_file": None,
+            "progress": None,
+            "remaining_time": None,
+            "layer_num": None,
+            "total_layers": None,
+            "stg_cur_name": None,
+            "time_format": time_format,
+        }
+
+    return {
+        "id": printer_id,
+        "name": printer.name,
+        "camera_rotation": printer.camera_rotation or 0,
+        "connected": state.connected,
+        "state": state.state,
+        "current_print": state.current_print,
+        "gcode_file": state.gcode_file,
+        "progress": state.progress,
+        "remaining_time": state.remaining_time,
+        "layer_num": state.layer_num,
+        "total_layers": state.total_layers,
+        "stg_cur_name": get_derived_status_name(state, printer.model),
+        "time_format": time_format,
+    }
+
+
 @router.get("/{printer_id}/current-print-user")
 async def get_current_print_user(
     printer_id: int,
@@ -938,6 +1029,15 @@ _cover_cache: dict[int, dict[tuple[str, str], bytes]] = {}
 # Cleared on print start alongside _cover_cache.
 _cover_404_cache: dict[int, set[tuple[str, str]]] = {}
 
+# In-flight cover downloads, keyed by (printer_id, subtask_name, view_key) (#2572).
+# The farm dashboard mounts a cover tile per printer card, so several browsers
+# request the same printer's cover in the same instant, all miss the cache, and
+# each runs the full multi-path FTP lookup + 3MF extraction (one observed live
+# transfer pulled an 81 MB 3MF while real print uploads were in flight). The
+# first request to miss becomes the leader; concurrent requests await its future
+# and then serve from the positive/negative cache it filled.
+_cover_inflight: dict[tuple[int, str, str], asyncio.Future] = {}
+
 
 def clear_cover_cache(printer_id: int) -> None:
     """Clear cached cover images for a printer. Call on print start to avoid stale thumbnails."""
@@ -949,17 +1049,30 @@ def clear_cover_cache(printer_id: int) -> None:
 async def get_printer_cover(
     printer_id: int,
     view: str | None = None,
-    db: AsyncSession = Depends(get_db),
     _: None = RequireCameraStreamTokenIfAuthEnabled,
 ):
     """Get the cover image for the current print job.
 
     Args:
-        view: Optional view type. Use "top" for top-down build plate view (useful for skip objects).
+        view: Optional view type. Use "top" for the top-down build plate view or
+              "pick" for the slicer's object-ID mask used by skip objects.
               Default returns angled 3D perspective view.
     """
-    result = await db.execute(select(Printer).where(Printer.id == printer_id))
-    printer = result.scalar_one_or_none()
+    # Fetch the printer in a short-lived session and release the pooled DB
+    # connection BEFORE the FTP download below. Previously this route took its
+    # row via Depends(get_db), whose session stays open for the whole request —
+    # so a 3MF cover download (up to 8 paths × 3 retries with backoff, minutes
+    # under FTP contention) pinned one pooled connection idle-in-transaction the
+    # entire time (issue #2572). db is used only for this one SELECT; everything
+    # after reads already-loaded printer.* scalars (expire_on_commit=False keeps
+    # them readable), printer_manager, and FTP/zip — no lazy loads.
+    #
+    # Reference async_session via the module so the maker is looked up at call
+    # time — keeps it in sync with reinitialize_database() and lets the test
+    # harness's patch of backend.app.core.database.async_session take effect.
+    async with database.async_session() as db:
+        result = await db.execute(select(Printer).where(Printer.id == printer_id))
+        printer = result.scalar_one_or_none()
     if not printer:
         raise HTTPException(404, "Printer not found")
 
@@ -1002,6 +1115,53 @@ async def get_printer_cover(
     if printer_id in _cover_404_cache and cache_key in _cover_404_cache[printer_id]:
         raise HTTPException(404, f"No cover available for '{subtask_name}' (cached)")
 
+    # Coalesce concurrent downloads for the same cover (#2572). The positive and
+    # negative caches were just checked above; if another request is already
+    # downloading this exact cover, wait for it and serve from the cache it fills
+    # instead of launching a duplicate multi-path FTP + 3MF extraction.
+    inflight_key = (printer_id, subtask_name, view_key)
+    leader = _cover_inflight.get(inflight_key)
+    if leader is not None:
+        # shield() so our own cancellation can't cancel the shared leader.
+        try:
+            await asyncio.shield(leader)
+        except Exception:
+            pass
+        if printer_id in _cover_cache and cache_key in _cover_cache[printer_id]:
+            return Response(content=_cover_cache[printer_id][cache_key], media_type="image/png")
+        if printer_id in _cover_404_cache and cache_key in _cover_404_cache[printer_id]:
+            raise HTTPException(404, f"No cover available for '{subtask_name}' (cached)")
+        # Leader finished without filling either cache (a transient 503) — fall
+        # through and try the download ourselves.
+
+    fut: asyncio.Future = asyncio.get_event_loop().create_future()
+    _cover_inflight[inflight_key] = fut
+    try:
+        image_data = await _produce_cover_image(printer, printer_id, subtask_name, view, view_key, plate_num, cache_key)
+        return Response(content=image_data, media_type="image/png")
+    finally:
+        if not fut.done():
+            fut.set_result(None)
+        _cover_inflight.pop(inflight_key, None)
+
+
+async def _produce_cover_image(
+    printer: Printer,
+    printer_id: int,
+    subtask_name: str,
+    view: str | None,
+    view_key: str,
+    plate_num: int | None,
+    cache_key: tuple[str, str],
+) -> bytes:
+    """Download the active-print 3MF and extract its cover thumbnail (#2572).
+
+    Split out of ``get_printer_cover`` so concurrent requests for the same cover
+    can single-flight through it (see ``_cover_inflight``). Returns the PNG bytes
+    on success (also filling ``_cover_cache``) and raises ``HTTPException`` on
+    failure (filling ``_cover_404_cache`` for the definitive 404s). Does no DB
+    work — the caller already released the pooled connection before this runs.
+    """
     # Build possible 3MF filenames from subtask_name
     # Bambu printers may store files as "name.gcode.3mf" (sliced via Bambu Studio)
     # or just "name.3mf" (uploaded directly)
@@ -1138,7 +1298,14 @@ async def get_printer_cover(
             # Try common thumbnail paths in 3MF files
             # Use plate_num to get the correct plate's thumbnail for multi-plate projects
             # Use top-down view if requested (better for skip objects modal)
-            if view == "top":
+            if view == "pick":
+                # Only the active plate's mask, with no fallback: every other view
+                # falls back to plate 1 because a slightly wrong picture is better
+                # than none, but a mask is coordinates, not decoration. Plate 1's
+                # mask over plate 3's layout would resolve clicks to whichever
+                # object happened to occupy that pixel on a different plate.
+                thumbnail_paths = [f"Metadata/pick_{plate_num}.png"]
+            elif view == "top":
                 thumbnail_paths = [
                     f"Metadata/top_{plate_num}.png",
                     # Fall back to plate 1 if specific plate not found
@@ -1165,18 +1332,25 @@ async def get_printer_cover(
                     if printer_id not in _cover_cache:
                         _cover_cache[printer_id] = {}
                     _cover_cache[printer_id][(subtask_name, view_key)] = image_data
-                    return Response(content=image_data, media_type="image/png")
+                    return image_data
                 except KeyError:
                     continue
 
-            # If no specific thumbnail found, try any PNG in Metadata
-            for name in zf.namelist():
-                if name.startswith("Metadata/") and name.endswith(".png"):
-                    image_data = zf.read(name)
-                    if printer_id not in _cover_cache:
-                        _cover_cache[printer_id] = {}
-                    _cover_cache[printer_id][(subtask_name, view_key)] = image_data
-                    return Response(content=image_data, media_type="image/png")
+            # If no specific thumbnail found, try any PNG in Metadata. Never for
+            # "pick": handing back a rendered thumbnail in place of the object-ID
+            # mask is worse than nothing, because the caller can't tell the
+            # difference and decodes the render's pixel colours as object IDs —
+            # dark pixels yield small integers that collide with real IDs, so a
+            # click would select an arbitrary object and skip it irreversibly.
+            # A 404 is what tells the UI to fall back to the checklist.
+            if view != "pick":
+                for name in zf.namelist():
+                    if name.startswith("Metadata/") and name.endswith(".png"):
+                        image_data = zf.read(name)
+                        if printer_id not in _cover_cache:
+                            _cover_cache[printer_id] = {}
+                        _cover_cache[printer_id][(subtask_name, view_key)] = image_data
+                        return image_data
 
             _cover_404_cache.setdefault(printer_id, set()).add(cache_key)
             raise HTTPException(404, "No thumbnail found in 3MF file")
@@ -1196,18 +1370,37 @@ async def get_printer_cover(
 # ============================================
 
 
+async def _load_printer_or_404(printer_id: int) -> Printer:
+    """Load a printer in a short-lived session, releasing the pooled DB
+    connection before the caller starts any FTP/network I/O (#2572).
+
+    The file-manager and storage routes talk FTP to the printer, which can
+    block for the full socket timeout — longer when a saturated FTP pool backs
+    up. Holding the request's Depends(get_db) session across that FTP pinned one
+    pooled connection idle-in-transaction per in-flight request, a top cause of
+    pool exhaustion on large farms. The returned row's scalar columns stay
+    readable after the session closes (expire_on_commit=False). Raises 404 when
+    the printer doesn't exist.
+
+    Reference async_session via the module so the maker is resolved at call time
+    — keeps it in sync with reinitialize_database() and lets tests patch it.
+    """
+    async with database.async_session() as db:
+        result = await db.execute(select(Printer).where(Printer.id == printer_id))
+        printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+    return printer
+
+
 @router.get("/{printer_id}/files")
 async def list_printer_files(
     printer_id: int,
     path: str = "/",
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
-    db: AsyncSession = Depends(get_db),
 ):
     """List files on the printer at the specified path."""
-    result = await db.execute(select(Printer).where(Printer.id == printer_id))
-    printer = result.scalar_one_or_none()
-    if not printer:
-        raise HTTPException(404, "Printer not found")
+    printer = await _load_printer_or_404(printer_id)
 
     files = await list_files_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
 
@@ -1226,13 +1419,9 @@ async def download_printer_file(
     printer_id: int,
     path: str,
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
-    db: AsyncSession = Depends(get_db),
 ):
     """Download a file from the printer."""
-    result = await db.execute(select(Printer).where(Printer.id == printer_id))
-    printer = result.scalar_one_or_none()
-    if not printer:
-        raise HTTPException(404, "Printer not found")
+    printer = await _load_printer_or_404(printer_id)
 
     data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
     if data is None:
@@ -1267,16 +1456,11 @@ async def get_printer_file_gcode(
     printer_id: int,
     path: str,
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
-    db: AsyncSession = Depends(get_db),
 ):
     """Get gcode for a file stored on a printer (for preview)."""
     import io
 
-    # Validate printer
-    result = await db.execute(select(Printer).where(Printer.id == printer_id))
-    printer = result.scalar_one_or_none()
-    if not printer:
-        raise HTTPException(404, "Printer not found")
+    printer = await _load_printer_or_404(printer_id)
 
     data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
     if data is None:
@@ -1306,7 +1490,6 @@ async def get_printer_file_plates(
     printer_id: int,
     path: str = Query(..., description="Full path to the 3MF file on the printer"),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
-    db: AsyncSession = Depends(get_db),
 ):
     """Get available plates from a multi-plate 3MF file stored on a printer."""
     import io
@@ -1314,11 +1497,7 @@ async def get_printer_file_plates(
 
     import defusedxml.ElementTree as ET
 
-    # Validate printer
-    result = await db.execute(select(Printer).where(Printer.id == printer_id))
-    printer = result.scalar_one_or_none()
-    if not printer:
-        raise HTTPException(404, "Printer not found")
+    printer = await _load_printer_or_404(printer_id)
 
     filename = path.split("/")[-1]
     if not filename.lower().endswith(".3mf"):
@@ -1551,15 +1730,11 @@ async def get_printer_file_plate_thumbnail(
     plate_index: int,
     path: str = Query(..., description="Full path to the 3MF file on the printer"),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
-    db: AsyncSession = Depends(get_db),
 ):
     """Get a plate thumbnail image from a printer-stored 3MF file."""
     import io
 
-    result = await db.execute(select(Printer).where(Printer.id == printer_id))
-    printer = result.scalar_one_or_none()
-    if not printer:
-        raise HTTPException(404, "Printer not found")
+    printer = await _load_printer_or_404(printer_id)
 
     data = await download_file_bytes_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
     if data is None:
@@ -1582,7 +1757,6 @@ async def download_printer_files_as_zip(
     printer_id: int,
     request: dict,
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
-    db: AsyncSession = Depends(get_db),
 ):
     """Download multiple files from the printer as a ZIP archive."""
     import io
@@ -1591,10 +1765,7 @@ async def download_printer_files_as_zip(
     if not paths:
         raise HTTPException(400, "No files specified")
 
-    result = await db.execute(select(Printer).where(Printer.id == printer_id))
-    printer = result.scalar_one_or_none()
-    if not printer:
-        raise HTTPException(404, "Printer not found")
+    printer = await _load_printer_or_404(printer_id)
 
     # Create ZIP in memory
     zip_buffer = io.BytesIO()
@@ -1629,13 +1800,9 @@ async def delete_printer_file(
     printer_id: int,
     path: str,
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
-    db: AsyncSession = Depends(get_db),
 ):
     """Delete a file from the printer."""
-    result = await db.execute(select(Printer).where(Printer.id == printer_id))
-    printer = result.scalar_one_or_none()
-    if not printer:
-        raise HTTPException(404, "Printer not found")
+    printer = await _load_printer_or_404(printer_id)
 
     from backend.app.services.bambu_ftp import DeleteResult
 
@@ -1652,13 +1819,9 @@ async def delete_printer_file(
 async def get_printer_storage(
     printer_id: int,
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
-    db: AsyncSession = Depends(get_db),
 ):
     """Get storage information from the printer."""
-    result = await db.execute(select(Printer).where(Printer.id == printer_id))
-    printer = result.scalar_one_or_none()
-    if not printer:
-        raise HTTPException(404, "Printer not found")
+    printer = await _load_printer_or_404(printer_id)
 
     storage_info = await get_storage_info_async(printer.ip_address, printer.access_code, printer_model=printer.model)
 
@@ -1755,6 +1918,11 @@ async def clear_mqtt_logs(
 # AMS Drying Endpoints
 # ============================================
 
+# The P1 firmware acks `ams_filament_drying` with result: success and then ignores it
+# — Bambu's own P1 manual says drying "may only be controlled from the P1S screen"
+# (#2533). Refuse the command rather than let the caller believe it landed.
+_DRYING_SCREEN_ONLY_DETAIL = "This printer only supports AMS drying from its own screen"
+
 
 @router.post("/{printer_id}/drying/start")
 async def start_drying(
@@ -1776,6 +1944,8 @@ async def start_drying(
     # Server-side guard: reject if this model/firmware doesn't support drying
     live_state = printer_manager.get_status(printer_id)
     firmware = live_state.firmware_version if live_state else None
+    if drying_screen_only(printer.model):
+        raise HTTPException(400, _DRYING_SCREEN_ONLY_DETAIL)
     if not supports_drying(printer.model, firmware):
         raise HTTPException(400, "Drying not supported for this printer model or firmware version")
 
@@ -1848,6 +2018,11 @@ async def stop_drying(
     if not printer:
         raise HTTPException(404, "Printer not found")
 
+    # Screen-only models ignore stop just as they ignore start — a cycle running on a
+    # P1S was started at the printer and has to be ended there too (#2533).
+    if drying_screen_only(printer.model):
+        raise HTTPException(400, _DRYING_SCREEN_ONLY_DETAIL)
+
     success = printer_manager.send_drying_command(printer_id, ams_id, temp=0, duration=0, mode=0)
     if not success:
         raise HTTPException(400, "Printer not connected")
@@ -2353,6 +2528,18 @@ async def configure_ams_slot(
         if kprofile_setting_id:
             effective_setting_id = kprofile_setting_id
 
+    # Back-fill setting_id from the resolved filament id when the client sent
+    # none. Built-in / local / Orca-generic presets in the Configure AMS Slot
+    # modal leave setting_id empty (they carry only a GF* tray_info_idx), and
+    # the printer treats a filament-id-without-setting-id slot as half
+    # configured: it shows the new material briefly, then reverts to its
+    # previously stored profile (#2604). This mirrors the derivation the
+    # inventory/assignment path already does (inventory.py). filament_id_to_
+    # setting_id leaves P* user presets and already-GFS* values unchanged, so
+    # only the empty-setting_id generic paths are affected.
+    if effective_tray_info_idx and not effective_setting_id:
+        effective_setting_id = filament_id_to_setting_id(effective_tray_info_idx)
+
     # Always send ams_set_filament_setting — the user explicitly clicked
     # "Configure Slot", so honor that.  Previous versions skipped this for
     # RFID-tagged slots to preserve the slicer eye icon, but printers cache
@@ -2548,6 +2735,17 @@ async def configure_ams_slot(
             except Exception:
                 pass
 
+    # Register a read-back verification (#2582) so the tray telemetry that the
+    # status push below returns can confirm the printer accepted this manual
+    # slot configuration. Mirrors the inventory/assignment path.
+    client.register_assignment_verification(
+        ams_id=ams_id,
+        tray_id=tray_id,
+        tray_info_idx=effective_tray_info_idx,
+        tray_color=tray_color,
+        cali_idx=cali_idx,
+    )
+
     # Request fresh status push from printer so frontend gets updated data via WebSocket
     logger.info("[configure_ams_slot] Requesting status update from printer")
     update_result = client.request_status_update()
@@ -3111,16 +3309,29 @@ async def bed_jog(
             "translates this into the right G-code Z sign per printer model."
         ),
     ),
-    force: bool = Query(False, description="If true, bypass soft endstops via M211 (for use when Z is not homed)"),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Adjust the nozzle-bed gap by a relative distance.
 
-    Emits a short G-code sequence via MQTT. When ``force`` is true the soft
-    endstops are disabled for the duration of the move, matching the
-    "ignore and move anyway" option Bambu Studio offers when the printer
-    is not homed.
+    Emits a short G-code sequence via MQTT.
+
+    Soft-endstop policy (#2579). The printer's software travel limits are the
+    only thing between a jog button and a bed crash — on Bambu machines the
+    physical endstops are homing-only (there is no runtime limit switch in the
+    travel path), so once they are disabled nothing stops the move. The old
+    code disabled them (``M211 S0``) around every forced jog, and the UI sent
+    ``force`` on every jog, so the limits were off on every bed move — that is
+    what let a jog drive the nozzle into the bed on all models (#2579). This
+    endpoint now emits a **bare relative move and never touches ``M211`` at
+    all** — byte-for-byte what the printer's own touchscreen jog sends, which
+    stops at the travel limit. Bambuddy no longer disables the firmware's soft
+    endstops, and it no longer sends ``M211 S1`` either: that was an unverified
+    attempt to re-enable a printer left disabled by an older build, and on real
+    hardware the jog moved past the limit *with* it. If a printer still jogs
+    past its limits, its endstops were disabled at the firmware level by the old
+    build — power-cycle it once to restore them; from then on Bambuddy leaves
+    them alone.
 
     Direction handling: on bed-on-Z printers (X1 / P1 / H2 family) the bed
     is the Z-axis, and Bambu's home convention puts Z=0 at the top with
@@ -3147,12 +3358,10 @@ async def bed_jog(
 
     gcode_distance = -distance if is_bed_slinger(printer.model) else distance
 
-    lines = []
-    if force:
-        lines.append("M211 S0")
-    lines += ["G91", f"G1 Z{gcode_distance:.2f} F600", "G90"]
-    if force:
-        lines.append("M211 S1")
+    # Bare relative move — exactly what the touchscreen sends. Never touch M211
+    # (#2579): the firmware keeps its soft endstops on by default and clamps the
+    # move at the travel limit.
+    lines = ["G91", f"G1 Z{gcode_distance:.2f} F600", "G90"]
 
     if not client.send_gcode("\n".join(lines)):
         raise HTTPException(500, "Failed to send bed-jog command")
@@ -3187,6 +3396,9 @@ async def xy_jog(
     if y:
         axes.append(f"Y{y:.2f}")
 
+    # Bare relative move — never touch M211 (#2579). The firmware keeps its soft
+    # endstops on by default and clamps the move at the travel limit; a printer
+    # left disabled by an older build is recovered with a power cycle.
     if not client.send_gcode("\n".join(["G91", f"G1 {' '.join(axes)} F6000", "G90"])):
         raise HTTPException(500, "Failed to send XY jog command")
 
@@ -3361,7 +3573,14 @@ async def get_printable_objects(
                 if downloaded and temp_path.exists():
                     with open(temp_path, "rb") as f:
                         data = f.read()
-                    objects, bbox_all = extract_printable_objects_from_3mf(data, include_positions=True)
+                    # Scope to the running plate: an all-plates 3MF lists every
+                    # plate's objects, and offering plate 1's while the printer
+                    # runs plate 2 makes every skip a misfire (#2522).
+                    objects, bbox_all = extract_printable_objects_from_3mf(
+                        data,
+                        plate_number=resolve_plate_id(client.state),
+                        include_positions=True,
+                    )
                     if objects:
                         client.state.printable_objects = objects
                         client.state.printable_objects_bbox_all = bbox_all
@@ -3721,8 +3940,12 @@ async def ams_load(
     - 254: external spool (single-external printers, or Ext-L on dual-nozzle H2D)
     - 255: Ext-R on dual-nozzle H2D
     """
-    if tray_id not in range(16) and tray_id not in (254, 255):
-        raise HTTPException(400, "tray_id must be 0..15 (AMS slot), 254 (external / Ext-L), or 255 (Ext-R)")
+    # 24-27 are the A2L AMS-Lite slots (normalised unit 6 = 6*4+slot); see
+    # a2l-am-unit-16. They are valid global tray ids alongside the regular 0-15.
+    if tray_id not in range(16) and tray_id not in range(24, 28) and tray_id not in (254, 255):
+        raise HTTPException(
+            400, "tray_id must be 0..15 (AMS slot), 24..27 (A2L AMS-Lite), 254 (external / Ext-L), or 255 (Ext-R)"
+        )
 
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()

+ 12 - 2
backend/app/api/routes/projects.py

@@ -263,6 +263,9 @@ async def list_projects(
                 target_count=project.target_count,
                 target_parts_count=project.target_parts_count,
                 budget=project.budget,
+                tags=project.tags,
+                due_date=project.due_date,
+                priority=project.priority,
                 created_at=project.created_at,
                 archive_count=archive_count,
                 total_items=total_items,
@@ -370,7 +373,11 @@ async def list_templates(
                 color=project.color,
                 status=project.status,
                 target_count=project.target_count,
+                target_parts_count=project.target_parts_count,
                 budget=project.budget,
+                tags=project.tags,
+                due_date=project.due_date,
+                priority=project.priority,
                 created_at=project.created_at,
                 archive_count=archive_count,
                 queue_count=0,
@@ -585,9 +592,12 @@ async def update_project(
         project.target_parts_count = data.target_parts_count
     if data.notes is not None:
         project.notes = data.notes
-    if data.tags is not None:
+    # Sent-but-null clears the field; omitted leaves it alone. Guarding on
+    # ``is not None`` would make an emptied tags field or a removed due date
+    # silently revert to the stored value (#2536).
+    if "tags" in data.model_fields_set:
         project.tags = data.tags
-    if data.due_date is not None:
+    if "due_date" in data.model_fields_set:
         project.due_date = data.due_date
     if data.priority is not None:
         if data.priority not in ["low", "normal", "high", "urgent"]:

+ 24 - 43
backend/app/api/routes/settings.py

@@ -35,32 +35,6 @@ _SENSITIVE_FIELDS_FOR_API_KEY = (
 )
 
 
-def _sqlalchemy_type_to_sqlite_type(type_repr: str) -> str:
-    """Map a SQLAlchemy column type's ``str()`` to a SQLite-native column type.
-
-    Used by ``create_backup_zip`` to reconstruct a portable SQLite database
-    file from PostgreSQL data. Falling through to TEXT for binary columns
-    corrupts non-UTF8 bytes — the BLOB branch is the #1333 regression guard
-    for OIDC icon BLOBs.
-
-    Extracted as a pure helper so it can be unit-tested without spinning up
-    the full FastAPI app + backup pipeline.
-    """
-    type_str = type_repr.upper()
-    if "INT" in type_str:
-        return "INTEGER"
-    if "FLOAT" in type_str or "REAL" in type_str or "NUMERIC" in type_str:
-        return "REAL"
-    if "BOOL" in type_str:
-        return "BOOLEAN"
-    if "BLOB" in type_str or "BYTEA" in type_str or "BINARY" in type_str:
-        # OIDC icon BLOB column (#1333) — without this branch the column
-        # was created as TEXT and non-UTF8 bytes were corrupted during the
-        # PG→SQLite-ZIP backup round trip.
-        return "BLOB"
-    return "TEXT"
-
-
 async def get_setting(db: AsyncSession, key: str) -> str | None:
     """Get a single setting value by key."""
     result = await db.execute(select(Settings).where(Settings.key == key))
@@ -131,12 +105,12 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
             "print_drying_enabled",
             "require_plate_clear",
             "queue_shortest_first",
-            "default_bed_levelling",
-            "default_flow_cali",
+            # default_bed_levelling / default_flow_cali / default_nozzle_offset_cali
+            # are tri-state strings (off/on/auto) — parsed via the raw-string else
+            # branch; the TriState validator coerces legacy "true"/"false" rows.
             "default_vibration_cali",
             "default_layer_inspect",
             "default_timelapse",
-            "default_nozzle_offset_cali",
             "ldap_enabled",
             "ldap_auto_provision",
             "local_login_enabled",
@@ -168,6 +142,7 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
             "pipeline_max_copies",
             "preheat_max_wait_seconds",
             "preheat_soak_seconds",
+            "queue_max_concurrent_uploads",
         ]:
             settings_dict[setting.key] = int(setting.value)
         elif setting.key == "default_printer_id":
@@ -582,25 +557,31 @@ async def create_backup_zip(output_path: Path | None = None) -> tuple[Path, str]
             import json
             import sqlite3
 
+            from sqlalchemy import create_engine as create_sync_engine
+
             from backend.app.core.database import Base, engine
 
             backup_db_path = temp_path / "bambuddy.db"
-            dst = sqlite3.connect(str(backup_db_path))
             metadata = Base.metadata
 
-            # Create tables in SQLite backup (simplified — just column names and types)
-            for table in metadata.sorted_tables:
-                cols = []
-                pk_cols = [col.name for col in table.columns if col.primary_key]
-                for col in table.columns:
-                    col_type = _sqlalchemy_type_to_sqlite_type(str(col.type))
-                    # Only inline PRIMARY KEY for single-column PKs
-                    pk = " PRIMARY KEY" if col.primary_key and len(pk_cols) == 1 else ""
-                    cols.append(f"{col.name} {col_type}{pk}")
-                # Add composite primary key constraint if needed
-                if len(pk_cols) > 1:
-                    cols.append(f"PRIMARY KEY ({', '.join(pk_cols)})")
-                dst.execute(f"CREATE TABLE IF NOT EXISTS {table.name} ({', '.join(cols)})")  # noqa: S608
+            # Build the portable SQLite schema with SQLAlchemy's own DDL rather
+            # than a hand-rolled CREATE TABLE. metadata.create_all() emits the
+            # exact schema a native SQLite install gets — NOT NULL, DEFAULT
+            # (server_default=func.now() → CURRENT_TIMESTAMP), foreign keys,
+            # unique constraints and indexes. The previous name+type-only
+            # rebuild dropped all of these, so a Postgres→SQLite restore left
+            # server_default columns (e.g. spoolbuddy_devices.created_at) with
+            # no DEFAULT — SQLAlchemy omits such columns on INSERT and the DB
+            # then wrote NULL, which 500'd on the next read (#2526). Using the
+            # real DDL also keeps the #1333 BLOB guard: LargeBinary still
+            # renders as BLOB, so OIDC icon bytes survive the round trip.
+            schema_engine = create_sync_engine(f"sqlite:///{backup_db_path}")
+            try:
+                metadata.create_all(schema_engine)
+            finally:
+                schema_engine.dispose()
+
+            dst = sqlite3.connect(str(backup_db_path))
 
             # Export data from Postgres to SQLite
             async with engine.connect() as conn:

+ 12 - 7
backend/app/api/routes/slice_jobs.py

@@ -18,22 +18,27 @@ router = APIRouter(prefix="/slice-jobs", tags=["slice-jobs"])
 @router.get("/{job_id}")
 async def get_slice_job(
     job_id: int,
-    # Job IDs are sequential integers and the body leaks source filenames
-    # plus the resulting library_file_id / archive_id. Gate on the library
-    # read permission family (own/all). NOTE: SliceJob is in-memory with no
-    # owner field, so we cannot per-row scope; callers with either OWN or
-    # ALL can poll any job_id. Adding owner_id to SliceJob is the proper
-    # follow-up (out of scope for the IDOR fix train).
-    _: tuple[User | None, bool] = Depends(
+    # Job IDs are sequential integers and the body leaks source filenames plus
+    # the resulting library_file_id / archive_id. Gate on the library read
+    # permission family (own/all) and then scope per-row: a READ_OWN caller may
+    # only poll jobs they started (SliceJob.owner_id).
+    auth: tuple[User | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
         )
     ),
 ):
+    user, can_read_all = auth
     job = slice_dispatch.get(job_id)
     if job is None:
         raise HTTPException(status_code=404, detail="Slice job not found or expired")
+    # Per-row scoping. Jobs started by API-key / auth-disabled callers have
+    # owner_id=None and are visible only to READ_ALL pollers (fail-closed,
+    # mirrors the library ownerless-row rule). 404 not 403 to avoid job-id
+    # enumeration.
+    if not can_read_all and (user is None or job.owner_id != user.id):
+        raise HTTPException(status_code=404, detail="Slice job not found or expired")
     body: dict = {
         "job_id": job.id,
         "status": job.status,

+ 63 - 8
backend/app/api/routes/slicer_presets.py

@@ -259,15 +259,23 @@ async def _fetch_orca_cloud_presets(
                     filament_colour = fc[0]
                 elif isinstance(fc, str):
                     filament_colour = fc
-            slots[slot].append(
-                UnifiedPreset(
-                    id=str(preset_id),
-                    name=str(name),
-                    source="orca_cloud",
-                    filament_type=filament_type,
-                    filament_colour=filament_colour,
-                )
+            preset = UnifiedPreset(
+                id=str(preset_id),
+                name=str(name),
+                source="orca_cloud",
+                filament_type=filament_type,
+                filament_colour=filament_colour,
             )
+            if slot in ("process", "filament"):
+                # The profile's own compatible-printer list, straight out of
+                # the content Orca already hands us (#2628). Without it the
+                # SliceModal falls back to reading the printer out of the
+                # profile NAME — and a profile whose name carries no model
+                # ("Overture PLA Matte @0.2") then reads as "can't tell",
+                # which the picker treats as usable and auto-picks for a
+                # printer the profile was never built for.
+                preset.compatible_printers = _content_compatible_printers(content)
+            slots[slot].append(preset)
         _orca_cloud_cache[cache_key] = (now, slots)
         return slots, "ok"
     finally:
@@ -297,6 +305,25 @@ async def _fetch_local_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset
     return slots
 
 
+def _content_compatible_printers(content: dict) -> list[str] | None:
+    """Pull ``compatible_printers`` out of an inline profile content dict.
+
+    Orca profiles carry it as a list of printer-preset names (the same shape
+    ``orca_profiles.py`` stores on import); a single-printer profile may store
+    a bare string. Returns ``None`` for missing / empty / malformed values so
+    the caller leaves the field unset and the SliceModal falls back to the
+    name-based matcher, rather than treating "no data" as "compatible with
+    nothing".
+    """
+    raw = content.get("compatible_printers")
+    if isinstance(raw, str):
+        raw = [raw]
+    if not isinstance(raw, list):
+        return None
+    names = [s.strip() for s in raw if isinstance(s, str) and s.strip()]
+    return names or None
+
+
 def _parse_compatible_printers(raw: str | None) -> list[str] | None:
     """``LocalPreset.compatible_printers`` stores a JSON array of printer-preset
     names. Return the parsed list, or ``None`` on missing / malformed data so
@@ -442,6 +469,16 @@ def _enrich_cloud_metadata(
     in ``local`` / ``orca_cloud`` / ``standard``. This is the only reason
     this function exists post-#1712 — without the enrich the Bambu Cloud
     tier can't score in ``pickFilamentForSlot``.
+
+    Compatibility merge (#2628): the same name bridge carries
+    ``compatible_printers`` onto any process / filament entry that lacks it.
+    Bambu Cloud never ships the list, so a profile whose NAME carries no
+    printer model reads as "compatibility unknown" — which the SliceModal
+    treats as usable and auto-picks for whatever printer is selected. When
+    the very same profile is also present as a local import or an Orca Cloud
+    profile, that copy states the truth; borrowing it turns the auto-pick
+    into a correctly-rejected mismatch. Only ever fills a gap: an entry that
+    carries its own list keeps it.
     """
     # Build a name → metadata lookup from the tiers that carry it (local,
     # orca_cloud, standard). Bambu cloud is intentionally skipped — it
@@ -464,6 +501,24 @@ def _enrich_cloud_metadata(
             if p.filament_colour is None and c is not None:
                 p.filament_colour = c
 
+    # Compatibility bridge (#2628). Runs over both slots that carry the
+    # list, and in both directions between the cloud tiers — whichever copy
+    # of a profile knows its printers teaches the ones that don't.
+    for slot in ("process", "filament"):
+        compat_by_name: dict[str, list[str]] = {}
+        for tier in (local, orca_cloud, cloud, standard):
+            for p in tier[slot]:
+                if p.compatible_printers and p.name not in compat_by_name:
+                    compat_by_name[p.name] = p.compatible_printers
+        if not compat_by_name:
+            continue
+        for tier in (orca_cloud, cloud):
+            for p in tier[slot]:
+                if not p.compatible_printers:
+                    borrowed = compat_by_name.get(p.name)
+                    if borrowed:
+                        p.compatible_printers = list(borrowed)
+
     return orca_cloud, cloud, local, standard
 
 

+ 19 - 11
backend/app/api/routes/smart_plugs.py

@@ -1,7 +1,7 @@
 """API routes for smart plug management."""
 
 import logging
-from datetime import datetime, timedelta, timezone
+from datetime import timedelta
 
 from fastapi import APIRouter, Body, Depends, HTTPException
 from pydantic import BaseModel
@@ -36,9 +36,11 @@ from backend.app.services.homeassistant import homeassistant_service
 from backend.app.services.mqtt_relay import mqtt_relay
 from backend.app.services.mqtt_smart_plug import subscribe_plug_to_mqtt
 from backend.app.services.notification_service import notification_service
+from backend.app.services.plug_energy_history import fill_derived_energy
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.rest_smart_plug import rest_smart_plug_service
 from backend.app.services.tasmota import tasmota_service
+from backend.app.utils.local_time import to_naive_utc, utcnow_naive
 
 logger = logging.getLogger(__name__)
 
@@ -581,10 +583,11 @@ async def control_smart_plug(
         plug.last_state = expected_state
         if expected_state == "ON":
             plug.auto_off_executed = False  # Reset flag when manually turning on
-        elif expected_state == "OFF" and plug.printer_id:
-            # Mark printer offline immediately for faster UI update
+        elif expected_state == "OFF" and plug.printer_id and plug.controls_printer_power:
+            # Mark printer offline immediately for faster UI update. Skipped for
+            # accessory plugs, which are linked to a printer but don't feed it (#2629).
             printer_manager.mark_printer_offline(plug.printer_id)
-    plug.last_checked = datetime.now(timezone.utc)
+    plug.last_checked = utcnow_naive()
     await db.commit()
 
     # Trigger associated scripts if this is a main (non-script) plug
@@ -671,7 +674,7 @@ async def get_plug_status(
             # Update last state in database
             if is_reachable and data.state:
                 plug.last_state = data.state
-                plug.last_checked = datetime.now(timezone.utc)
+                plug.last_checked = utcnow_naive()
                 await db.commit()
 
             energy_data = None
@@ -706,7 +709,7 @@ async def get_plug_status(
     # Update last state in database
     if status["reachable"]:
         plug.last_state = status["state"]
-        plug.last_checked = datetime.now(timezone.utc)
+        plug.last_checked = utcnow_naive()
         await db.commit()
 
     # Fetch energy data if device is reachable
@@ -714,6 +717,11 @@ async def get_plug_status(
     if status["reachable"]:
         energy = await service.get_energy(plug)
         if energy:
+            # Most plugs report only a lifetime counter — a Shelly has no notion
+            # of "today" at all, and Home Assistant never reports "yesterday".
+            # Fill those in from the hourly snapshots (#2539). Tasmota, which
+            # knows its own daily figures, is left alone.
+            energy = await fill_derived_energy(db, plug.id, energy)
             energy_data = SmartPlugEnergy(**energy)
 
             # Check power alerts
@@ -735,10 +743,10 @@ async def check_power_alerts(plug: SmartPlug, current_power: float | None, db: A
     # Cooldown: don't alert more than once per 5 minutes
     cooldown_minutes = 5
     if plug.power_alert_last_triggered:
-        last_triggered = plug.power_alert_last_triggered
-        if last_triggered.tzinfo is None:
-            last_triggered = last_triggered.replace(tzinfo=timezone.utc)
-        time_since_last = datetime.now(timezone.utc) - last_triggered
+        # Naive UTC on both sides: the column is naive, so a row loaded fresh from
+        # the DB comes back without an offset and subtracting an aware now() would
+        # raise TypeError.
+        time_since_last = utcnow_naive() - to_naive_utc(plug.power_alert_last_triggered)
         if time_since_last < timedelta(minutes=cooldown_minutes):
             return
 
@@ -759,7 +767,7 @@ async def check_power_alerts(plug: SmartPlug, current_power: float | None, db: A
         threshold = plug.power_alert_low
 
     if alert_triggered:
-        plug.power_alert_last_triggered = datetime.now(timezone.utc)
+        plug.power_alert_last_triggered = utcnow_naive()
         await db.commit()
 
         # Send notification

+ 47 - 12
backend/app/api/routes/support.py

@@ -1119,21 +1119,51 @@ async def _collect_support_info() -> dict:
 
 
 def _get_log_content(max_bytes: int = 10 * 1024 * 1024, sensitive_strings: dict[str, str] | None = None) -> bytes:
-    """Get log file content, limited to max_bytes from the end."""
+    """Get recent log content, limited to max_bytes from the end.
+
+    Spans the rotated files as well as the live one. ``bambuddy.log`` is capped
+    at 5 MB by the RotatingFileHandler, and the bundle used to ship only that
+    file — so on a large fleet with debug logging on, the window we ask a
+    reporter for was far shorter than anyone realised. The 19-printer farm in
+    #2555 emits ~100 lines/s of MQTT frame dumps, which fills 5 MB in under five
+    minutes: the bundle we received to diagnose a *queue* problem barely
+    contained one upload. The three rotated backups were sitting on disk unread.
+
+    Reads oldest -> newest so the result is chronological, then takes the last
+    ``max_bytes``, which is where the budget was all along.
+    """
     log_file = settings.log_dir / "bambuddy.log"
     if not log_file.exists():
         return b"Log file not found"
 
-    file_size = log_file.stat().st_size
-    if file_size <= max_bytes:
-        content = log_file.read_text(encoding="utf-8", errors="replace")
-    else:
-        # Read last max_bytes
-        with open(log_file, "rb") as f:
-            f.seek(file_size - max_bytes)
-            # Skip partial line at start
-            f.readline()
-            content = f.read().decode("utf-8", errors="replace")
+    # RotatingFileHandler names its backups .log.1 (newest) .. .log.N (oldest).
+    # Walk them in reverse so the concatenation reads forwards in time.
+    candidates: list[Path] = []
+    for index in range(settings.log_backup_count, 0, -1):
+        rotated = log_file.with_name(f"{log_file.name}.{index}")
+        if rotated.exists():
+            candidates.append(rotated)
+    candidates.append(log_file)
+
+    chunks: list[str] = []
+    remaining = max_bytes
+    # Fill from the newest backwards so the byte budget is spent on recent
+    # history, then flip back to chronological order for the reader.
+    for path in reversed(candidates):
+        if remaining <= 0:
+            break
+        try:
+            size = path.stat().st_size
+            with open(path, "rb") as f:
+                if size > remaining:
+                    f.seek(size - remaining)
+                    f.readline()  # discard the partial line the seek landed in
+                chunks.append(f.read().decode("utf-8", errors="replace"))
+            remaining -= min(size, remaining)
+        except OSError:
+            logger.debug("Failed to read log file %s for support bundle", path, exc_info=True)
+
+    content = "".join(reversed(chunks))
 
     # Sanitize sensitive data
     content = sanitize_log_content(content, sensitive_strings)
@@ -1278,7 +1308,12 @@ async def generate_support_bundle(
             zf.writestr(f"push-status/printer-{i + 1}.json", snapshot_json)
 
         # Add log file
-        log_content = _get_log_content(sensitive_strings=sensitive_strings)
+        # Off the event loop: this reads up to 10 MB and then runs one full regex
+        # pass per sensitive string over it. Now that the bundle spans the rotated
+        # files it can genuinely reach that ceiling, and the blocking cost scales
+        # with the number of printers (4 redaction patterns each) — i.e. it is
+        # worst on exactly the fleet size this change was written for.
+        log_content = await asyncio.to_thread(_get_log_content, sensitive_strings=sensitive_strings)
         zf.writestr("bambuddy.log", log_content)
 
     zip_buffer.seek(0)

+ 18 - 0
backend/app/api/routes/system.py

@@ -606,6 +606,24 @@ async def get_system_health(
     return await asyncio.to_thread(scan_logs, sensitive_strings=sensitive_strings)
 
 
+@router.get("/db-pool")
+async def get_db_pool(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
+):
+    """Live database connection-pool gauges for large-farm diagnostics (#2572).
+
+    Reports the resolved pool configuration plus current checked-out /
+    checked-in / overflow counts. Deliberately takes no DB session — reading
+    the pool's own counters must not itself consume a connection, so this stays
+    truthful even when the pool is saturated. On a healthy install ``checked_out``
+    sits well below ``config.pool_size + config.max_overflow``; sustained
+    saturation points at connections held across slow I/O (see #2572).
+    """
+    from backend.app.core.database import get_pool_status
+
+    return get_pool_status()
+
+
 @router.get("/appliance")
 async def get_appliance_defaults():
     """Expose appliance-set state for the SPA's bootstrap surface.

+ 162 - 19
backend/app/core/auth.py

@@ -3,6 +3,7 @@ from __future__ import annotations
 import logging
 import os
 import secrets
+import time
 from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
@@ -395,7 +396,7 @@ def require_energy_cost_update():
                 if username is None:
                     raise credentials_exception
                 jti: str | None = payload.get("jti")
-                if not jti or await is_jti_revoked(jti):
+                if not jti or await is_jti_revoked(jti, db):
                     raise credentials_exception
                 iat: int | float | None = payload.get("iat")
             except JWTError:
@@ -700,9 +701,44 @@ async def verify_camera_stream_token(token: str) -> bool:
 
         # Long-lived path. Imported lazily so the auth module stays importable
         # at startup before the long_lived_tokens model is registered.
+        from backend.app.services.long_lived_tokens import STREAM_SCOPES, verify_token as verify_long_lived
+
+        record = await verify_long_lived(db, token, scope=STREAM_SCOPES)
+        return record is not None
+
+
+async def verify_camwall_token(token: str) -> bool:
+    """Verify a Cam Wall token (#2531). Reusable — does not consume it.
+
+    Deliberately narrower than :func:`verify_camera_stream_token`: only the
+    long-lived ``camwall`` scope passes. The 60-minute ephemeral token belongs
+    to a logged-in browser, which already reaches the wall's metadata through
+    the ordinary printers API and has no need of this endpoint; and a
+    ``camera_stream`` token was handed out for video alone, so it must not
+    acquire the ability to enumerate printers by name just because a new
+    feature shipped.
+    """
+    async with async_session() as db:
+        from backend.app.services.long_lived_tokens import verify_token as verify_long_lived
+
+        record = await verify_long_lived(db, token, scope="camwall")
+        return record is not None
+
+
+async def verify_overlay_token(token: str) -> bool:
+    """Verify a streaming-overlay token (#2613). Reusable — does not consume it.
+
+    Like :func:`verify_camwall_token`, only the matching long-lived scope passes:
+    the overlay status feed names the file being printed, so it must not be
+    reachable by a ``camwall`` token (which is trusted to hide the part name) or
+    a bare ``camera_stream`` token (handed out for video alone). The 60-minute
+    ephemeral token belongs to a logged-in browser, which reaches the same data
+    through the ordinary printers API and has no need of this endpoint.
+    """
+    async with async_session() as db:
         from backend.app.services.long_lived_tokens import verify_token as verify_long_lived
 
-        record = await verify_long_lived(db, token, scope="camera_stream")
+        record = await verify_long_lived(db, token, scope="overlay")
         return record is not None
 
 
@@ -780,10 +816,18 @@ async def revoke_jti(jti: str, expires_at: datetime, username: str | None = None
             await db.rollback()  # jti already revoked — desired state, ignore
 
 
-async def is_jti_revoked(jti: str) -> bool:
-    """Return True if the given jti has been revoked."""
-    async with async_session() as db:
-        result = await db.execute(
+async def is_jti_revoked(jti: str, db: AsyncSession | None = None) -> bool:
+    """Return True if the given jti has been revoked.
+
+    Pass ``db`` to reuse the caller's session instead of opening a new one
+    (issue #2572): the permission dependencies already hold a session, and a
+    second checkout per request doubled pool pressure — a login burst then
+    exhausted the pool. With ``db`` omitted a short session is opened as before,
+    for callers that check the jti before they have a session open.
+    """
+
+    async def _query(session: AsyncSession) -> bool:
+        result = await session.execute(
             select(AuthEphemeralToken).where(
                 AuthEphemeralToken.token == jti,
                 AuthEphemeralToken.token_type == "revoked_jti",
@@ -791,6 +835,11 @@ async def is_jti_revoked(jti: str) -> bool:
         )
         return result.scalar_one_or_none() is not None
 
+    if db is not None:
+        return await _query(db)
+    async with async_session() as own_db:
+        return await _query(own_db)
+
 
 async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
     """Get a user by username (case-insensitive) with groups loaded for permission checks."""
@@ -844,6 +893,33 @@ async def authenticate_user_by_email(db: AsyncSession, email: str, password: str
     return user
 
 
+# Short-lived cache for the auth-enabled flag (issue #2572). The middleware
+# and every ownership/permission dependency probe this once (or more) per
+# request; on a large farm that DB round-trip is pure overhead because the
+# value changes only when an admin toggles auth.
+#
+# SECURITY: only a ``True`` (auth-enabled) result is EVER cached. A disabled /
+# unconfigured result is never cached, so a stale cache can only ever cause a
+# request to REQUIRE auth that a moment ago wasn't required — it can never skip
+# an auth check that is now required. Staleness fails CLOSED, never open (cf.
+# GHSA-6mf4-q26m-47pv). ``set_auth_enabled`` invalidates explicitly on any
+# toggle; the TTL is only a backstop for out-of-band changes (a direct DB edit,
+# or another worker process in a multi-worker deployment).
+_AUTH_ENABLED_CACHE_TTL_SECONDS = 30.0
+_auth_enabled_cached_value: bool = False
+_auth_enabled_cached_until: float = 0.0
+
+
+def invalidate_auth_enabled_cache() -> None:
+    """Drop the cached auth-enabled flag so the next probe re-reads the DB.
+
+    Call after any write that toggles the ``auth_enabled`` setting.
+    """
+    global _auth_enabled_cached_value, _auth_enabled_cached_until
+    _auth_enabled_cached_value = False
+    _auth_enabled_cached_until = 0.0
+
+
 async def is_auth_enabled(db: AsyncSession) -> bool:
     """Check if authentication is enabled.
 
@@ -860,12 +936,25 @@ async def is_auth_enabled(db: AsyncSession) -> bool:
     no exception. Any OTHER failure (connection error, fd exhaustion,
     schema mismatch, …) propagates so the caller can deny the request
     (503 / 500). Fail-closed is the only safe default for an auth probe.
+
+    Result is cached briefly to cut per-request DB load on large farms; only
+    the enabled=True result is cached, so a stale read can only fail closed.
+    See the module-level cache comment above.
     """
+    global _auth_enabled_cached_value, _auth_enabled_cached_until
+    if _auth_enabled_cached_value and time.monotonic() < _auth_enabled_cached_until:
+        return True
+
     result = await db.execute(select(Settings).where(Settings.key == "auth_enabled"))
     setting = result.scalar_one_or_none()
-    if setting is None:
-        return False
-    return setting.value.lower() == "true"
+    enabled = setting is not None and setting.value.lower() == "true"
+    if enabled:
+        _auth_enabled_cached_value = True
+        _auth_enabled_cached_until = time.monotonic() + _AUTH_ENABLED_CACHE_TTL_SECONDS
+    else:
+        # Never cache "disabled" — keep failing closed on any future staleness.
+        _auth_enabled_cached_value = False
+    return enabled
 
 
 async def _user_from_api_key(db: AsyncSession, api_key: APIKey) -> User | None:
@@ -955,13 +1044,16 @@ async def get_current_user_optional(
         if username is None:
             raise _unauthorized
         jti: str | None = payload.get("jti")
-        if not jti or await is_jti_revoked(jti):
-            raise _unauthorized  # I6: revoked token → 401, not anonymous
         iat: int | float | None = payload.get("iat")
     except JWTError:
         raise _unauthorized
 
+    if not jti:
+        raise _unauthorized  # I6: revoked token → 401, not anonymous
+
     async with async_session() as db:
+        if await is_jti_revoked(jti, db):
+            raise _unauthorized  # I6: revoked token → 401, not anonymous
         user = await get_user_by_username(db, username)
         if user is None or not user.is_active:
             raise _unauthorized
@@ -988,13 +1080,16 @@ async def get_current_user(
         if username is None:
             raise credentials_exception
         jti: str | None = payload.get("jti")
-        if not jti or await is_jti_revoked(jti):
-            raise credentials_exception
         iat: int | float | None = payload.get("iat")
     except JWTError:
         raise credentials_exception
 
+    if not jti:
+        raise credentials_exception
+
     async with async_session() as db:
+        if await is_jti_revoked(jti, db):
+            raise credentials_exception
         user = await get_user_by_username(db, username)
         if user is None:
             raise credentials_exception
@@ -1064,7 +1159,7 @@ async def require_auth_if_enabled(
                         headers={"WWW-Authenticate": "Bearer"},
                     )
                 jti: str | None = payload.get("jti")
-                if not jti or await is_jti_revoked(jti):
+                if not jti or await is_jti_revoked(jti, db):
                     raise HTTPException(
                         status_code=status.HTTP_401_UNAUTHORIZED,
                         detail="Could not validate credentials",
@@ -1173,7 +1268,7 @@ def require_admin_if_auth_enabled():
                         headers={"WWW-Authenticate": "Bearer"},
                     )
                 jti: str | None = payload.get("jti")
-                if not jti or await is_jti_revoked(jti):
+                if not jti or await is_jti_revoked(jti, db):
                     raise HTTPException(
                         status_code=status.HTTP_401_UNAUTHORIZED,
                         detail="Could not validate credentials",
@@ -1413,7 +1508,7 @@ def require_permission(*permissions: str | Permission):
                 if username is None:
                     raise credentials_exception
                 jti: str | None = payload.get("jti")
-                if not jti or await is_jti_revoked(jti):
+                if not jti or await is_jti_revoked(jti, db):
                     raise credentials_exception
                 iat: int | float | None = payload.get("iat")
             except JWTError:
@@ -1500,7 +1595,7 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
                             headers={"WWW-Authenticate": "Bearer"},
                         )
                     jti: str | None = payload.get("jti")
-                    if not jti or await is_jti_revoked(jti):
+                    if not jti or await is_jti_revoked(jti, db):
                         raise HTTPException(
                             status_code=status.HTTP_401_UNAUTHORIZED,
                             detail="Could not validate credentials",
@@ -1600,7 +1695,7 @@ def require_any_permission_if_auth_enabled(*permissions: str | Permission):
                             headers={"WWW-Authenticate": "Bearer"},
                         )
                     jti: str | None = payload.get("jti")
-                    if not jti or await is_jti_revoked(jti):
+                    if not jti or await is_jti_revoked(jti, db):
                         raise HTTPException(
                             status_code=status.HTTP_401_UNAUTHORIZED,
                             detail="Could not validate credentials",
@@ -1673,6 +1768,54 @@ def require_camera_stream_token_if_auth_enabled():
 RequireCameraStreamTokenIfAuthEnabled = Depends(require_camera_stream_token_if_auth_enabled())
 
 
+def require_camwall_token_if_auth_enabled():
+    """Dependency that validates a Cam Wall token query param when auth is enabled.
+
+    Used by the read-only Cam Wall feed (#2531), which a kiosk browser loads
+    with the token in the URL because it has no login session to carry a JWT.
+    """
+
+    async def checker(token: str | None = None) -> None:
+        async with async_session() as db:
+            if not await is_auth_enabled(db):
+                return  # Auth disabled, allow access
+        if not token or not await verify_camwall_token(token):
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail="Valid Cam Wall token required. Create one under Settings > API Keys with the 'Cam Wall' scope.",
+            )
+
+    return checker
+
+
+RequireCamWallTokenIfAuthEnabled = Depends(require_camwall_token_if_auth_enabled())
+
+
+def require_overlay_token_if_auth_enabled():
+    """Dependency that validates a streaming-overlay token query param when auth
+    is enabled.
+
+    Used by the read-only overlay status feed (#2613), which OBS (or any
+    embed with no login session) loads with the token in the URL because it
+    has no JWT to carry.
+    """
+
+    async def checker(token: str | None = None) -> None:
+        async with async_session() as db:
+            if not await is_auth_enabled(db):
+                return  # Auth disabled, allow access
+        if not token or not await verify_overlay_token(token):
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail="Valid overlay token required. Create one under Settings > API Keys with the 'Streaming Overlay' scope.",
+            )
+
+    return checker
+
+
+RequireOverlayTokenIfAuthEnabled = Depends(require_overlay_token_if_auth_enabled())
+
+
 def require_ownership_permission(
     all_permission: str | Permission,
     own_permission: str | Permission,
@@ -1754,7 +1897,7 @@ def require_ownership_permission(
                             headers={"WWW-Authenticate": "Bearer"},
                         )
                     jti: str | None = payload.get("jti")
-                    if not jti or await is_jti_revoked(jti):
+                    if not jti or await is_jti_revoked(jti, db):
                         raise HTTPException(
                             status_code=status.HTTP_401_UNAUTHORIZED,
                             detail="Could not validate credentials",

+ 25 - 1
backend/app/core/config.py

@@ -3,10 +3,11 @@ import os
 import re as _re
 from pathlib import Path
 
+from pydantic import Field
 from pydantic_settings import BaseSettings
 
 # Application version - single source of truth
-APP_VERSION = "0.2.4.9"
+APP_VERSION = "1.2.5"
 GITHUB_REPO = "maziggy/bambuddy"
 BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")
 
@@ -73,9 +74,32 @@ class Settings(BaseSettings):
     log_dir: Path = _log_dir
     database_url: str = _external_db_url or f"sqlite+aiosqlite:///{_db_path}"
 
+    # Database connection pool sizing. ``None`` = use the built-in, dialect-aware
+    # default (PostgreSQL: pool_size 20 + max_overflow 80; SQLite: 20 + 200).
+    # Large PostgreSQL printer farms can raise these via the DB_POOL_SIZE /
+    # DB_MAX_OVERFLOW / DB_POOL_TIMEOUT / DB_POOL_RECYCLE env vars (issue #2572).
+    # Make sure PostgreSQL ``max_connections`` comfortably exceeds
+    # (pool_size + max_overflow) x number of app worker processes.
+    db_pool_size: int | None = Field(default=None, gt=0)
+    db_max_overflow: int | None = Field(default=None, ge=0)
+    db_pool_timeout: int | None = Field(default=None, gt=0)
+    db_pool_recycle: int | None = Field(default=None, gt=0)
+    # LIFO checkout (PostgreSQL default on): reuse the most-recently-returned
+    # connection so a bursty farm keeps a small hot set busy and lets the excess
+    # overflow connections age out via pool_recycle instead of churning the whole
+    # pool. Override with DB_POOL_USE_LIFO. No effect on SQLite. (#2572)
+    db_pool_use_lifo: bool | None = Field(default=None)
+
     # Logging
     log_level: str = "INFO"  # Override with LOG_LEVEL env var or DEBUG=true
     log_to_file: bool = True  # Set to false to disable file logging
+    # Rotation for bambuddy.log. Read by main.py (which owns the handler) and by
+    # the support bundle (which harvests the backups as well as the live file);
+    # they must agree on the backup count or the bundle silently skips history.
+    # Bounded: RotatingFileHandler treats maxBytes=0 as "never rotate", so a
+    # zero/negative override would grow the log without limit.
+    log_max_bytes: int = Field(default=5 * 1024 * 1024, gt=0)
+    log_backup_count: int = Field(default=3, ge=0)
 
     # API
     api_prefix: str = "/api/v1"

+ 442 - 4
backend/app/core/database.py

@@ -23,12 +23,58 @@ def _set_sqlite_pragmas(dbapi_conn, connection_record):
     cursor.close()
 
 
-def _create_engine():
-    """Create the async engine with dialect-appropriate settings."""
+# Resolved connection-pool configuration, captured at engine creation so
+# /system/db-pool can report it without re-deriving the dialect defaults.
+_pool_config: dict = {}
+
+
+def _resolve_pool_kwargs() -> dict:
+    """Build the pool kwargs for ``create_async_engine`` (issue #2572).
+
+    Dialect-aware defaults, each overridable via env (``DB_POOL_SIZE`` etc.):
+      - PostgreSQL: pool_size 20 + max_overflow 80, ``pool_pre_ping`` (recover
+        server-dropped connections instead of erroring the request) and
+        ``pool_recycle`` 1800s. The old hard-coded 10 + 20 exhausted on large
+        farms while printer callbacks held connections.
+      - SQLite: pool_size 20 + max_overflow 200 (unchanged); no pre-ping /
+        recycle — the connection is a local file, not a server socket.
+    """
     if is_sqlite():
-        kwargs = {"pool_size": 20, "max_overflow": 200}
+        pool_size = settings.db_pool_size if settings.db_pool_size is not None else 20
+        max_overflow = settings.db_max_overflow if settings.db_max_overflow is not None else 200
+        kwargs = {"pool_size": pool_size, "max_overflow": max_overflow}
     else:
-        kwargs = {"pool_size": 10, "max_overflow": 20}
+        pool_size = settings.db_pool_size if settings.db_pool_size is not None else 20
+        max_overflow = settings.db_max_overflow if settings.db_max_overflow is not None else 80
+        kwargs = {
+            "pool_size": pool_size,
+            "max_overflow": max_overflow,
+            "pool_pre_ping": True,
+            "pool_recycle": settings.db_pool_recycle if settings.db_pool_recycle is not None else 1800,
+            # LIFO checkout keeps a bursty farm on a small hot connection set and
+            # lets overflow connections recycle out during quiet spells (#2572).
+            "pool_use_lifo": settings.db_pool_use_lifo if settings.db_pool_use_lifo is not None else True,
+        }
+    if settings.db_pool_timeout is not None:
+        kwargs["pool_timeout"] = settings.db_pool_timeout
+    return kwargs
+
+
+def _create_engine():
+    """Create the async engine with dialect-appropriate settings."""
+    kwargs = _resolve_pool_kwargs()
+
+    global _pool_config
+    _pool_config = {
+        "pool_size": kwargs["pool_size"],
+        "max_overflow": kwargs["max_overflow"],
+        # SQLAlchemy's own defaults when we don't pass the kwarg.
+        "pool_timeout": kwargs.get("pool_timeout", 30),
+        "pool_recycle": kwargs.get("pool_recycle", -1),
+        "pool_pre_ping": kwargs.get("pool_pre_ping", False),
+        "pool_use_lifo": kwargs.get("pool_use_lifo", False),
+    }
+
     eng = create_async_engine(
         settings.database_url,
         echo=settings.debug,
@@ -79,6 +125,36 @@ async_session = async_sessionmaker(
 )
 
 
+def get_pool_status() -> dict:
+    """Snapshot the DB connection pool for diagnostics (issue #2572).
+
+    Returns the resolved configuration plus live gauges (checked-out /
+    checked-in / overflow). Reads the pool's own counters — it does NOT
+    check out a connection, so it stays truthful even when the pool is
+    exhausted. Gauges a given pool implementation doesn't expose come back
+    as ``None`` rather than raising.
+    """
+    pool = engine.sync_engine.pool
+    gauges: dict = {}
+    for key, method_name in (
+        ("current_size", "size"),
+        ("checked_out", "checkedout"),
+        ("checked_in", "checkedin"),
+        ("overflow", "overflow"),
+    ):
+        method = getattr(pool, method_name, None)
+        try:
+            gauges[key] = method() if callable(method) else None
+        except Exception:
+            # A gauge should never take down the diagnostics endpoint.
+            gauges[key] = None
+    return {
+        "dialect": "sqlite" if is_sqlite() else "postgresql",
+        "config": dict(_pool_config),
+        **gauges,
+    }
+
+
 async def run_with_retry(fn, *, max_attempts: int = 3, label: str = ""):
     """Run an async DB operation with retry for SQLite 'database is locked' errors.
 
@@ -439,6 +515,187 @@ async def _migrate_normalize_printer_ids(conn) -> None:
             await conn.execute(text("UPDATE api_keys SET printer_ids = NULL WHERE printer_ids::text = '[]'"))
 
 
+async def _migrate_scope_force_color_overrides_to_plate(conn) -> None:
+    """Re-scope queue items that carry another plate's filament overrides (#2551).
+
+    Queueing several plates of one 3MF used to store the union of every selected
+    plate's overrides on each item, so a ``force_color_match`` plate printing one
+    colour sat at Waiting until a printer had the whole batch's palette loaded.
+    The write paths now narrow to the plate, but items queued before the fix would
+    stay stuck until the user deleted and re-added them by hand — with a waiting
+    reason that gives no hint as to why. Repair them here instead.
+
+    Only pending items are touched: a printing or finished item's overrides are a
+    record of what it dispatched with, not an instruction. An item whose plate we
+    cannot read keeps every override, per ``overrides_for_plate``. Idempotent —
+    an already-scoped item narrows to itself and is not rewritten.
+    """
+    import json
+    from pathlib import Path
+
+    from sqlalchemy import text
+
+    from backend.app.services.filament_requirements import overrides_for_plate
+
+    rows = (
+        await conn.execute(
+            text(
+                "SELECT q.id, q.plate_id, q.filament_overrides, "
+                "a.file_path AS archive_path, l.file_path AS library_path "
+                "FROM print_queue q "
+                "LEFT JOIN print_archives a ON a.id = q.archive_id "
+                "LEFT JOIN library_files l ON l.id = q.library_file_id "
+                "WHERE q.status = 'pending' "
+                "AND q.plate_id IS NOT NULL "
+                "AND q.filament_overrides IS NOT NULL"
+            )
+        )
+    ).fetchall()
+
+    repaired = 0
+    for row in rows:
+        try:
+            overrides = json.loads(row.filament_overrides)
+        except (json.JSONDecodeError, TypeError):
+            continue
+        if not isinstance(overrides, list) or not overrides:
+            continue
+
+        stored_path = row.archive_path or row.library_path
+        if not stored_path:
+            continue
+        path = Path(stored_path)
+        if not path.is_absolute():
+            path = settings.base_dir / stored_path
+
+        scoped = overrides_for_plate(overrides, path, row.plate_id)
+        if len(scoped) == len(overrides):
+            continue
+
+        async with conn.begin_nested():
+            await conn.execute(
+                text("UPDATE print_queue SET filament_overrides = :overrides WHERE id = :id"),
+                {"overrides": json.dumps(scoped) if scoped else None, "id": row.id},
+            )
+        repaired += 1
+
+    if repaired:
+        logger.info(
+            "Re-scoped the filament overrides of %d queued item(s) to the plate they print (#2551)",
+            repaired,
+        )
+
+
+async def _migrate_scope_run_filament_to_plate(conn) -> None:
+    """Repair completed print-log rows that stored a multi-plate 3MF's whole-file
+    filament (and cost) instead of the printed plate's (#2614).
+
+    When the AMS tracker measured nothing for a completed run, the per-run filament
+    fell back to ``PrintArchive.filament_used_grams`` — the sum over EVERY plate of
+    the source 3MF (right for the archive card / project rollup, wrong for one
+    printed plate). So each printed plate of a 22-plate file logged the full ~12 kg,
+    inflating lifetime / user / project / filament stats by the plate count. The
+    forward fix scopes new rows; this repairs the rows already written.
+
+    Only completed rows whose stored grams EXACTLY equal the archive's whole-file
+    value are touched — that is the mis-copy signature. Tracker-measured rows (a
+    rounded spool-delta sum) and partial-progress rows (scaled to progress) never
+    match, so they are never clobbered. Cost is scaled by the plate's share of the
+    whole so it stays consistent with the corrected grams. Runs AFTER the #2603
+    archive plate_id backfill so ``print_archives.plate_id`` is populated.
+
+    Gated to run **exactly once** via a settings flag. This is not merely for
+    idempotency: a genuine single-plate print carries a ``plate_id`` too (the UI
+    always sends one), and for it the plate estimate legitimately equals the
+    whole-file value — so those rows match the signature on every boot. Without
+    the one-shot gate we would re-parse every single-plate 3MF on the print log at
+    each startup, a cost that grows without bound with print history. One pass is
+    enough: the forward fix keeps all new rows correct.
+    """
+    from pathlib import Path
+
+    from sqlalchemy import text
+
+    from backend.app.utils.threemf_tools import extract_plate_metadata_from_3mf
+
+    flag = "_backfill_2614_plate_filament_done"
+
+    async with conn.begin_nested():
+        already = (
+            await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": flag})
+        ).scalar_one_or_none()
+        if already:
+            return
+
+        rows = (
+            await conn.execute(
+                text(
+                    "SELECT ple.id AS entry_id, ple.filament_used_grams AS grams, ple.cost AS cost, "
+                    "a.plate_id AS plate_id, a.filament_used_grams AS whole_grams, a.file_path AS file_path "
+                    "FROM print_log_entries ple "
+                    "JOIN print_archives a ON a.id = ple.archive_id "
+                    "WHERE ple.status = 'completed' "
+                    "AND a.plate_id IS NOT NULL "
+                    "AND a.file_path IS NOT NULL "
+                    "AND a.filament_used_grams IS NOT NULL "
+                    "AND ple.filament_used_grams IS NOT NULL "
+                    "AND ple.filament_used_grams = a.filament_used_grams"
+                )
+            )
+        ).fetchall()
+
+        corrected = 0
+        grams_removed = 0.0
+        for row in rows:
+            path = Path(row.file_path)
+            if not path.is_absolute():
+                path = settings.base_dir / row.file_path
+            if not path.exists():
+                continue
+            try:
+                plate_grams = extract_plate_metadata_from_3mf(path, row.plate_id).filament_used_grams
+            except Exception as exc:
+                logger.warning(
+                    "[#2614] could not read plate %s of %s for log entry %s: %s",
+                    row.plate_id,
+                    row.file_path,
+                    row.entry_id,
+                    exc,
+                )
+                continue
+            if not plate_grams or plate_grams <= 0:
+                continue
+            new_grams = round(plate_grams, 2)
+            if abs(new_grams - (row.grams or 0)) < 0.01:
+                continue  # nothing to change (e.g. a genuine single-plate file)
+            new_cost = row.cost
+            whole = row.whole_grams or 0
+            if row.cost and whole > 0:
+                new_cost = round(row.cost * (plate_grams / whole), 2)
+            await conn.execute(
+                text("UPDATE print_log_entries SET filament_used_grams = :g, cost = :c WHERE id = :id"),
+                {"g": new_grams, "c": new_cost, "id": row.entry_id},
+            )
+            corrected += 1
+            grams_removed += (row.grams or 0) - new_grams
+
+        if corrected:
+            logger.info(
+                "[#2614] Re-scoped %d completed print-log row(s) from whole-file to plate filament "
+                "(removed %.0f g of over-counted usage from statistics)",
+                corrected,
+                grams_removed,
+            )
+
+        # Mark done unconditionally (even when nothing matched) so this one-shot
+        # never re-scans the print log on subsequent boots. id/timestamps come
+        # from the table's own defaults; "key" is quoted as it's a keyword.
+        await conn.execute(
+            text('INSERT INTO settings ("key", value) VALUES (:k, :v)'),
+            {"k": flag, "v": "true"},
+        )
+
+
 async def _migrate_drop_library_print_name(conn) -> None:
     """Strip the embedded 3MF Title (``print_name``) from library file metadata (#1489).
 
@@ -705,6 +962,13 @@ async def run_migrations(conn):
     # Migration: Add f3d_path column to print_archives for Fusion 360 design files
     await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN f3d_path VARCHAR(500)")
 
+    # Migration: Add plate_id column to print_archives (#2603). The selected plate
+    # of a multi-plate 3MF is copied from the queue item at dispatch so Print
+    # History can show the actual plate instead of falling back to Plate 1.
+    # Nullable, no default — identical DDL on SQLite and Postgres. Backfilled from
+    # linked queue rows below.
+    await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN plate_id INTEGER")
+
     # Migration: Add on_maintenance_due column to notification_providers
     await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_maintenance_due BOOLEAN DEFAULT 0")
 
@@ -1178,6 +1442,63 @@ async def run_migrations(conn):
     else:
         await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzle_offset_cali BOOLEAN DEFAULT TRUE")
 
+    # Migration: convert bed_levelling / flow_cali / nozzle_offset_cali from
+    # boolean to tri-state strings (off/on/auto). BambuStudio exposes a third
+    # "auto" state for these (skip the calibration if it was done recently); our
+    # booleans could only send force-on / off. Legacy rows map true->'on',
+    # false->'off'; the new default is 'auto'. Idempotent on both dialects:
+    # SQLite leans on column affinity (a BOOLEAN-declared column stores text
+    # fine) and only rewrites rows still holding 0/1; PostgreSQL alters the
+    # column type only while it is still boolean, so re-runs and fresh
+    # create_all() schemas (already VARCHAR) are skipped. Column names are
+    # hardcoded constants, not user input.
+    _tristate_cols = ("bed_levelling", "flow_cali", "nozzle_offset_cali")
+    if is_sqlite():
+        for _col in _tristate_cols:
+            async with conn.begin_nested():
+                # B608 is a false positive here: _col is a hardcoded constant
+                # from _tristate_cols, never user input, and SQL identifiers
+                # can't be bound as parameters. Suppressed inline below.
+                await conn.execute(
+                    text(f"UPDATE print_queue SET {_col} = 'on' WHERE {_col} IN (1, '1', 'true', 'True')")  # nosec B608
+                )
+                await conn.execute(
+                    text(f"UPDATE print_queue SET {_col} = 'off' WHERE {_col} IN (0, '0', 'false', 'False')")  # nosec B608
+                )
+    else:
+        for _col in _tristate_cols:
+            result = await conn.execute(
+                text(
+                    "SELECT data_type FROM information_schema.columns "
+                    "WHERE table_name = 'print_queue' AND column_name = :col"
+                ),
+                {"col": _col},
+            )
+            row = result.fetchone()
+            if row and row[0] == "boolean":
+                await _safe_execute(conn, f"ALTER TABLE print_queue ALTER COLUMN {_col} DROP DEFAULT")
+                await _safe_execute(
+                    conn,
+                    f"ALTER TABLE print_queue ALTER COLUMN {_col} TYPE VARCHAR(8) "
+                    f"USING (CASE WHEN {_col} THEN 'on' ELSE 'off' END)",
+                )
+                await _safe_execute(conn, f"ALTER TABLE print_queue ALTER COLUMN {_col} SET DEFAULT 'auto'")
+
+    # Migration: normalise the workflow-default settings rows that back these
+    # options from legacy "true"/"false" to the tri-state vocabulary so the API
+    # returns real values (the AppSettings validator also coerces on read, but
+    # rewriting keeps the stored data honest). Only these three became tri-state.
+    for _skey in ("default_bed_levelling", "default_flow_cali", "default_nozzle_offset_cali"):
+        async with conn.begin_nested():
+            await conn.execute(
+                text("UPDATE settings SET value = 'on' WHERE key = :k AND lower(value) IN ('true', '1')"),
+                {"k": _skey},
+            )
+            await conn.execute(
+                text("UPDATE settings SET value = 'off' WHERE key = :k AND lower(value) IN ('false', '0')"),
+                {"k": _skey},
+            )
+
     # Migration: Per-item preheat / heat-soak override (#1468). preheat_override
     # is one of {inherit, on, off} — 'inherit' falls back to the global
     # preheat_enabled setting; 'on' / 'off' force the decision. The chamber
@@ -1257,6 +1578,22 @@ async def run_migrations(conn):
         except (OperationalError, ProgrammingError):
             pass  # Already applied
 
+    # Migration: Add dispatching_at claim column to print_queue (#2615). Nullable
+    # timestamp; the type differs by dialect (SQLite DATETIME vs Postgres
+    # TIMESTAMP) so an existing-DB upgrade doesn't hit "type datetime does not
+    # exist" on Postgres. On a fresh DB create_all() already built the column, so
+    # the ALTER is swallowed as "already exists".
+    #
+    # Placed AFTER the print_queue_new2 table-recreate above: that recreate
+    # (SQLite-only, and only on ancient DBs whose archive_id is still NOT NULL)
+    # rebuilds print_queue from an explicit column list that doesn't carry this
+    # column, so adding it earlier would let the recreate silently drop it. Adding
+    # it here means it survives that path.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN dispatching_at DATETIME")
+    else:
+        await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN dispatching_at TIMESTAMP")
+
     # Migration: Add HA energy sensor entity columns to smart_plugs
     await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN ha_power_entity VARCHAR(100)")
     await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN ha_energy_today_entity VARCHAR(100)")
@@ -2143,6 +2480,14 @@ async def run_migrations(conn):
     await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_energy_url VARCHAR(500)")
     await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_energy_multiplier REAL DEFAULT 1.0")
 
+    # Migration (#2539): a REST plug's lifetime energy counter, separate from its
+    # today counter. Devices differ in which they expose — a Shelly reports only
+    # a cumulative `aenergy.total`, a Tasmota behind a REST bridge reports both —
+    # and conflating the two made the cumulative value read as "today", so it
+    # never reset at midnight and "Total" stayed empty forever.
+    await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_energy_total_path VARCHAR(200)")
+    await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_energy_total_multiplier REAL DEFAULT 1.0")
+
     # Migration: Add batch_id column to print_queue for batch grouping
     try:
         async with conn.begin_nested():
@@ -3167,10 +3512,24 @@ async def run_migrations(conn):
     else:
         await _safe_execute(conn, "ALTER TABLE users ADD COLUMN IF NOT EXISTS orca_cloud_pending_at TIMESTAMP")
 
+    # Migration: record when Bambu rejects a stored cloud token. Until now the
+    # only state we kept was the token string itself, so a dead credential was
+    # indistinguishable from a live one and the UI reported "connected" forever
+    # while every cloud call 401'd. DATETIME is SQLite-only — Postgres uses
+    # TIMESTAMP, so the column is dialect-branched per project convention.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE users ADD COLUMN cloud_token_invalid_at DATETIME")
+    else:
+        await _safe_execute(conn, "ALTER TABLE users ADD COLUMN IF NOT EXISTS cloud_token_invalid_at TIMESTAMP")
+
     # Data migration: drop the embedded 3MF Title (`print_name`) from library
     # file metadata so the FileManager displays the filename, not the title (#1489).
     await _migrate_drop_library_print_name(conn)
 
+    # Data migration: queue items written before #2551 carry every selected plate's
+    # filament overrides, so a force-colour plate waits on colours it never prints.
+    await _migrate_scope_force_color_overrides_to_plate(conn)
+
     # Backfill NULL print_archives.created_at — older rows (and rows imported
     # via the SQLite ↔ Postgres cross-DB restore path) can land with NULL
     # because the column was originally created without a DEFAULT clause and
@@ -3330,6 +3689,85 @@ async def run_migrations(conn):
     else:
         await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN is_autologin BOOLEAN DEFAULT false")
 
+    # Migration: Add dispatch_attempts to print_queue (#2555). Counts the times
+    # the start-watchdog reverted the row from 'printing' back to 'pending' so a
+    # printer that never actually starts stops being retried forever. INTEGER
+    # DEFAULT 0 is spelled identically on SQLite and Postgres — no dialect branch.
+    # Verified on both dialects: ADD COLUMN ... DEFAULT 0 backfills existing rows,
+    # so no separate UPDATE is needed (and _safe_execute is DDL-only — see its
+    # docstring). The scheduler reads it as `(item.dispatch_attempts or 0) + 1`
+    # regardless, so even a NULL row could not disable the retry cap.
+    await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN dispatch_attempts INTEGER DEFAULT 0")
+
+    # Backfill: copy the selected plate from linked queue rows onto their archives
+    # (#2603). Recovers the plate for archives created before print_archives had a
+    # plate_id column, wherever the queue row still points at the archive and
+    # carries a plate. Runs here — after every print_queue column migration
+    # (plate_id, archive_id) — because it reads print_queue.plate_id, which is
+    # added far earlier in this function but must exist before this DML runs on a
+    # first-ever migration pass. Correlated-subquery form so the DML is identical
+    # on SQLite and Postgres; the WHERE plate_id IS NULL guard makes it idempotent
+    # and keeps it from clobbering values set on later runs.
+    async with conn.begin_nested():
+        # Only do any work (and, on SQLite, the FTS rebuild below) when there is
+        # actually a plate to recover — so this is a one-off cost on the upgrade
+        # boot, not an every-boot tax once every archive is backfilled.
+        has_work = (
+            await conn.execute(
+                text(
+                    "SELECT 1 FROM print_archives a "
+                    "JOIN print_queue q ON q.archive_id = a.id "
+                    "WHERE a.plate_id IS NULL AND q.plate_id IS NOT NULL "
+                    "LIMIT 1"
+                )
+            )
+        ).first() is not None
+        if has_work:
+            # SQLite: print_archives has an external-content FTS index (archive_fts,
+            # created above) whose AFTER UPDATE trigger issues an FTS 'delete' for
+            # the row. Archives created before that table existed were never indexed
+            # (its creation runs no rebuild), and updating an un-indexed row trips
+            # "database disk image is malformed". plate_id isn't even an FTS column,
+            # so the trigger's re-index is pointless here — but it still fires. Rebuild
+            # the index from the content table first so every row is present and the
+            # trigger's 'delete' is well-defined. Postgres has no such FTS table.
+            if is_sqlite():
+                await conn.execute(text("INSERT INTO archive_fts(archive_fts) VALUES('rebuild')"))
+            await conn.execute(
+                text(
+                    "UPDATE print_archives "
+                    "SET plate_id = ("
+                    "  SELECT pq.plate_id FROM print_queue pq "
+                    "  WHERE pq.archive_id = print_archives.id AND pq.plate_id IS NOT NULL "
+                    "  LIMIT 1"
+                    ") "
+                    "WHERE plate_id IS NULL "
+                    "AND EXISTS ("
+                    "  SELECT 1 FROM print_queue pq "
+                    "  WHERE pq.archive_id = print_archives.id AND pq.plate_id IS NOT NULL"
+                    ")"
+                )
+            )
+
+    # Migration: repair completed print-log rows that stored a multi-plate 3MF's
+    # whole-file filament instead of the printed plate's (#2614). Runs AFTER the
+    # #2603 archive plate_id backfill above so print_archives.plate_id is populated.
+    await _migrate_scope_run_filament_to_plate(conn)
+
+    # Migration: Add controls_printer_power to smart_plugs (#2629). Marks
+    # whether a plug actually feeds the printer's own power — only then may an
+    # auto-off mark the printer offline. Defaults to true so existing plugs
+    # keep the previous behaviour; accessory plugs (filter fan, lights) are
+    # opted out by the user. BOOLEAN literals differ per dialect (SQLite has
+    # no true/false keyword), so the default is dialect-branched.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN controls_printer_power BOOLEAN DEFAULT 1")
+    else:
+        await _safe_execute(
+            conn,
+            "ALTER TABLE smart_plugs ADD COLUMN IF NOT EXISTS controls_printer_power BOOLEAN DEFAULT true",
+        )
+
     # Migration: Disambiguate the four ``user_print_*`` notification template
     # names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``.
     await _migrate_rename_user_print_template_names(conn)

Разница между файлами не показана из-за своего большого размера
+ 549 - 257
backend/app/main.py


+ 8 - 0
backend/app/models/archive.py

@@ -56,6 +56,14 @@ class PrintArchive(Base):
     # print and keep the original row instead of cancel-then-create.
     subtask_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
 
+    # Which plate of a multi-plate 3MF this print was for (1-based), copied from
+    # the queue item at dispatch (#2603). A whole multi-plate 3MF is uploaded
+    # under one filename with no plate suffix, so the parser can't recover the
+    # selected plate and extra_data holds all-plates aggregate metadata; without
+    # this the history UI can't tell which plate was printed and falls back to
+    # Plate 1. NULL for archives with no specific selected plate.
+    plate_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
+
     # Extended metadata (JSON blob for flexibility)
     extra_data: Mapped[dict | None] = mapped_column(JSON)
 

+ 24 - 4
backend/app/models/print_queue.py

@@ -65,6 +65,14 @@ class PrintQueueItem(Base):
     # Auto-print G-code injection (#422)
     gcode_injection: Mapped[bool] = mapped_column(Boolean, default=False)
 
+    # How many times the start-watchdog has reverted this item from 'printing'
+    # back to 'pending' (#2555). A printer that accepts project_file but never
+    # starts (#1678) used to be retried forever: upload, wait out the watchdog,
+    # revert, upload again — burning a full 3MF transfer per cycle and, with
+    # the queue dispatching serially, dragging every other printer's start time
+    # out with it. The counter bounds that loop; see DISPATCH_MAX_ATTEMPTS.
+    dispatch_attempts: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
+
     # H2C dual-nozzle-rack slicer pick preservation (#1780). BambuStudio's
     # project_file MQTT command for rack-swap-capable models (O1C2 today)
     # carries per-filament physical nozzle position IDs in `nozzle_mapping`,
@@ -81,15 +89,17 @@ class PrintQueueItem(Base):
     # true, the scheduler deletes the source row/files after archiving a copy.
     cleanup_library_after_dispatch: Mapped[bool] = mapped_column(Boolean, default=False)
 
-    # Print options
-    bed_levelling: Mapped[bool] = mapped_column(Boolean, default=True)
-    flow_cali: Mapped[bool] = mapped_column(Boolean, default=False)
+    # Print options. bed_levelling / flow_cali / nozzle_offset_cali are tri-state
+    # strings (off/on/auto) matching BambuStudio; "auto" = skip if recently done.
+    # The remaining three stay boolean (BambuStudio exposes no auto for them).
+    bed_levelling: Mapped[str] = mapped_column(String(8), default="auto")
+    flow_cali: Mapped[str] = mapped_column(String(8), default="auto")
     vibration_cali: Mapped[bool] = mapped_column(Boolean, default=True)
     layer_inspect: Mapped[bool] = mapped_column(Boolean, default=False)
     timelapse: Mapped[bool] = mapped_column(Boolean, default=False)
     use_ams: Mapped[bool] = mapped_column(Boolean, default=True)
     # Nozzle offset calibration — dual-nozzle printers only, MQTT-gated (#1682)
-    nozzle_offset_cali: Mapped[bool] = mapped_column(Boolean, default=True)
+    nozzle_offset_cali: Mapped[str] = mapped_column(String(8), default="auto")
 
     # Preheat / heat-soak override (#1468). 'inherit' uses the global
     # preheat_enabled setting; 'on' / 'off' force the per-item decision. The
@@ -103,6 +113,16 @@ class PrintQueueItem(Base):
     # Status: pending, printing, completed, failed, skipped, cancelled
     status: Mapped[str] = mapped_column(String(20), default="pending")
 
+    # Dispatch claim (#2615). Set atomically by the scheduler the moment it
+    # begins dispatching this row and cleared when dispatch ends. The row stays
+    # `status='pending'` throughout the (slow) FTP upload, which left a window
+    # where a concurrent PATCH could reassign printer_id mid-upload and split the
+    # queue row from the archive/expected-print/physical command. While this is
+    # set the edit routes reject changes (409) and the scheduler won't re-select
+    # the row. Startup reconciliation clears any left over by a crash mid-dispatch
+    # (no coroutine survives a restart), so a stale claim never wedges an item.
+    dispatching_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+
     # Cleared by the per-printer "Resume after failure" action (#1818) so the
     # scheduler's `_check_previous_success` lookback skips this row. Without
     # this, a single `failed` or `aborted` print poisoned every later

+ 16 - 0
backend/app/models/smart_plug.py

@@ -67,14 +67,30 @@ class SmartPlug(Base):
     rest_power_path: Mapped[str | None] = mapped_column(String(200), nullable=True)  # JSON path for power (watts)
     rest_power_multiplier: Mapped[float] = mapped_column(Float, server_default="1.0")  # Unit conversion for power
     rest_energy_url: Mapped[str | None] = mapped_column(String(500), nullable=True)  # Separate URL for energy data
+    # Energy used *today*, resetting at midnight (kWh after the multiplier).
     rest_energy_path: Mapped[str | None] = mapped_column(String(200), nullable=True)  # JSON path for energy (kWh)
     rest_energy_multiplier: Mapped[float] = mapped_column(
         Float, server_default="1.0"
     )  # Unit conversion (e.g., 0.001 for Wh→kWh)
+    # Lifetime cumulative counter that never resets (#2539). A Shelly exposes only
+    # this one (`aenergy.total`, in Wh); a Tasmota behind a REST bridge exposes
+    # both. Kept separate from rest_energy_path because a cumulative counter read
+    # as "today" is silently wrong all day, and feeds Yesterday / Total / the
+    # hourly snapshots that the Statistics page's date filters run on.
+    rest_energy_total_path: Mapped[str | None] = mapped_column(String(200), nullable=True)
+    rest_energy_total_multiplier: Mapped[float] = mapped_column(Float, server_default="1.0")
 
     # Link to printer (multiple plugs/scripts can be linked to one printer)
     printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="SET NULL"), nullable=True)
 
+    # Whether this plug actually feeds the printer's own power (#2629). The
+    # printer link is also used for accessories that merely follow the print
+    # cycle — filter fans, chamber lights, enclosure heaters. Only a plug that
+    # really cuts printer power may mark the printer offline on auto-off;
+    # doing it for an accessory blanks the printer state and stalls the queue.
+    # Defaults to True so existing plugs keep their previous behaviour.
+    controls_printer_power: Mapped[bool] = mapped_column(Boolean, default=True, server_default="1")
+
     # Automation settings
     enabled: Mapped[bool] = mapped_column(Boolean, default=True)
     auto_on: Mapped[bool] = mapped_column(Boolean, default=True)  # Turn on at print start

+ 8 - 0
backend/app/models/user.py

@@ -44,6 +44,14 @@ class User(Base):
     cloud_email: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
     # "global" or "china"; NULL treated as "global" for legacy rows.
     cloud_region: Mapped[str | None] = mapped_column(String(10), nullable=True, default=None)
+    # Set when Bambu answers 401 to a call made with ``cloud_token`` — the token
+    # has expired or been revoked. NULL means "not known to be dead". The token
+    # itself is kept: clearing it would lose the email/region we show on the
+    # re-login form, and a token can only be replaced by signing in again anyway.
+    # Bambu's token is opaque and carries no expiry we can read, and Bambuddy
+    # does not persist the refresh token, so this flag is the *only* record that
+    # a stored credential has stopped working (#2562 follow-up).
+    cloud_token_invalid_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
 
     # Per-user Orca Cloud credentials. Unlike Bambu Cloud, Orca uses Supabase PKCE
     # with short-lived access tokens (1h) and rotating single-use refresh tokens,

+ 1 - 0
backend/app/schemas/archive.py

@@ -55,6 +55,7 @@ class ArchiveResponse(BaseModel):
     object_count: int | None = None
 
     print_name: str | None
+    plate_id: int | None = None  # Selected plate of a multi-plate 3MF (#2603)
     print_time_seconds: int | None  # Estimated time from slicer
     actual_time_seconds: int | None = None  # Computed from started_at/completed_at
     # Percentage: 100 = perfect, >100 = faster than estimated

+ 4 - 0
backend/app/schemas/cloud.py

@@ -38,6 +38,10 @@ class CloudAuthStatus(BaseModel):
     is_authenticated: bool
     email: str | None = None
     region: Region | None = None
+    # True when a token is stored but Bambu no longer accepts it. Both this and
+    # "never signed in" render the login form, but only this one warrants
+    # telling the user why it came back.
+    sign_in_expired: bool = False
 
 
 class CloudTokenRequest(BaseModel):

+ 4 - 0
backend/app/schemas/makerworld.py

@@ -109,3 +109,7 @@ class MakerWorldStatus(BaseModel):
 
     has_cloud_token: bool = Field(description="Whether the caller's account has a stored Bambu Cloud token")
     can_download: bool = Field(description="Shortcut: has_cloud_token AND it looks valid. Downloads require it.")
+    sign_in_expired: bool = Field(
+        default=False,
+        description="A token is stored but Bambu has rejected it — the user must sign in to Bambu Cloud again.",
+    )

+ 4 - 0
backend/app/schemas/notification.py

@@ -233,6 +233,10 @@ class PushoverConfig(BaseModel):
     user_key: str = Field(..., description="Your Pushover user key")
     app_token: str = Field(..., description="Your Pushover application token")
     priority: int = Field(default=0, ge=-2, le=2, description="Message priority (-2 to 2)")
+    # Emergency priority (2) only: how often to re-alert and when to stop.
+    # Pushover requires retry >= 30s and expire <= 10800s (3h).
+    retry: int = Field(default=60, ge=30, le=10800, description="Emergency re-alert interval in seconds (priority 2)")
+    expire: int = Field(default=3600, ge=30, le=10800, description="Emergency alert expiry in seconds (priority 2)")
 
 
 class TelegramConfig(BaseModel):

+ 27 - 35
backend/app/schemas/orca_cloud.py

@@ -1,50 +1,42 @@
-"""Schemas for Orca Cloud auth + profile sync endpoints."""
+"""Schemas for Orca Cloud device-pairing auth + profile sync endpoints."""
 
 from typing import Literal
 
 from pydantic import BaseModel, Field
 
-# The three OAuth providers Orca's sign-in surface offers. Supabase
-# accepts the bare lowercase provider name in the authorize query string.
-OrcaOAuthProvider = Literal["google", "apple", "github"]
 
+class OrcaDeviceStartResponse(BaseModel):
+    """Returned by ``POST /orca-cloud/device/start``. The frontend shows
+    ``user_code`` and a clickable/QR ``verification_uri_complete``; the user
+    approves in their Orca Cloud settings. The ``device_code`` itself is a
+    secret and stays server-side — it is deliberately NOT in this response."""
 
-class OrcaAuthStartRequest(BaseModel):
-    """Body for ``POST /orca-cloud/auth/start``. Provider defaults to
-    ``google`` so existing clients that send an empty body keep working."""
+    user_code: str = Field(..., description="Short code the user confirms on the approval page")
+    verification_uri: str = Field(..., description="Approval page URL")
+    verification_uri_complete: str = Field(..., description="Approval page URL with the code pre-filled")
+    interval: int = Field(..., description="Seconds the frontend should wait between poll calls")
+    expires_in: int = Field(..., description="Seconds until this pairing attempt expires")
 
-    provider: OrcaOAuthProvider = Field(default="google", description="OAuth provider to use for sign-in")
 
+# Poll outcomes surfaced to the frontend. ``authorization_pending`` /
+# ``slow_down`` mean keep polling; ``access_denied`` / ``expired_token`` are
+# terminal (restart the flow); ``complete`` means paired.
+OrcaDevicePollStatus = Literal[
+    "authorization_pending",
+    "slow_down",
+    "access_denied",
+    "expired_token",
+    "complete",
+]
 
-class OrcaAuthStartResponse(BaseModel):
-    """Returned by ``POST /orca-cloud/auth/start``. The frontend opens
-    ``auth_url`` in a new tab. After the user signs in to Orca, they copy the
-    redirected URL from their address bar and POST it to
-    ``/orca-cloud/auth/finish`` to complete the handshake."""
 
-    auth_url: str = Field(..., description="URL to open for Orca Cloud sign-in")
+class OrcaDevicePollResponse(BaseModel):
+    """Returned by ``POST /orca-cloud/device/poll`` — one poll attempt."""
 
-
-class OrcaAuthFinishRequest(BaseModel):
-    """Submitted by the frontend after the user pastes the callback URL from
-    their browser. The URL contains a Supabase ``code`` (and our ``state``)
-    that we exchange for tokens."""
-
-    callback_url: str = Field(..., description="The full URL the browser was redirected to after sign-in")
-
-
-class OrcaAuthPasswordRequest(BaseModel):
-    """Body for ``POST /orca-cloud/auth/password``. Whether this succeeds
-    depends on Orca's Supabase project — their desktop client refuses
-    password payloads, but the web sign-in offers email+password as one
-    option. We forward the credentials and surface the server's response.
-    ``email`` is plain ``str`` rather than Pydantic's ``EmailStr`` to avoid
-    pulling in the optional ``email-validator`` dependency — Supabase will
-    reject malformed addresses with a clear error itself, and the existing
-    Bambu Cloud login schema uses the same approach."""
-
-    email: str = Field(..., min_length=1)
-    password: str = Field(..., min_length=1)
+    status: OrcaDevicePollStatus
+    connected: bool = False
+    email: str | None = None
+    user_id: str | None = None
 
 
 class OrcaAuthStatusResponse(BaseModel):

+ 45 - 17
backend/app/schemas/print_queue.py

@@ -1,7 +1,7 @@
 from datetime import datetime
 from typing import Annotated, Literal
 
-from pydantic import BaseModel, Field, PlainSerializer, model_validator
+from pydantic import BaseModel, BeforeValidator, Field, PlainSerializer, model_validator
 
 
 # Custom serializer to ensure UTC datetimes have Z suffix
@@ -15,6 +15,33 @@ def serialize_utc_datetime(dt: datetime | None) -> str | None:
 UTCDatetime = Annotated[datetime | None, PlainSerializer(serialize_utc_datetime)]
 
 
+def _coerce_tristate(v: object) -> object:
+    """Map legacy on/off booleans onto the tri-state calibration options.
+
+    bed_levelling / flow_cali / nozzle_offset_cali were plain booleans before we
+    added BambuStudio's third "auto" state (skip if recently done). Rows and API
+    payloads created under the old scheme carry bool / 0-1 int / "true"/"false";
+    coerce them so old clients and un-migrated rows still validate. getValueInt
+    parity: off=0, on=1, auto=2.
+    """
+    if isinstance(v, bool):
+        return "on" if v else "off"
+    if isinstance(v, int):
+        return {0: "off", 1: "on", 2: "auto"}.get(v, "auto")
+    if isinstance(v, str):
+        low = v.strip().lower()
+        if low in ("true", "1"):
+            return "on"
+        if low in ("false", "0"):
+            return "off"
+    return v
+
+
+# Tri-state calibration option: "auto" (printer decides / skip if recent),
+# "on" (force every print), "off" (never). Mirrors BambuStudio's ops_auto.
+TriState = Annotated[Literal["off", "on", "auto"], BeforeValidator(_coerce_tristate)]
+
+
 class PrintQueueItemCreate(BaseModel):
     printer_id: int | None = None  # None = unassigned, user assigns later
     target_model: str | None = None  # Target printer model (mutually exclusive with printer_id)
@@ -39,17 +66,18 @@ class PrintQueueItemCreate(BaseModel):
     ams_mapping: list[int] | None = None
     # Plate ID for multi-plate 3MF files (1-indexed, None = auto-detect/plate 1)
     plate_id: int | None = None
-    # Print options
-    bed_levelling: bool = True
-    flow_cali: bool = False
+    # Print options. bed_levelling / flow_cali / nozzle_offset_cali are tri-state
+    # (off/on/auto), defaulting to "auto" to match BambuStudio. vibration_cali /
+    # layer_inspect / timelapse stay on/off (BambuStudio exposes no auto for them).
+    bed_levelling: TriState = "auto"
+    flow_cali: TriState = "auto"
     vibration_cali: bool = True
     layer_inspect: bool = False
     timelapse: bool = False
     use_ams: bool = True
-    # Nozzle offset calibration — dual-nozzle printers only (#1682). Default True
-    # matches BambuStudio's default; the MQTT layer ignores the flag on
-    # single-nozzle printers so the wire value stays "skip" there.
-    nozzle_offset_cali: bool = True
+    # Nozzle offset calibration — dual-nozzle printers only (#1682). The MQTT
+    # layer ignores the value on single-nozzle printers so the wire stays "skip".
+    nozzle_offset_cali: TriState = "auto"
     # Preheat / heat-soak per-item override (#1468). 'inherit' uses the global
     # preheat_enabled setting; 'on' / 'off' force the decision. The chamber
     # target falls through: this override → max(filament-map[loaded tray]) → 0.
@@ -83,13 +111,13 @@ class PrintQueueItemUpdate(BaseModel):
     ams_mapping: list[int] | None = None
     plate_id: int | None = None
     # Print options
-    bed_levelling: bool | None = None
-    flow_cali: bool | None = None
+    bed_levelling: TriState | None = None
+    flow_cali: TriState | None = None
     vibration_cali: bool | None = None
     layer_inspect: bool | None = None
     timelapse: bool | None = None
     use_ams: bool | None = None
-    nozzle_offset_cali: bool | None = None
+    nozzle_offset_cali: TriState | None = None
     preheat_override: Literal["inherit", "on", "off"] | None = None
     preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=60)
     # Auto-print G-code injection
@@ -126,13 +154,13 @@ class PrintQueueItemResponse(BaseModel):
     ams_mapping: list[int] | None = None
     plate_id: int | None = None  # Plate ID for multi-plate 3MF files
     # Print options
-    bed_levelling: bool = True
-    flow_cali: bool = False
+    bed_levelling: TriState = "auto"
+    flow_cali: TriState = "auto"
     vibration_cali: bool = True
     layer_inspect: bool = False
     timelapse: bool = False
     use_ams: bool = True
-    nozzle_offset_cali: bool = True
+    nozzle_offset_cali: TriState = "auto"
     preheat_override: Literal["inherit", "on", "off"] = "inherit"
     preheat_chamber_target_override: int | None = None
     status: Literal["pending", "printing", "completed", "failed", "skipped", "cancelled"]
@@ -235,13 +263,13 @@ class PrintQueueBulkUpdate(BaseModel):
     auto_off_after: bool | None = None
     manual_start: bool | None = None
     # Print options
-    bed_levelling: bool | None = None
-    flow_cali: bool | None = None
+    bed_levelling: TriState | None = None
+    flow_cali: TriState | None = None
     vibration_cali: bool | None = None
     layer_inspect: bool | None = None
     timelapse: bool | None = None
     use_ams: bool | None = None
-    nozzle_offset_cali: bool | None = None
+    nozzle_offset_cali: TriState | None = None
     preheat_override: Literal["inherit", "on", "off"] | None = None
     preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=60)
     # Auto-print G-code injection

+ 19 - 0
backend/app/schemas/printer.py

@@ -181,6 +181,10 @@ class AMSTray(BaseModel):
     drying_temp: int | None = None  # RFID-recommended drying temp
     drying_time: int | None = None  # RFID-recommended drying time (hours)
     state: int | None = None  # AMS tray state: 9=empty, 10=spool present not loaded, 11=loaded
+    # Firmware's authoritative "spool physically present" bit (from tray_exist_bits).
+    # True for a non-RFID spool the firmware can't identify — the UI shows "?" rather
+    # than "Empty" (#2527). None when the bitmask was unavailable (→ state-based fallback).
+    exists: bool | None = None
 
 
 class AMSUnit(BaseModel):
@@ -328,6 +332,17 @@ class PrinterStatus(BaseModel):
     fila_switch: FilaSwitchResponse | None = None
     # Currently loaded tray (global ID): 254 = external spool, 255 = no filament
     tray_now: int = 255
+    # Runout / filament-replacement guidance (#2587). Populated only while the
+    # print is PAUSED. Both are globalised tray IDs (ams_id*4+slot, or 128-135 for
+    # AMS-HT, or 254 for external) so the frontend can highlight them with the same
+    # logic it uses for tray_now:
+    #   expected_tray = the slot the firmware now expects filament in (from tray_tar).
+    #                   None when idle, not paused, or the slot can't be resolved
+    #                   (multi-AMS ambiguity) — the UI then says "check the printer".
+    #   previous_tray = the slot loaded before the pause, i.e. the one that ran out
+    #                   (from tray_pre). None when unknown.
+    expected_tray: int | None = None
+    previous_tray: int | None = None
     # AMS status for filament change tracking
     # Main status: 0=idle, 1=filament_change, 2=rfid_identifying, 3=assist, 4=calibration
     ams_status_main: int = 0
@@ -361,6 +376,10 @@ class PrinterStatus(BaseModel):
     # AMS "Print While Drying" — drying mid-print. Verified per Bambu wiki release notes;
     # see _DRY_WHILE_PRINTING_MIN_FIRMWARE in printer_manager.py for the matrix.
     supports_drying_while_printing: bool = False
+    # The AMS can dry, but only from the printer's own screen (P1 series, #2533).
+    # supports_drying is False on these; the UI keeps the control visible but disabled
+    # and says why, rather than dropping it without explanation.
+    drying_screen_only: bool = False
     # Active chamber heater (responds to M141). True only for H2C/H2D/H2DPro/H2S/X2D.
     supports_chamber_heater: bool = False
     # Linked archive for the active print (resolved via subtask_id). Frontend uses

+ 7 - 0
backend/app/schemas/project.py

@@ -151,6 +151,13 @@ class ProjectListResponse(BaseModel):
     target_count: int | None
     target_parts_count: int | None = None
     budget: float | None = None
+    # The edit dialog is shared with the project detail page and seeds its fields
+    # from whichever project object it is handed, so the list payload has to carry
+    # everything the dialog edits — otherwise a save from the list view submits a
+    # blank tags field and a default priority over the stored values (#2536).
+    tags: str | None = None
+    due_date: datetime | None = None
+    priority: str = "normal"
     created_at: datetime
     # Quick stats
     archive_count: int = 0  # Number of print jobs

+ 22 - 8
backend/app/schemas/settings.py

@@ -2,6 +2,8 @@ import json
 
 from pydantic import BaseModel, Field, field_validator
 
+from backend.app.schemas.print_queue import TriState
+
 
 class AppSettings(BaseModel):
     """Application settings schema."""
@@ -294,9 +296,10 @@ class AppSettings(BaseModel):
         description="Enable user email notifications for print job events (requires Advanced Authentication)",
     )
 
-    # Default print options
-    default_bed_levelling: bool = Field(default=True, description="Default bed levelling option for new prints")
-    default_flow_cali: bool = Field(default=False, description="Default flow calibration option for new prints")
+    # Default print options. bed_levelling / flow_cali / nozzle_offset_cali are
+    # tri-state (off/on/auto), defaulting to "auto" per BambuStudio.
+    default_bed_levelling: TriState = Field(default="auto", description="Default bed levelling option for new prints")
+    default_flow_cali: TriState = Field(default="auto", description="Default flow calibration option for new prints")
     default_vibration_cali: bool = Field(
         default=True, description="Default vibration calibration option for new prints"
     )
@@ -304,8 +307,8 @@ class AppSettings(BaseModel):
         default=False, description="Default first layer inspection option for new prints"
     )
     default_timelapse: bool = Field(default=False, description="Default timelapse option for new prints")
-    default_nozzle_offset_cali: bool = Field(
-        default=True,
+    default_nozzle_offset_cali: TriState = Field(
+        default="auto",
         description="Default nozzle offset calibration option for new prints (dual-nozzle printers only)",
     )
 
@@ -326,6 +329,16 @@ class AppSettings(BaseModel):
         default=False,
         description="Shortest Job First — scheduler prioritizes shorter print jobs over longer ones",
     )
+    queue_max_concurrent_uploads: int = Field(
+        default=4,
+        ge=1,
+        le=16,
+        description=(
+            "How many printers the queue may upload to at the same time. Printers are independent "
+            "machines, so raising this starts a multi-printer batch proportionally sooner; each "
+            "concurrent upload costs one connection and one thread on the Bambuddy host."
+        ),
+    )
 
     # Preheat / heat-soak before queued prints (#1468). The scheduler stage runs
     # BEFORE FTP upload. Three hardware tiers behave differently:
@@ -543,16 +556,17 @@ class AppSettingsUpdate(BaseModel):
     low_stock_threshold: float | None = Field(default=None, ge=0.1, le=99.9)
     session_max_hours: int | None = Field(default=None, ge=1, le=720)
     user_notifications_enabled: bool | None = None
-    default_bed_levelling: bool | None = None
-    default_flow_cali: bool | None = None
+    default_bed_levelling: TriState | None = None
+    default_flow_cali: TriState | None = None
     default_vibration_cali: bool | None = None
     default_layer_inspect: bool | None = None
     default_timelapse: bool | None = None
-    default_nozzle_offset_cali: bool | None = None
+    default_nozzle_offset_cali: TriState | None = None
     stagger_group_size: int | None = Field(default=None, ge=1, le=50)
     stagger_interval_minutes: int | None = Field(default=None, ge=1, le=60)
     require_plate_clear: bool | None = None
     queue_shortest_first: bool | None = None
+    queue_max_concurrent_uploads: int | None = Field(default=None, ge=1, le=16)
     preheat_enabled: bool | None = None
     preheat_filament_targets: str | None = None
     preheat_max_wait_seconds: int | None = Field(default=None, ge=60, le=3600)

+ 15 - 0
backend/app/schemas/slicer.py

@@ -82,6 +82,21 @@ class SliceRequest(BaseModel):
         default=False,
         description="If true, request a 3MF response with embedded G-code instead of raw G-code.",
     )
+    use_embedded_settings: bool = Field(
+        default=False,
+        description=(
+            "3MF only. Slice using the file's embedded "
+            "``Metadata/project_settings.config`` (the designer's own tweaks — wall "
+            "count, infill, etc.) instead of the picked printer/process/filament "
+            "triplet. This is the 'slice as designed' path: no ``--load-settings`` "
+            "override, so a MakerWorld author's settings survive. Ignored for STL / "
+            "plain-model 3MF (no embedded profile to honour). The preset refs are "
+            "still required by the validator but go unused on this path. Only makes "
+            "sense when the picked printer matches the design's target model — the "
+            "UI gates the toggle on that; there is no cross-printer re-targeting here "
+            "(that is exactly what the profile path is for)."
+        ),
+    )
     bed_type: str | None = Field(
         default=None,
         max_length=64,

+ 12 - 0
backend/app/schemas/smart_plug.py

@@ -58,10 +58,18 @@ class SmartPlugBase(BaseModel):
     rest_power_path: str | None = Field(default=None, max_length=200)
     rest_power_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)
     rest_energy_url: str | None = Field(default=None, max_length=500)
+    # Today's usage, resetting at midnight.
     rest_energy_path: str | None = Field(default=None, max_length=200)
     rest_energy_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)
+    # Lifetime counter that never resets (#2539) — a Shelly's `aenergy.total`.
+    rest_energy_total_path: str | None = Field(default=None, max_length=200)
+    rest_energy_total_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)
 
     printer_id: int | None = None
+    # #2629: only a plug that really feeds the printer may mark it offline when
+    # it switches off. Accessory plugs (filter fan, lights) are linked to a
+    # printer purely to follow the print cycle.
+    controls_printer_power: bool = True
     enabled: bool = True
     auto_on: bool = True
     auto_off: bool = True
@@ -153,7 +161,11 @@ class SmartPlugUpdate(BaseModel):
     rest_energy_url: str | None = None
     rest_energy_path: str | None = None
     rest_energy_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
+    rest_energy_total_path: str | None = None
+    rest_energy_total_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
     printer_id: int | None = None
+    # #2629: see SmartPlugBase.controls_printer_power.
+    controls_printer_power: bool | None = None
     enabled: bool | None = None
     auto_on: bool | None = None
     auto_off: bool | None = None

+ 47 - 29
backend/app/services/archive.py

@@ -69,13 +69,37 @@ def resolve_display_stem(filename: str) -> str:
     return Path(name).stem
 
 
+def _read_plate_index(plate) -> int | None:
+    """Return the 1-based index of a ``slice_info.config`` ``<plate>`` element, or None.
+
+    Bambu Studio and OrcaSlicer record it as a ``<metadata key="index"
+    value="N"/>`` child — there is no ``plate_idx`` attribute on ``<plate>``
+    itself, so an XPath predicate on one never matches (#2522).
+    """
+    for meta in plate.findall("metadata"):
+        if meta.get("key") == "index":
+            value = meta.get("value")
+            if not value:
+                return None
+            try:
+                return int(value)
+            except ValueError:
+                return None
+    return None
+
+
 def peek_plate_index_in_3mf(file_path: Path) -> int | None:
-    """Return the plate index recorded inside a Bambu 3MF, or None.
+    """Return the plate index a single-plate Bambu 3MF represents, or None.
 
     Reads only ``Metadata/slice_info.config`` to keep this cheap — used by
     the print-start callback to verify that the 3MF we just downloaded over
     FTP actually matches the plate the printer is running (#1204). The full
     ThreeMFParser does much more work and runs later inside ArchiveService.
+
+    An all-plates export carries every plate, so "which plate is this file"
+    has no answer; returning None there keeps the #1204 guard from reading
+    plate 1 out of such a file, declaring a mismatch against the plate that
+    is really running, and discarding a perfectly good 3MF (#2522).
     """
     try:
         with zipfile.ZipFile(file_path, "r") as zf:
@@ -83,20 +107,12 @@ def peek_plate_index_in_3mf(file_path: Path) -> int | None:
                 return None
             content = zf.read("Metadata/slice_info.config").decode()
             root = ET.fromstring(content)
-            plate = root.find(".//plate")
-            if plate is None:
+            plates = root.findall(".//plate")
+            if len(plates) != 1:
                 return None
-            for meta in plate.findall("metadata"):
-                if meta.get("key") == "index":
-                    value = meta.get("value")
-                    if value:
-                        try:
-                            return int(value)
-                        except ValueError:
-                            return None
+            return _read_plate_index(plates[0])
     except Exception:
         return None
-    return None
 
 
 _PLATE_SUFFIX_RE = re.compile(r"^(.*?)(\s*-\s*Plate\s+|_plate_)(\d+)$", re.IGNORECASE)
@@ -615,26 +631,26 @@ def extract_printable_objects_from_3mf(
             content = zf.read("Metadata/slice_info.config").decode()
             root = ET.fromstring(content)
 
-            # Find the correct plate
-            if plate_number:
-                plate = root.find(f".//plate[@plate_idx='{plate_number}']")
-                if plate is None:
-                    plate = root.find(".//plate")
-            else:
-                plate = root.find(".//plate")
+            plates = root.findall(".//plate")
+            if not plates:
+                return printable_objects
 
+            # Pick the plate that is actually printing. An all-plates export
+            # lists every plate, so without this we offered the objects (and
+            # the marker positions) of plate 1 whatever the printer was
+            # running (#2522). Falling back to the first plate keeps the
+            # single-plate export — the common case — working when the caller
+            # has no plate to give us.
+            plate = None
+            if plate_number is not None:
+                plate = next((p for p in plates if _read_plate_index(p) == plate_number), None)
             if plate is None:
-                return printable_objects
+                plate = plates[0]
 
-            # Get actual plate index from metadata (sliced files only have one plate)
-            plate_idx = plate_number or 1
-            for meta in plate.findall("metadata"):
-                if meta.get("key") == "index":
-                    try:
-                        plate_idx = int(meta.get("value", "1"))
-                    except ValueError:
-                        pass  # Use default plate_idx if value is non-numeric
-                    break
+            # Derive plate_idx from the plate we settled on, never from the
+            # requested one: on a fallback they differ, and plate_idx also
+            # selects the plate_N.json the positions come from.
+            plate_idx = _read_plate_index(plate) or 1
 
             # Load position data from plate_N.json if we need positions
             # Build a lookup by name - use list to handle duplicate names
@@ -1126,6 +1142,7 @@ class ArchiveService:
         project_id: int | None = None,
         subtask_id: str | None = None,
         prefer_filename_for_name: bool = False,
+        plate_id: int | None = None,
     ) -> PrintArchive | None:
         """Archive a 3MF file with metadata.
 
@@ -1298,6 +1315,7 @@ class ArchiveService:
             created_by_id=created_by_id,
             project_id=project_id,
             subtask_id=subtask_id,
+            plate_id=plate_id,
         )
 
         self.db.add(archive)

+ 200 - 0
backend/app/services/backup_path.py

@@ -0,0 +1,200 @@
+"""Why a backup directory is not writable — and what to actually do about it.
+
+Bambuddy's systemd unit runs with ``ProtectSystem=strict``. That mounts the
+entire filesystem read-only inside the service's own mount namespace and carves
+back out only ``ReadWritePaths=<install> <data> <logs>``. A backup output path
+on a NAS mount is therefore read-only *to the service* while the operator's own
+shell writes to it happily. The kernel reports this as ``EROFS``, not
+``EACCES``, so the obvious move — checking folder permissions — turns up nothing
+and the real cause (our own unit file) is the last place anyone looks (#2544).
+
+Docker has the same shape with a different cause: a host path that was never
+bind-mounted into the container is simply not the host path. Worse, it is still
+*writable* — the write lands in the container's ephemeral layer and vanishes on
+the next ``docker compose up``. A backup that silently goes nowhere is the one
+failure mode a backup feature must not have.
+
+So: probe the directory with a real write before trusting it, and when that
+write fails, name which of these it is and hand back the exact command that
+fixes it.
+"""
+
+from __future__ import annotations
+
+import errno
+import logging
+import os
+import re
+import tempfile
+from pathlib import Path
+
+from backend.app.services.discovery import is_running_in_docker
+
+logger = logging.getLogger(__name__)
+
+# Cgroup line for a systemd service, e.g.
+#   0::/system.slice/bambuddy.service
+#   0::/system.slice/system-bambuddy.slice/bambuddy@1.service
+_SERVICE_CGROUP = re.compile(r"/([^/]+\.service)\b")
+
+
+def systemd_unit_name() -> str | None:
+    """Name of the systemd unit we are running as, or None if we are not one.
+
+    ``INVOCATION_ID`` is set by systemd for every unit it starts and by nothing
+    else, so it is the signal that we are a unit at all. The name itself comes
+    from the cgroup path — systemd exports no environment variable for it.
+    """
+    if not os.environ.get("INVOCATION_ID"):
+        return None
+    try:
+        cgroup = Path("/proc/self/cgroup").read_text()
+    except OSError:
+        return "bambuddy.service"
+    match = _SERVICE_CGROUP.search(cgroup)
+    return match.group(1) if match else "bambuddy.service"
+
+
+def _systemd_remedy(unit: str, path: Path) -> str:
+    return (
+        f"sudo systemctl edit {unit}\n"
+        "\n"
+        "Add these two lines to the drop-in, save, then restart:\n"
+        "\n"
+        "[Service]\n"
+        f"ReadWritePaths={path}\n"
+        "\n"
+        f"sudo systemctl restart {unit}"
+    )
+
+
+def _docker_remedy(path: Path) -> str:
+    return f"services:\n  bambuddy:\n    volumes:\n      - {path}:{path}"
+
+
+def classify_backup_dir_error(exc: OSError, backup_dir: Path) -> dict:
+    """Map an OSError raised while writing to ``backup_dir`` onto a diagnosis.
+
+    ``message`` is English and goes to the log and the API. The frontend
+    translates from ``code`` and renders ``remedy`` verbatim as a snippet.
+    """
+    detail = str(exc)
+    unit = systemd_unit_name()
+
+    if exc.errno == errno.EROFS:
+        if unit:
+            return {
+                "writable": False,
+                "path": str(backup_dir),
+                "code": "sandboxed",
+                "detail": detail,
+                "remedy": _systemd_remedy(unit, backup_dir),
+                "message": (
+                    f"{backup_dir} is read-only for the Bambuddy service. Its systemd unit runs with "
+                    "ProtectSystem=strict, which makes every path outside the install, data and log "
+                    f"directories read-only — add ReadWritePaths={backup_dir} to a drop-in "
+                    f"(sudo systemctl edit {unit}) and restart. If the path is on a network share, also "
+                    "confirm the share itself is not mounted read-only."
+                ),
+            }
+        return {
+            "writable": False,
+            "path": str(backup_dir),
+            "code": "read_only",
+            "detail": detail,
+            "remedy": None,
+            "message": f"{backup_dir} is on a read-only filesystem.",
+        }
+
+    if exc.errno in (errno.EACCES, errno.EPERM):
+        return {
+            "writable": False,
+            "path": str(backup_dir),
+            "code": "permission_denied",
+            "detail": detail,
+            "remedy": None,
+            "message": f"Bambuddy is not allowed to write to {backup_dir}. Check the directory's owner and mode.",
+        }
+
+    if exc.errno == errno.ENOSPC:
+        return {
+            "writable": False,
+            "path": str(backup_dir),
+            "code": "no_space",
+            "detail": detail,
+            "remedy": None,
+            "message": f"No space left on the filesystem holding {backup_dir}.",
+        }
+
+    if exc.errno in (errno.ENOTDIR, errno.EEXIST):
+        return {
+            "writable": False,
+            "path": str(backup_dir),
+            "code": "not_a_directory",
+            "detail": detail,
+            "remedy": None,
+            "message": f"{backup_dir} exists but is not a directory.",
+        }
+
+    if exc.errno == errno.ENOENT:
+        return {
+            "writable": False,
+            "path": str(backup_dir),
+            "code": "missing",
+            "detail": detail,
+            "remedy": None,
+            "message": f"{backup_dir} does not exist and could not be created.",
+        }
+
+    return {
+        "writable": False,
+        "path": str(backup_dir),
+        "code": "error",
+        "detail": detail,
+        "remedy": None,
+        "message": f"Bambuddy cannot write to {backup_dir}: {exc}",
+    }
+
+
+def _is_container_ephemeral(backup_dir: Path) -> bool:
+    """True if this path lives in the container's own writable layer.
+
+    A bind mount or named volume always sits on a different device than the
+    container root, so a matching ``st_dev`` means nothing was mounted here and
+    the backups die with the container.
+    """
+    try:
+        return backup_dir.stat().st_dev == Path("/").stat().st_dev
+    except OSError:
+        return False
+
+
+def probe_backup_dir(backup_dir: Path) -> dict:
+    """Create the directory and write a throwaway file in it.
+
+    Returns the same shape as :func:`classify_backup_dir_error`, plus a
+    ``warning`` code for a directory that is writable but not persistent.
+    """
+    try:
+        backup_dir.mkdir(parents=True, exist_ok=True)
+        with tempfile.NamedTemporaryFile(dir=backup_dir, prefix=".bambuddy-write-test-") as probe:
+            probe.write(b"bambuddy")
+            probe.flush()
+    except OSError as e:
+        result = classify_backup_dir_error(e, backup_dir)
+        logger.warning("Backup path check failed: %s", result["message"])
+        return {**result, "warning": None}
+
+    warning = None
+    if is_running_in_docker() and _is_container_ephemeral(backup_dir):
+        warning = "container_ephemeral"
+
+    return {
+        "writable": True,
+        "path": str(backup_dir),
+        "code": "ok",
+        "detail": None,
+        "remedy": _docker_remedy(backup_dir) if warning else None,
+        "message": f"{backup_dir} is writable.",
+        "warning": warning,
+    }

+ 213 - 14
backend/app/services/bambu_cloud.py

@@ -4,8 +4,11 @@ Bambu Lab Cloud API Service
 Handles authentication and profile management with Bambu Lab's cloud services.
 """
 
+import hashlib
 import logging
-from datetime import datetime, timedelta, timezone
+import time
+from collections.abc import Awaitable, Callable
+from datetime import datetime, timezone
 
 import httpx
 
@@ -14,6 +17,61 @@ logger = logging.getLogger(__name__)
 BAMBU_API_BASE = "https://api.bambulab.com"
 BAMBU_API_BASE_CN = "https://api.bambulab.cn"
 
+# How long a "Bambu still accepts this token" answer is trusted before we ask
+# again. ``/cloud/status`` is polled by several components, so validating on
+# every call would put a Bambu round-trip behind every settings render; a token
+# does not expire on a five-minute boundary, so caching that long is free.
+_VALIDATION_TTL_SECONDS = 300
+
+# token digest -> (monotonic deadline, accepted?). Keyed by digest so a token
+# never sits in a process-wide dict in the clear.
+_validation_cache: dict[str, tuple[float, bool]] = {}
+
+
+def _token_digest(token: str) -> str:
+    return hashlib.sha256(token.encode("utf-8")).hexdigest()
+
+
+def is_expiry_401(response: httpx.Response) -> bool:
+    """Whether a 401 is Bambu's genuine "token expired" signal.
+
+    Bambu answers an expired/revoked token with ``{"code":4,"error":"Please
+    login.","message":""}``. Not every 401 means that: individual endpoints
+    return 401 for resource-, region- or scope-specific reasons, and a working
+    token still draws the occasional transient 401 (Cloudflare edge, a brief
+    backend blip). Treating *any* 401 as a dead credential signs the user out on
+    a single stray rejection — the #2562 follow-up regression. We trust only the
+    documented expiry body, so a benign 401 no longer nukes the whole cloud
+    integration. An unparseable / unsigned 401 is deliberately NOT expiry.
+
+    Shared by the Bambu Cloud and MakerWorld services — both carry the same
+    token and see the same expiry body.
+    """
+    try:
+        body = response.json()
+    except Exception:
+        return False
+    if not isinstance(body, dict):
+        return False
+    if body.get("code") == 4:
+        return True
+    text = f"{body.get('error', '')} {body.get('message', '')}".lower()
+    return "please login" in text
+
+
+def invalidate_validation_cache(token: str | None = None) -> None:
+    """Drop cached validation verdicts.
+
+    Called on login/logout so a fresh token isn't judged by the previous one's
+    cached verdict, and so a re-login clears a cached rejection immediately
+    rather than leaving the user staring at "sign-in expired" for five minutes.
+    """
+    if token is None:
+        _validation_cache.clear()
+    else:
+        _validation_cache.pop(_token_digest(token), None)
+
+
 # Client identity sent to Bambu Lab's cloud services. We identify honestly as
 # Bambuddy — the URL in parens makes the source unambiguous so Bambu can
 # distinguish our traffic from impersonators. This is the opposite of what the
@@ -79,9 +137,17 @@ _SLICER_API_VERSION = "1.0.0.0"
 
 
 class BambuCloudError(Exception):
-    """Base exception for Bambu Cloud errors."""
+    """Base exception for Bambu Cloud errors.
 
-    pass
+    ``status_code`` carries the upstream HTTP status when the failure came from
+    a response rather than from the transport, so callers can tell an expected
+    "this preset isn't in the catalog" 400 apart from an expired token or a
+    cloud outage. It stays ``None`` for connection-level failures.
+    """
+
+    def __init__(self, message: str, *, status_code: int | None = None):
+        super().__init__(message)
+        self.status_code = status_code
 
 
 class BambuCloudAuthError(BambuCloudError):
@@ -108,11 +174,23 @@ def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
 class BambuCloudService:
     """Service for interacting with Bambu Lab Cloud API."""
 
-    def __init__(self, region: str = "global", client: httpx.AsyncClient | None = None):
+    def __init__(
+        self,
+        region: str = "global",
+        client: httpx.AsyncClient | None = None,
+        on_auth_failure: Callable[[], Awaitable[None]] | None = None,
+    ):
         self.base_url = BAMBU_API_BASE if region == "global" else BAMBU_API_BASE_CN
         self.access_token: str | None = None
         self.refresh_token: str | None = None
         self.token_expiry: datetime | None = None
+        # Fired once when Bambu answers 401 to a call we made with a stored
+        # token — the credential is dead and the caller wants to record that.
+        # ``build_authenticated_cloud`` wires this to the persisted flag, so
+        # every route that builds a service through it gets invalidation for
+        # free rather than each one having to notice 401s for itself.
+        self._on_auth_failure = on_auth_failure
+        self._auth_failure_reported = False
         # Prefer an explicitly-injected client (tests), else fall back to the
         # app-scoped shared client (production), and finally create our own so
         # scripts / tests that skip the lifespan still get a working service.
@@ -128,11 +206,107 @@ class BambuCloudService:
 
     @property
     def is_authenticated(self) -> bool:
-        """Check if we have a valid token."""
+        """Whether a credential is *loaded* — NOT whether Bambu accepts it.
+
+        Bambu's access token is opaque (no JWT claims to read an expiry out
+        of), so the only authority on whether it still works is Bambu. This
+        used to pretend otherwise: ``set_token`` stamped ``token_expiry =
+        now + 30 days`` every time a stored token was loaded, which made the
+        expiry check reset on every request and this property incapable of
+        ever returning False. The UI reported "connected" indefinitely while
+        every cloud call 401'd (#2562 follow-up).
+
+        ``token_expiry`` is now only set when we genuinely know it. Callers
+        that need to know the token still *works* must ask Bambu — see
+        :meth:`validate_token` — or react to the 401 that surfaces.
+        """
         if not self.access_token:
             return False
         return not (self.token_expiry and datetime.now(timezone.utc) > self.token_expiry)
 
+    async def _note_response(self, response: httpx.Response) -> bool:
+        """Record Bambu's genuine token-expiry 401 as "this credential is dead".
+
+        Returns ``True`` only for the real expiry signal (see
+        :meth:`_is_expiry_401`); a plain/transient 401 returns ``False`` and is
+        left alone so it can't durably sign the user out. The durable flag is
+        written at most once per service instance so a route making several
+        calls doesn't write it repeatedly.
+        """
+        if response.status_code != 401:
+            return False
+        if not is_expiry_401(response):
+            logger.info(
+                "Bambu Cloud returned 401 without the expiry signature — treating as transient, "
+                "not signing the stored token out"
+            )
+            return False
+        if self._on_auth_failure is None or self._auth_failure_reported:
+            return True
+        self._auth_failure_reported = True
+        if self.access_token:
+            _validation_cache[_token_digest(self.access_token)] = (
+                time.monotonic() + _VALIDATION_TTL_SECONDS,
+                False,
+            )
+        try:
+            await self._on_auth_failure()
+        except Exception:
+            # Recording the failure is best-effort — the caller still needs the
+            # real error (a 401) rather than a bookkeeping exception on top.
+            logger.exception("Failed to record Bambu Cloud auth failure")
+        return True
+
+    async def validate_token(self) -> bool | None:
+        """Ask Bambu whether the loaded token is still accepted.
+
+        ``True`` accepted, ``False`` rejected (401), ``None`` unknown — Bambu
+        was unreachable or answered 5xx.
+
+        ``None`` must never be treated as "invalid": a Bambu outage or a
+        Cloudflare interstitial would otherwise sign every user out of a
+        perfectly good session. Callers report their last known state instead.
+        """
+        if not self.access_token:
+            return False
+
+        digest = _token_digest(self.access_token)
+        cached = _validation_cache.get(digest)
+        if cached and cached[0] > time.monotonic():
+            return cached[1]
+
+        try:
+            response = await self._client.get(
+                f"{self.base_url}/v1/design-user-service/my/preference",
+                headers=self._get_headers(),
+                timeout=15.0,
+            )
+        except httpx.HTTPError as exc:
+            logger.info("Could not reach Bambu Cloud to validate the stored token: %s", exc)
+            return None
+
+        if response.status_code == 401:
+            # Only a 401 carrying Bambu's expiry signature is a real sign-out.
+            # A signature-less 401 here is transient/edge noise — report unknown
+            # (last-known state) rather than expiring a working session.
+            expired = await self._note_response(response)
+            return False if expired else None
+        if response.status_code >= 500:
+            logger.info(
+                "Bambu Cloud returned %s while validating the token — treating as unknown", response.status_code
+            )
+            return None
+        if response.status_code != 200:
+            # 4xx that isn't 401 (403, 418 Cloudflare challenge, 429): the token
+            # itself was not rejected, so don't declare it dead.
+            logger.info(
+                "Bambu Cloud returned %s while validating the token — treating as unknown", response.status_code
+            )
+            return None
+
+        _validation_cache[digest] = (time.monotonic() + _VALIDATION_TTL_SECONDS, True)
+        return True
+
     def _get_headers(self) -> dict:
         """Get headers for authenticated requests."""
         headers = {
@@ -305,9 +479,10 @@ class BambuCloudService:
             if response.status_code == 200 and access_token:
                 self.access_token = access_token
                 self.refresh_token = data.get("refreshToken")
-                from datetime import datetime, timedelta, timezone
-
-                self.token_expiry = datetime.now(timezone.utc) + timedelta(days=30)
+                # Expiry left unset: Bambu does not tell us when the token dies
+                # and the token is opaque, so any value here would be invented.
+                self.token_expiry = None
+                invalidate_validation_cache(access_token)
                 return {"success": True, "message": "Login successful"}
 
             # Provide helpful error message
@@ -325,16 +500,30 @@ class BambuCloudService:
             return {"success": False, "message": f"TOTP verification error: {e}"}
 
     def _set_tokens(self, data: dict):
-        """Set tokens from login response."""
+        """Set tokens from a login response.
+
+        No expiry is recorded. Bambu's login response carries no expiry, and
+        the access token is opaque, so the old ``now + 30 days`` was a guess
+        that outlived its own accuracy — see :attr:`is_authenticated`.
+        """
         self.access_token = data.get("accessToken")
         self.refresh_token = data.get("refreshToken")
-        # Token typically valid for ~3 months, but we'll refresh more often
-        self.token_expiry = datetime.now(timezone.utc) + timedelta(days=30)
+        self.token_expiry = None
+        if self.access_token:
+            invalidate_validation_cache(self.access_token)
 
     def set_token(self, access_token: str):
-        """Set access token directly (for stored tokens)."""
+        """Load a stored access token.
+
+        This used to stamp ``token_expiry = now + 30 days`` — re-derived from
+        *now* on every request, for a token of entirely unknown age. That made
+        ``is_authenticated`` a permanent True and is why Bambuddy went on
+        reporting "connected" long after Bambu had stopped accepting the token.
+        A stored token's remaining life is unknowable from the token alone, so
+        we record no expiry and let Bambu be the authority.
+        """
         self.access_token = access_token
-        self.token_expiry = datetime.now(timezone.utc) + timedelta(days=30)
+        self.token_expiry = None
 
     def logout(self):
         """Clear authentication state."""
@@ -383,6 +572,7 @@ class BambuCloudService:
 
             data = response.json()
 
+            await self._note_response(response)
             if response.status_code == 200:
                 return data
 
@@ -403,12 +593,16 @@ class BambuCloudService:
                 params={"version": _SLICER_API_VERSION},
             )
 
+            await self._note_response(response)
             if response.status_code == 200:
                 return response.json()
 
             # Include body so a future contract change is self-diagnostic from logs.
             body = (response.text or "")[:200]
-            raise BambuCloudError(f"Failed to get setting detail: {response.status_code} {body}")
+            raise BambuCloudError(
+                f"Failed to get setting detail: {response.status_code} {body}",
+                status_code=response.status_code,
+            )
 
         except httpx.RequestError as e:
             raise BambuCloudError(f"Request failed: {e}")
@@ -453,6 +647,7 @@ class BambuCloudService:
 
             data = response.json()
 
+            await self._note_response(response)
             if response.status_code in (200, 201):
                 return data
 
@@ -538,6 +733,7 @@ class BambuCloudService:
 
             data = response.json()
 
+            await self._note_response(response)
             if response.status_code == 200:
                 return data
 
@@ -567,6 +763,7 @@ class BambuCloudService:
                 params={"version": _SLICER_API_VERSION},
             )
 
+            await self._note_response(response)
             if response.status_code in (200, 204):
                 return {"success": True, "message": "Setting deleted"}
 
@@ -587,6 +784,7 @@ class BambuCloudService:
                 f"{self.base_url}/v1/iot-service/api/user/bind", headers=self._get_headers()
             )
 
+            await self._note_response(response)
             if response.status_code == 200:
                 return response.json()
 
@@ -615,6 +813,7 @@ class BambuCloudService:
                 params={"device_id": device_id},
             )
 
+            await self._note_response(response)
             if response.status_code == 200:
                 data = response.json()
                 # API wraps response in 'data' field

+ 229 - 21
backend/app/services/bambu_ftp.py

@@ -6,7 +6,9 @@ import socket
 import ssl
 import threading
 import time
+import weakref
 from collections.abc import Awaitable, Callable
+from concurrent.futures import ThreadPoolExecutor
 from enum import Enum
 from ftplib import FTP, FTP_TLS  # nosec B402
 from io import BytesIO
@@ -17,6 +19,58 @@ logger = logging.getLogger(__name__)
 
 T = TypeVar("T")
 
+# Every FTP call below is blocking ftplib work handed to a thread. They used to
+# run on asyncio's *default* executor, which is sized min(32, cpu_count + 4) —
+# six threads on a 2-core NAS — and is shared with every other ``to_thread`` /
+# ``run_in_executor`` caller in the app. That was survivable only because the
+# scheduler uploaded to exactly one printer at a time. Dispatching to several
+# printers at once (#2555) would park one thread per in-flight upload for
+# minutes at a stretch (a 41 MB 3MF at the ~150 KB/s a Bambu printer sustains
+# takes ~4 min), starving the default pool and stalling unrelated work.
+#
+# A dedicated pool keeps that blast radius inside the FTP layer: the scheduler's
+# own concurrency cap is what limits parallel uploads, and it can never exhaust
+# the executor everything else depends on. Threads are created lazily, so an
+# idle pool costs nothing.
+#
+# Sized well above `queue_max_concurrent_uploads` (max 16), because uploads are
+# not the only traffic here: SD browsing, timelapse/recording listing, cover
+# downloads, deletes and storage checks all run through this pool too, and on a
+# farm they fan out across every printer at once. The pool's work queue is
+# unbounded, so exceeding it does not fail — it queues. But `asyncio.wait_for`
+# starts its clock at submission, not at thread start, so a task that sits in the
+# queue can burn its whole timeout without ever running, and `list_files_async`
+# reports a timeout as an empty listing — a silent "this printer has no files".
+# Keep the headroom.
+_FTP_MAX_WORKERS = 48
+_ftp_executor = ThreadPoolExecutor(max_workers=_FTP_MAX_WORKERS, thread_name_prefix="bambu-ftp")
+
+# Overall upload deadline (#2529). A flat wall-clock cap punishes big files on
+# slow links rather than catching broken ones: a 96 MB 3MF at the ~75 KB/s an A1
+# sustains over WiFi legitimately needs ~20 minutes, and the old flat 600 s
+# declared it dead at ~70 MB. The deadline is therefore derived from the file
+# size against a deliberately pessimistic floor rate. This is a backstop, not the
+# failure detector — a link that has actually died is caught within
+# ``socket_timeout`` by the blocking ``sendall``, long before this fires.
+_UPLOAD_FLOOR_BYTES_PER_SEC = 25 * 1024
+_UPLOAD_MIN_TIMEOUT = 600.0
+
+# How long to give the worker thread to notice the cancel flag, unwind, and
+# delete its partial file. It checks the flag once per CHUNK_SIZE, so on a link
+# slow enough to have hit the deadline this is one chunk plus the delete.
+_UPLOAD_CANCEL_GRACE = 60.0
+
+
+class UploadCancelled(Exception):
+    """Raised inside the upload worker to abort an in-flight transfer.
+
+    ``upload_file`` treats any exception from its progress callback as "stop
+    now": it breaks out of the send loop, deletes the partial file from the
+    printer, and re-raises. That is the only way to stop a transfer — an
+    executor thread cannot be cancelled from the event loop, so a bare
+    ``asyncio.wait_for`` leaves it streaming (see ``upload_file_async``).
+    """
+
 
 class DeleteResult(Enum):
     """Outcome of an FTP delete attempt.
@@ -64,7 +118,18 @@ class ImplicitFTP_TLS(FTP_TLS):
         self.ssl_context = ssl.create_default_context()
         self.ssl_context.check_hostname = False
         self.ssl_context.verify_mode = ssl.CERT_NONE
+        # ``create_default_context()`` does NOT guarantee a protocol floor: it
+        # leaves ``minimum_version`` at ``MINIMUM_SUPPORTED``, and what that
+        # resolves to is a property of the OpenSSL build, not of this code.
+        # Measured on identical OpenSSL 3.5.6: python:3.13-slim-trixie (our
+        # Docker base) reports TLSv1_2, a bare-metal venv reports
+        # MINIMUM_SUPPORTED. Docker users have therefore always been floored at
+        # 1.2 — every Bambu model is reachable under that floor — while
+        # bare-metal and appliance installs could silently negotiate TLS 1.0.
+        # State the floor rather than inheriting it.
+        self.ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
         if cap_tls_v1_2:
+            # With the floor above this pins the connection to exactly TLS 1.2.
             self.ssl_context.maximum_version = ssl.TLSVersion.TLSv1_2
 
     def connect(self, host="", port=990, timeout=-999, source_address=None):
@@ -881,7 +946,7 @@ async def download_file_async(
         done = threading.Event()
         try:
             return await asyncio.wait_for(
-                loop.run_in_executor(None, _download, force_prot_c, completion, done), timeout=timeout
+                loop.run_in_executor(_ftp_executor, _download, force_prot_c, completion, done), timeout=timeout
             )
         except TimeoutError:
             # Slow WiFi links commonly overshoot ftp_timeout by 10–30 s without
@@ -894,6 +959,12 @@ async def download_file_async(
             # floor so artificially small test timeouts still give zombies a
             # realistic window to finish.
             grace = max(min(timeout, 30.0), 0.5)
+            # Deliberately the DEFAULT executor, not `_ftp_executor`: this thread
+            # blocks waiting on `_download`, which is itself an `_ftp_executor`
+            # worker. Parking waiters in the same bounded pool as the workers they
+            # wait for is how you build a deadlock — with enough concurrent
+            # timeouts the waiters would occupy every slot and the downloads they
+            # are waiting for could never be scheduled.
             await loop.run_in_executor(None, done.wait, grace)
             if completion["success"] and local_path.exists() and local_path.stat().st_size > 0:
                 logger.info(
@@ -938,12 +1009,21 @@ async def download_file_try_paths_async(
     local_path: Path,
     socket_timeout: float | None = None,
     printer_model: str | None = None,
+    timeout: float = 90.0,
 ) -> bool:
     """Try downloading a file from multiple paths using a single connection.
 
     Args:
         socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
         printer_model: Printer model for A1-specific workarounds
+        timeout: overall async cap. The per-socket timeout only bounds an
+            in-flight worker; it does NOT bound how long this coroutine waits
+            for a free slot in the fixed-size ``_ftp_executor``. On a large
+            farm where offline printers keep every worker busy on dead
+            connects, that queue wait is otherwise unbounded — and any caller
+            holding a DB connection while awaiting this would pin it until the
+            pool is exhausted (#2572). The cap converts that into a bounded
+            wait; the orphaned worker finishes and its result is discarded.
     """
     loop = asyncio.get_event_loop()
 
@@ -966,7 +1046,44 @@ async def download_file_try_paths_async(
         finally:
             client.disconnect()
 
-    return await loop.run_in_executor(None, _download)
+    try:
+        return await asyncio.wait_for(loop.run_in_executor(_ftp_executor, _download), timeout=timeout)
+    except TimeoutError:
+        logger.warning("FTP download_try_paths exceeded its %ss cap for %s (#2572)", timeout, ip_address)
+        return False
+
+
+def _upload_deadline(local_path: Path) -> float:
+    """Derive an upload deadline from the file size (#2529).
+
+    See ``_UPLOAD_FLOOR_BYTES_PER_SEC``. An unstat-able file falls back to the
+    floor timeout — ``upload_file`` will fail on the open() anyway.
+    """
+    try:
+        size = local_path.stat().st_size
+    except OSError:
+        return _UPLOAD_MIN_TIMEOUT
+    return max(_UPLOAD_MIN_TIMEOUT, size / _UPLOAD_FLOOR_BYTES_PER_SEC)
+
+
+# One upload at a time per printer. Two concurrent STOR commands for the same
+# remote path leave a corrupt file on the SD card, and the printer reads as
+# flaky rather than busy (#2529). Held for the duration of a transfer, so a
+# second dispatch to the same printer queues behind the first instead of racing
+# it. Keyed per event loop: an asyncio.Lock binds to the loop that first awaits
+# it, and the test suite runs each case on a fresh loop.
+_upload_locks: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, dict[str, asyncio.Lock]] = (
+    weakref.WeakKeyDictionary()
+)
+
+
+def _upload_lock(loop: asyncio.AbstractEventLoop, ip_address: str) -> asyncio.Lock:
+    per_loop = _upload_locks.setdefault(loop, {})
+    lock = per_loop.get(ip_address)
+    if lock is None:
+        lock = asyncio.Lock()
+        per_loop[ip_address] = lock
+    return lock
 
 
 async def upload_file_async(
@@ -974,7 +1091,7 @@ async def upload_file_async(
     access_code: str,
     local_path: Path,
     remote_path: str,
-    timeout: float = 600.0,
+    timeout: float | None = None,
     progress_callback: Callable[[int, int], None] | None = None,
     socket_timeout: float | None = None,
     printer_model: str | None = None,
@@ -989,19 +1106,31 @@ async def upload_file_async(
         access_code: Printer access code
         local_path: Local file path to upload
         remote_path: Remote path on printer
-        timeout: Overall operation timeout (asyncio)
+        timeout: Overall deadline. ``None`` (the default) derives it from the
+            file size — see ``_upload_deadline``. A caller that passes a number
+            gets exactly that, which is what the tests rely on.
         progress_callback: Optional callback for progress updates
         socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
         printer_model: Printer model for A1-specific workarounds
     """
     loop = asyncio.get_event_loop()
     is_a1 = printer_model in BambuFTPClient.A1_MODELS if printer_model else False
+    deadline = _upload_deadline(local_path) if timeout is None else timeout
+
+    # Set when the deadline expires. The worker checks it once per chunk.
+    cancel = threading.Event()
+
+    def _guarded_progress(uploaded: int, total: int) -> None:
+        if cancel.is_set():
+            raise UploadCancelled(f"upload of {remote_path} exceeded its {deadline:.0f}s deadline")
+        if progress_callback:
+            progress_callback(uploaded, total)
 
     def _upload(force_prot_c: bool = False) -> bool:
         mode_str = "prot_c" if force_prot_c else "prot_p"
         logger.info(
             f"FTP connecting to {ip_address} for upload (model={printer_model}, "
-            f"mode={mode_str}, socket_timeout={socket_timeout}s)..."
+            f"mode={mode_str}, socket_timeout={socket_timeout}s, deadline={deadline:.0f}s)..."
         )
         client = BambuFTPClient(
             ip_address, access_code, timeout=socket_timeout, printer_model=printer_model, force_prot_c=force_prot_c
@@ -1009,7 +1138,7 @@ async def upload_file_async(
         if client.connect():
             logger.info("FTP connected to %s", ip_address)
             try:
-                result = client.upload_file(local_path, remote_path, progress_callback)
+                result = client.upload_file(local_path, remote_path, _guarded_progress)
                 if result:
                     # Cache the working mode
                     BambuFTPClient.cache_mode(ip_address, mode_str)
@@ -1019,32 +1148,80 @@ async def upload_file_async(
         logger.warning("FTP connection failed to %s", ip_address)
         return False
 
-    try:
+    async def _attempt(force_prot_c: bool) -> bool:
+        """Run one upload attempt, and make a timeout actually stop the transfer.
+
+        ``asyncio.wait_for`` cancels the *future*, never the executor thread
+        behind it. Before #2529 a slow-but-healthy upload that overran the
+        deadline left that thread streaming: it kept pushing bytes, kept firing
+        the progress callback, and the retry above put a *second* STOR of the
+        same file onto the same printer. The reporter's 96 MB job ran four
+        concurrent transfers and never landed. So on timeout we signal the
+        worker (it raises ``UploadCancelled`` from the progress callback, which
+        breaks the send loop and deletes the partial file) and wait for it to
+        actually go.
+        """
+        fut = loop.run_in_executor(_ftp_executor, lambda: _upload(force_prot_c))
+        try:
+            return await asyncio.wait_for(asyncio.shield(fut), timeout=deadline)
+        except TimeoutError:
+            cancel.set()
+            logger.warning(
+                "FTP upload of %s exceeded its %.0fs deadline — cancelling the transfer",
+                remote_path,
+                deadline,
+            )
+            try:
+                await asyncio.wait_for(asyncio.shield(fut), timeout=_UPLOAD_CANCEL_GRACE)
+            except UploadCancelled:
+                logger.info("FTP upload of %s cancelled; partial file removed from the printer", remote_path)
+            except TimeoutError:
+                # The thread is wedged somewhere that never reaches the callback
+                # (a blocked sendall, say). Nothing more we can do from here —
+                # but consume the eventual result so asyncio doesn't log the
+                # future's exception as unretrieved when it is garbage-collected.
+                logger.error(
+                    "FTP upload thread for %s did not stop within %.0fs of the cancel signal",
+                    remote_path,
+                    _UPLOAD_CANCEL_GRACE,
+                )
+                fut.add_done_callback(_swallow_future_result)
+            except Exception as e:
+                logger.warning("FTP upload of %s errored while cancelling: %s", remote_path, e)
+            # Raise rather than return False: a deadline expiry means the link
+            # sustained less than the floor rate for the whole transfer, and a
+            # retry would only spend another full deadline finding that out
+            # again — with check_queue serialized, four of those block the
+            # entire print queue for hours. ``with_ftp_retry`` never retries it.
+            raise UploadCancelled(
+                f"Upload of {remote_path} to {ip_address} exceeded its {deadline:.0f}s deadline "
+                f"(link sustained less than {_UPLOAD_FLOOR_BYTES_PER_SEC // 1024} KB/s)"
+            ) from None
+
+    async with _upload_lock(loop, ip_address):
         # Check if we have a cached mode for this printer
         cached_mode = BambuFTPClient._mode_cache.get(ip_address)
 
         if cached_mode:
             # Use cached mode
-            force_prot_c = cached_mode == "prot_c"
-            return await asyncio.wait_for(loop.run_in_executor(None, lambda: _upload(force_prot_c)), timeout=timeout)
+            return await _attempt(cached_mode == "prot_c")
 
         # No cached mode - try prot_p first
-        result = await asyncio.wait_for(loop.run_in_executor(None, lambda: _upload(False)), timeout=timeout)
-
-        if result:
+        if await _attempt(False):
             return True
 
         # Upload failed - for A1 models, try prot_c fallback
         if is_a1:
             logger.info("FTP upload failed with prot_p for A1 model, trying prot_c fallback...")
-            result = await asyncio.wait_for(loop.run_in_executor(None, lambda: _upload(True)), timeout=timeout)
-            return result
+            return await _attempt(True)
 
         return False
 
-    except TimeoutError:
-        logger.warning("FTP upload timed out after %ss for %s", timeout, remote_path)
-        return False
+
+def _swallow_future_result(fut: asyncio.Future) -> None:
+    """Retrieve a future's exception so asyncio doesn't log it as unhandled."""
+    if not fut.cancelled():
+        fut.exception()
 
 
 async def list_files_async(
@@ -1073,7 +1250,7 @@ async def list_files_async(
         return []
 
     try:
-        return await asyncio.wait_for(loop.run_in_executor(None, _list), timeout=timeout)
+        return await asyncio.wait_for(loop.run_in_executor(_ftp_executor, _list), timeout=timeout)
     except TimeoutError:
         logger.warning("FTP list_files timed out after %ss for %s", timeout, path)
         return []
@@ -1085,6 +1262,7 @@ async def delete_file_async(
     remote_path: str,
     socket_timeout: float | None = None,
     printer_model: str | None = None,
+    timeout: float = 60.0,
 ) -> DeleteResult:
     """Async wrapper for deleting a file.
 
@@ -1095,6 +1273,8 @@ async def delete_file_async(
     Args:
         socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
         printer_model: Printer model for A1-specific workarounds
+        timeout: overall async cap so a saturated ``_ftp_executor`` can't pin
+            the caller (and any DB connection it holds) indefinitely (#2572).
     """
     loop = asyncio.get_event_loop()
 
@@ -1107,7 +1287,11 @@ async def delete_file_async(
                 client.disconnect()
         return DeleteResult.FAILED
 
-    return await loop.run_in_executor(None, _delete)
+    try:
+        return await asyncio.wait_for(loop.run_in_executor(_ftp_executor, _delete), timeout=timeout)
+    except TimeoutError:
+        logger.warning("FTP delete_file exceeded its %ss cap for %s (#2572)", timeout, ip_address)
+        return DeleteResult.FAILED
 
 
 async def download_file_bytes_async(
@@ -1116,12 +1300,19 @@ async def download_file_bytes_async(
     remote_path: str,
     socket_timeout: float | None = None,
     printer_model: str | None = None,
+    timeout: float = 300.0,
 ) -> bytes | None:
     """Async wrapper for downloading file as bytes.
 
     Args:
         socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
         printer_model: Printer model for A1-specific workarounds
+        timeout: overall async cap so a saturated ``_ftp_executor`` can't pin
+            the caller (and any DB connection it holds) indefinitely (#2572).
+            Generous by default because this pulls whole files (timelapse
+            video, gcode) which can legitimately take minutes over slow Wi-Fi —
+            the cap only guards against a permanently-starved pool, not a
+            slow-but-progressing transfer.
     """
     loop = asyncio.get_event_loop()
 
@@ -1134,7 +1325,11 @@ async def download_file_bytes_async(
                 client.disconnect()
         return None
 
-    return await loop.run_in_executor(None, _download)
+    try:
+        return await asyncio.wait_for(loop.run_in_executor(_ftp_executor, _download), timeout=timeout)
+    except TimeoutError:
+        logger.warning("FTP download_bytes exceeded its %ss cap for %s (#2572)", timeout, ip_address)
+        return None
 
 
 async def get_storage_info_async(
@@ -1142,12 +1337,15 @@ async def get_storage_info_async(
     access_code: str,
     socket_timeout: float | None = None,
     printer_model: str | None = None,
+    timeout: float = 60.0,
 ) -> dict | None:
     """Async wrapper for getting storage info.
 
     Args:
         socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
         printer_model: Printer model for A1-specific workarounds
+        timeout: overall async cap so a saturated ``_ftp_executor`` can't pin
+            the caller (and any DB connection it holds) indefinitely (#2572).
     """
     loop = asyncio.get_event_loop()
 
@@ -1160,7 +1358,11 @@ async def get_storage_info_async(
                 client.disconnect()
         return None
 
-    return await loop.run_in_executor(None, _get_storage)
+    try:
+        return await asyncio.wait_for(loop.run_in_executor(_ftp_executor, _get_storage), timeout=timeout)
+    except TimeoutError:
+        logger.warning("FTP get_storage_info exceeded its %ss cap for %s (#2572)", timeout, ip_address)
+        return None
 
 
 async def get_ftp_retry_settings() -> tuple[bool, int, float, float]:
@@ -1202,6 +1404,10 @@ async def with_ftp_retry(
 
     Returns:
         Result of the operation, or None if all attempts fail
+
+    ``UploadCancelled`` is never retried, whatever the caller passes: it means
+    the transfer overran its size-derived deadline, so a retry would spend
+    another full deadline reaching the same conclusion (#2529).
     """
     last_error = None
 
@@ -1216,6 +1422,8 @@ async def with_ftp_retry(
             # Operation returned failure indicator
             if attempt > 0:
                 logger.info("%s attempt %s/%s returned failure", operation_name, attempt + 1, max_retries + 1)
+        except UploadCancelled:
+            raise
         except Exception as e:
             if non_retry_exceptions and isinstance(e, non_retry_exceptions):
                 raise

Разница между файлами не показана из-за своего большого размера
+ 647 - 30
backend/app/services/bambu_mqtt.py


+ 106 - 9
backend/app/services/camera_fanout.py

@@ -26,6 +26,11 @@ logger = logging.getLogger(__name__)
 # on some firmwares and is the very reconnect cost we are trying to avoid).
 _GRACE_SECONDS = 5.0
 
+# Upper bound on how long a new broadcaster waits for a displaced one to finish
+# tearing down before proceeding anyway (#2521). Teardown is normally sub-second
+# (cancel pump + close socket); the cap only guards a wedged upstream close.
+_TEARDOWN_WAIT_SECONDS = 10.0
+
 # Per-subscriber queue depth. Small on purpose: if a viewer can't keep up
 # with the printer's frame rate we drop frames for that viewer rather than
 # blocking the broadcaster. Live video — old frames have no value.
@@ -35,13 +40,20 @@ _SUBSCRIBER_QUEUE_SIZE = 4
 # subscriber's read loop can break out cleanly instead of hanging on get().
 _UPSTREAM_GONE = b""
 
+# How often a subscriber that isn't receiving frames re-checks whether its
+# client is still connected. Only pays a cost when the stream is *not* producing
+# frames — the normal path returns from queue.get() as soon as a frame lands and
+# checks after the yield. Kept short because the subscriber count derived from
+# it is what /camera/stop uses to decide whether to tear the upstream down.
+_DISCONNECT_POLL_SECONDS = 1.0
+
 UpstreamFactory = Callable[[asyncio.Event], AsyncGenerator[bytes, None]]
 
 
 class MjpegBroadcaster:
     """Single upstream MJPEG stream, fanned out to N subscribers."""
 
-    def __init__(self, key: str, factory: UpstreamFactory) -> None:
+    def __init__(self, key: str, factory: UpstreamFactory, predecessor: MjpegBroadcaster | None = None) -> None:
         self._key = key
         self._factory = factory
         self._subscribers: list[asyncio.Queue[bytes]] = []
@@ -52,6 +64,22 @@ class MjpegBroadcaster:
         # stop reconnecting when the last subscriber leaves.
         self._upstream_disconnect = asyncio.Event()
         self._stopped = False
+        # Most recent chunk pumped to subscribers. New (late) subscribers are
+        # primed with it so the browser renders a frame immediately instead of
+        # waiting for the next upstream frame — critical on slow chamber-image
+        # cams where the wait looked like a permanent black screen (#2521).
+        self._last_chunk: bytes | None = None
+        # Set once teardown is fully complete (pump cancelled AND the upstream
+        # socket closed). A successor broadcaster waits on this before dialing
+        # so a single-connection printer never sees two sockets at once — the
+        # overlap stranded frames on an orphaned socket for the ~20 min it took
+        # the printer's TCP keepalive to reap it (#2521).
+        self._teardown_complete = asyncio.Event()
+        # The stopped broadcaster this one replaces, if any. The pump waits for
+        # its socket to close before opening ours. Guarding at the pump (not at
+        # get_or_create) keeps it correct when concurrent viewers race to
+        # replace the same stopped broadcaster — only the single pump dials.
+        self._predecessor = predecessor
 
     @property
     def key(self) -> str:
@@ -79,6 +107,15 @@ class MjpegBroadcaster:
             queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=_SUBSCRIBER_QUEUE_SIZE)
             self._subscribers.append(queue)
 
+            # Prime a late joiner with the last frame so it renders instantly
+            # (#2521). The very first subscriber has nothing to prime yet — it
+            # starts the pump below.
+            if self._last_chunk is not None:
+                try:
+                    queue.put_nowait(self._last_chunk)
+                except asyncio.QueueFull:  # pragma: no cover — fresh queue
+                    pass
+
             if self._pump_task is None or self._pump_task.done():
                 # Reset the disconnect signal in case a previous pump set it.
                 self._upstream_disconnect = asyncio.Event()
@@ -105,6 +142,18 @@ class MjpegBroadcaster:
         """Tear down immediately, kick all subscribers. Idempotent."""
         pump_task = await self._mark_stopped_locked(notify_subscribers=True)
         await self._await_pump_cancellation(pump_task)
+        # Upstream socket is now closed (pump's finally ran) — release anyone
+        # waiting to open a replacement broadcaster (#2521).
+        self._teardown_complete.set()
+
+    async def wait_until_torn_down(self) -> None:
+        """Block until this broadcaster's upstream socket has fully closed.
+
+        Only meaningful for a stopped broadcaster; on a live one this never
+        returns. get_or_create_broadcaster gates a replacement on it so the
+        old and new upstream sockets never overlap (#2521).
+        """
+        await self._teardown_complete.wait()
 
     async def _grace_then_stop(self) -> None:
         try:
@@ -123,6 +172,8 @@ class MjpegBroadcaster:
             self._grace_task = None
             self._stopped = True
         await self._await_pump_cancellation(pump_task)
+        # Upstream socket is now closed — release any pending replacement (#2521).
+        self._teardown_complete.set()
 
     async def _mark_stopped_locked(self, *, notify_subscribers: bool) -> asyncio.Task | None:
         """Mark the broadcaster stopped and detach the pump task.
@@ -165,10 +216,23 @@ class MjpegBroadcaster:
     async def _pump(self) -> None:
         """Drive the upstream generator and broadcast each chunk."""
         try:
+            # Don't dial the printer until the broadcaster we're replacing has
+            # closed its socket (#2521). Bounded so a wedged teardown degrades
+            # to the old overlap behaviour rather than never producing a frame.
+            predecessor = self._predecessor
+            self._predecessor = None
+            if predecessor is not None:
+                try:
+                    await asyncio.wait_for(predecessor.wait_until_torn_down(), timeout=_TEARDOWN_WAIT_SECONDS)
+                except asyncio.TimeoutError:
+                    logger.warning("Prior broadcaster %r didn't tear down in time; dialing anyway", self._key)
             async for chunk in self._factory(self._upstream_disconnect):
                 # Snapshot subscribers under lock so we don't iterate a list
                 # mutated by subscribe()/unsubscribe() while we are putting.
+                # Remember the frame under the same lock so subscribe() can
+                # prime a late joiner with a consistent last-chunk value (#2521).
                 async with self._lock:
+                    self._last_chunk = chunk
                     targets = list(self._subscribers)
                 for queue in targets:
                     try:
@@ -203,22 +267,49 @@ async def get_or_create_broadcaster(key: str, factory: UpstreamFactory) -> Mjpeg
 
     A broadcaster that has been stopped (force shutdown or grace timeout) is
     replaced with a fresh instance — the caller will subscribe to the new one.
+
+    When replacing a stopped broadcaster, the fresh instance is handed it as a
+    predecessor: its pump waits for the old socket to close before dialing, so
+    a single-connection cam (chamber-image port 6000) never sees two sockets at
+    once. Otherwise the printer keeps feeding the orphaned socket and starves
+    the new one until its TCP keepalive reaps it, ~20 min later (#2521).
     """
     async with _registry_lock:
         existing = _broadcasters.get(key)
         if existing is not None and not existing.stopped:
             return existing
-        new_bc = MjpegBroadcaster(key, factory)
+        # `existing` (if any) is stopped/tearing down — chain the new pump
+        # behind its socket close.
+        new_bc = MjpegBroadcaster(key, factory, predecessor=existing)
         _broadcasters[key] = new_bc
         return new_bc
 
 
 async def shutdown_broadcaster(key: str) -> bool:
-    """Force-shutdown the broadcaster for `key`. Returns True if one was running."""
+    """Force-shutdown the broadcaster for `key`. Returns True if one was running.
+
+    The stopped broadcaster stays in the registry on purpose. It used to be
+    popped *before* ``force_shutdown()`` was awaited, which vacated the slot
+    while the upstream socket was still closing: a ``/camera/stream`` request
+    landing in that window found nothing, minted a broadcaster with
+    ``predecessor=None``, and dialled the printer immediately. That is exactly
+    the two-sockets-at-once overlap the predecessor gate exists to prevent —
+    the gate only engages when the stopped broadcaster is still *findable*, and
+    popping it here bypassed the gate in the one case it was written for. A page
+    reload fires ``/camera/stop`` and the new stream request concurrently, so a
+    single-connection cam (chamber-image port 6000) ended up with an orphaned
+    socket that the printer kept feeding, starving the live viewer until the
+    printer's TCP keepalive reaped it ~20 min later (#2521).
+
+    Leaving it in place is safe: ``get_or_create_broadcaster`` replaces a stopped
+    entry (chaining the successor behind its teardown), ``get_subscriber_count``
+    reports 0 for it, and ``active_broadcaster_keys`` filters it out. There is at
+    most one entry per printer, and it is overwritten by the next viewer.
+    """
     async with _registry_lock:
-        bc = _broadcasters.pop(key, None)
-    if bc is None:
-        return False
+        bc = _broadcasters.get(key)
+        if bc is None or bc.stopped:
+            return False
     await bc.force_shutdown()
     return True
 
@@ -273,10 +364,16 @@ async def iter_subscriber(
     try:
         while True:
             try:
-                chunk = await asyncio.wait_for(queue.get(), timeout=30.0)
+                chunk = await asyncio.wait_for(queue.get(), timeout=_DISCONNECT_POLL_SECONDS)
             except asyncio.TimeoutError:
-                # No frame in 30s — check whether the client is still there.
-                # If yes, keep waiting; if no, bail out.
+                # No frame this tick — is the client still there? This used to
+                # wait 30 s before asking, and the disconnect check after a yield
+                # only fires when frames are actually flowing. So a viewer that
+                # went away while the stream was black stayed *counted* as a
+                # subscriber for up to half a minute — and ``/camera/stop``
+                # trusts that count to decide whether to tear the upstream down,
+                # so a phantom subscriber could make it skip teardown entirely
+                # (#2521). Poll often enough that the count means something.
                 if is_disconnected is not None and await is_disconnected():
                     break
                 continue

+ 51 - 0
backend/app/services/filament_requirements.py

@@ -102,6 +102,57 @@ def extract_filament_requirements(file_path: Path, plate_id: int | None = None)
     return filaments
 
 
+def overrides_for_plate(
+    overrides: list[dict],
+    file_path: Path | None,
+    plate_id: int | None,
+) -> list[dict]:
+    """Drop the filament overrides whose slots this plate never prints.
+
+    Queueing several plates of one 3MF builds a single override list out of every
+    selected plate's filaments and hands that same list to each plate's item. A
+    ``force_color_match`` entry blocks dispatch until the printer has that exact
+    colour loaded, so a single-colour plate ended up waiting on every colour in
+    the batch (#2551). Each item may only demand what its own plate consumes.
+
+    Overrides are kept as-is when the plate's slots cannot be established (whole
+    file selected, source gone, unreadable 3MF, malformed entry): an item that
+    waits on a colour it does not need is visible and fixable, whereas one that
+    silently loses a forced colour can dispatch the print in the wrong filament.
+    """
+    if not overrides or plate_id is None or file_path is None or not file_path.exists():
+        return overrides
+
+    plate_slots = {f["slot_id"] for f in extract_filament_requirements(file_path, plate_id)}
+    if not plate_slots:
+        logger.warning(
+            "Cannot read the filaments of plate %s in %s; keeping all %d filament override(s)",
+            plate_id,
+            file_path.name,
+            len(overrides),
+        )
+        return overrides
+
+    narrowed = []
+    for override in overrides:
+        try:
+            slot_id = int(override["slot_id"])
+        except (KeyError, TypeError, ValueError):
+            narrowed.append(override)
+            continue
+        if slot_id in plate_slots:
+            narrowed.append(override)
+
+    if len(narrowed) != len(overrides):
+        logger.info(
+            "Plate %s: kept %d of %d filament override(s) — the rest belong to other plates",
+            plate_id,
+            len(narrowed),
+            len(overrides),
+        )
+    return narrowed
+
+
 def _collect_filaments(parent: ET.Element, into: list[dict]) -> None:
     """Walk every `./filament` child under `parent` and append normalised
     entries to `into`. Skips filaments with `used_g <= 0` (slot present in

+ 28 - 9
backend/app/services/ftp_profiles.py

@@ -34,10 +34,8 @@ class FTPProfile:
 
     # Pin the SSL context's ``maximum_version`` to TLS 1.2.
     #
-    # Python 3.13's default ``ssl.create_default_context()`` negotiates
-    # TLS 1.3 when both peers support it. The Bambuddy Docker image is
-    # ``python:3.13-slim-trixie``, so every Docker user gets 1.3 by
-    # default. Some Bambu printer firmwares (P2S 01.02.00.00 confirmed
+    # ``ssl.create_default_context()`` negotiates TLS 1.3 when both peers
+    # support it. Some Bambu printer firmwares (P2S 01.02.00.00 confirmed
     # by @iitazz, #1401) implement session reuse on the FTPS data
     # channel against an old vsFTPd build that doesn't tolerate TLS
     # 1.3's asynchronous session-ticket model: the data channel gets
@@ -47,12 +45,18 @@ class FTPProfile:
     # the printer). Capping to TLS 1.2 makes session resumption
     # synchronous and the upload completes normally.
     #
+    # Note this cap only bites on models that *offer* 1.3 in the first
+    # place. Probed directly on :990, an X1C and an H2D both refuse
+    # TLS 1.0, 1.1 and 1.3 with a handshake_failure alert and complete
+    # only on 1.2 — so for those models the cap is a no-op and the
+    # negotiated version was never 1.3. The P2S evidently does offer
+    # 1.3, which is why it alone surfaced the session-reuse bug.
+    # (P1S untested; no claim made either way.)
+    #
     # **Defaults to False** — only applied to printer models where a
-    # reporter has confirmed the symptom. Existing P1S / X1C / H2D
-    # installs that work fine today stay on the negotiated TLS 1.3.
-    # This is deliberately conservative; flipping a printer to the
-    # capped path is a config edit when a new model surfaces the
-    # same bug.
+    # reporter has confirmed the symptom. This is deliberately
+    # conservative; flipping a printer to the capped path is a config
+    # edit when a new model surfaces the same bug.
     cap_tls_v1_2: bool = False
 
 
@@ -87,6 +91,19 @@ _PROFILES: dict[str, FTPProfile] = {
     "X2D": FTPProfile(
         cap_tls_v1_2=True,
     ),
+    # H2C firmware 01.02.00.00 (#2582, reporter @gyrene2083) — same H2
+    # generation and same firmware line as P2S, and with no profile it
+    # ran on the Python-default TLS 1.3. Reported symptom is exactly the
+    # one the X2D comment describes: the sliced 3MF intermittently fails
+    # to come off the printer over FTPS, so the print drops to the no-3MF
+    # fallback archive with no slice data — which is why the Print Log
+    # shows no filament and nothing is deducted. Cap to TLS 1.2 by analogy
+    # with P2S (intermittent "sometimes works" points at the session-reuse
+    # variant, not X2D's deterministic handshake failure); if a debug
+    # capture shows a different FTPS variant the entry stays the tuning slot.
+    "H2C": FTPProfile(
+        cap_tls_v1_2=True,
+    ),
 }
 
 # SSDP internal codes that should resolve to a display-name profile.
@@ -94,6 +111,8 @@ _PROFILES: dict[str, FTPProfile] = {
 _MODEL_ALIASES: dict[str, str] = {
     "N7": "P2S",  # P2S internal SSDP code
     "N6": "X2D",  # X2D internal SSDP code
+    "O1C": "H2C",  # H2C internal SSDP code
+    "O1C2": "H2C",  # H2C dual-nozzle variant SSDP code
 }
 
 

+ 30 - 34
backend/app/services/local_backup.py

@@ -6,46 +6,22 @@ on a configurable schedule with retention management.
 
 import asyncio
 import logging
-import os
-from datetime import datetime, timedelta, timezone, tzinfo
+from datetime import datetime, timedelta, timezone
 from pathlib import Path
-from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
 
 from sqlalchemy import select
 
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import async_session
 from backend.app.models.settings import Settings
+from backend.app.services.backup_path import classify_backup_dir_error, probe_backup_dir
 
-logger = logging.getLogger(__name__)
-
-
-def _local_zone() -> tzinfo:
-    """Resolve the local timezone for scheduled-backup HH:MM interpretation.
-
-    Uses the container's ``TZ`` env var (the same value the support package
-    surfaces); falls back to UTC when unset or unrecognised so a missing TZ
-    keeps the legacy behaviour rather than crashing. See #1602 follow-up.
-
-    On Windows the embedded Python in our installer doesn't carry an IANA
-    tz database, so ``ZoneInfo(...)`` — including ``ZoneInfo("UTC")`` —
-    raises ``ZoneInfoNotFoundError`` unless the ``tzdata`` PyPI package is
-    installed. requirements.txt now pins ``tzdata`` on win32, but to keep
-    this resilient on installs that haven't refreshed deps we fall through
-    to the stdlib ``datetime.timezone.utc`` as a last resort; it satisfies
-    every ``astimezone`` / ``str()`` call site without needing the IANA DB.
-    """
-    tz_name = os.environ.get("TZ", "").strip()
-    if tz_name:
-        try:
-            return ZoneInfo(tz_name)
-        except ZoneInfoNotFoundError:
-            logger.warning("Unrecognised TZ env value %r, scheduling in UTC", tz_name)
-    try:
-        return ZoneInfo("UTC")
-    except ZoneInfoNotFoundError:
-        return timezone.utc
+# The TZ-env resolution used to live here. It moved to utils/local_time when the
+# smart-plug energy history (#2539) needed the same local day boundary. Re-exported
+# under the old private name so existing importers keep working.
+from backend.app.utils.local_time import local_zone as _local_zone
 
+logger = logging.getLogger(__name__)
 
 SCHEDULE_INTERVALS = {
     "hourly": 3600,
@@ -194,6 +170,15 @@ class LocalBackupService:
             return Path(path_setting.strip())
         return _default_backup_dir()
 
+    def check_path(self, path_setting: str) -> dict:
+        """Probe the configured output directory with a real write.
+
+        Called when the path is saved and when the backup card is opened, so a
+        directory the service cannot write to is caught there and then instead
+        of at 03:00 for a week (#2544).
+        """
+        return probe_backup_dir(self._resolve_backup_dir(path_setting))
+
     async def run_backup(self, settings: dict | None = None) -> dict:
         """Run a backup now. Returns {success, message, filename}."""
         if self._running:
@@ -205,11 +190,22 @@ class LocalBackupService:
                 settings = await self._load_settings()
 
             backup_dir = self._resolve_backup_dir(settings["path"])
-            backup_dir.mkdir(parents=True, exist_ok=True)
 
-            from backend.app.api.routes.settings import create_backup_zip
+            try:
+                backup_dir.mkdir(parents=True, exist_ok=True)
+
+                from backend.app.api.routes.settings import create_backup_zip
 
-            zip_path, filename = await create_backup_zip(output_path=backup_dir)
+                zip_path, filename = await create_backup_zip(output_path=backup_dir)
+            except OSError as e:
+                # A raw "[Errno 30] Read-only file system" sends people off to check
+                # folder permissions, which is exactly where the answer is not (#2544).
+                diagnosis = classify_backup_dir_error(e, backup_dir)
+                self._last_backup_at = datetime.now(timezone.utc).isoformat()
+                self._last_status = "failed"
+                self._last_message = diagnosis["message"]
+                logger.error("Local backup failed: %s (%s)", diagnosis["message"], diagnosis["detail"])
+                return {"success": False, "message": diagnosis["message"], "diagnosis": diagnosis}
 
             # Prune old backups
             retention = max(1, settings["retention"])

+ 37 - 8
backend/app/services/long_lived_tokens.py

@@ -22,6 +22,7 @@ tokens — a leaked permanent token would be irrevocable footgun-by-design).
 from __future__ import annotations
 
 import secrets
+from collections.abc import Collection
 from dataclasses import dataclass
 from datetime import datetime, timedelta, timezone
 
@@ -35,9 +36,26 @@ from backend.app.models.long_lived_token import LongLivedToken
 # (90 days) and the create route enforces this ceiling.
 MAX_TOKEN_LIFETIME_DAYS = 365
 
-# Only V1 scope. Adding "snapshot" or "control" later means adding a value
-# to this tuple and an `if scope == ...` branch in the route, no schema work.
-ALLOWED_SCOPES: frozenset[str] = frozenset({"camera_stream"})
+# Every scope is a separate grant, never implied by another. A token minted for
+# one purpose must not silently widen when a later scope is added.
+#
+#   camera_stream — the MJPEG stream / snapshot endpoints and nothing else
+#                   (#1108). What a Home Assistant or Frigate card needs.
+#   camwall       — those same streams *plus* the read-only tile metadata the
+#                   Cam Wall draws: printer names and print state (#2531).
+#                   Strictly wider than camera_stream, so it gets its own scope
+#                   rather than quietly extending tokens already handed out.
+#   overlay       — the streaming overlay (#2613): the camera stream plus the
+#                   single-printer status the /overlay page draws, which unlike
+#                   the Cam Wall *includes the print filename*. A distinct grant
+#                   precisely because it reveals the part name a camwall token
+#                   is trusted never to expose, so folding it into camwall would
+#                   silently widen every wall token already handed out.
+ALLOWED_SCOPES: frozenset[str] = frozenset({"camera_stream", "camwall", "overlay"})
+
+# Scopes the camera stream / snapshot endpoints honour. A Cam Wall or overlay
+# token has to be able to pull the video its own view is showing.
+STREAM_SCOPES: tuple[str, ...] = ("camera_stream", "camwall", "overlay")
 
 # Don't write to last_used_at more than once per minute per token. MJPEG
 # streams call verify() at most once per fetch (the browser holds the
@@ -142,23 +160,34 @@ async def create_token(
     return CreatedToken(record=record, plaintext=plaintext)
 
 
-async def verify_token(db: AsyncSession, token: str, *, scope: str = "camera_stream") -> LongLivedToken | None:
+async def verify_token(
+    db: AsyncSession,
+    token: str,
+    *,
+    scope: str | Collection[str] = "camera_stream",
+) -> LongLivedToken | None:
     """Validate a token. Returns the matching record on success, None otherwise.
 
-    The bcrypt-style verify is the slow step (intentional — pbkdf2 by design),
-    so we pre-filter by the indexed ``lookup_prefix`` to ensure the verify
-    runs against at most one or two candidate rows.
+    ``scope`` accepts a single scope or a collection of acceptable ones — the
+    stream endpoints pass ``STREAM_SCOPES`` because more than one scope may
+    legitimately reach them. The record must carry one of them; a token is
+    never accepted on the strength of a scope it does not hold.
+
+    The pbkdf2 verify is the slow step (intentional), so we pre-filter by the
+    indexed ``lookup_prefix`` to ensure the verify runs against at most one or
+    two candidate rows.
     """
     parsed = _parse_token(token)
     if parsed is None:
         return None
     lookup_prefix, full_token = parsed
+    scopes = (scope,) if isinstance(scope, str) else tuple(scope)
 
     now = datetime.now(timezone.utc)
     result = await db.execute(
         select(LongLivedToken).where(
             LongLivedToken.lookup_prefix == lookup_prefix,
-            LongLivedToken.scope == scope,
+            LongLivedToken.scope.in_(scopes),
             LongLivedToken.revoked_at.is_(None),
         )
     )

+ 77 - 8
backend/app/services/makerworld.py

@@ -20,11 +20,16 @@ from __future__ import annotations
 import asyncio
 import logging
 import re
+import ssl
+from collections.abc import Awaitable, Callable
 from typing import Any
 from urllib.parse import urlparse
 
+import certifi
 import httpx
 
+from backend.app.services.bambu_cloud import is_expiry_401
+
 logger = logging.getLogger(__name__)
 
 
@@ -58,6 +63,18 @@ _CLIENT_HEADERS = {
     "Referer": "https://makerworld.com/",
 }
 
+# Shown whenever Bambu rejects the stored bearer. Bambu's own 401 body is
+# ``{"code":4,"error":"Please login.","message":""}`` and we used to forward that
+# string verbatim, which surfaced as a "Please login." toast on a UI that was
+# simultaneously reporting the user as connected — maximally confusing, and it
+# named no page to go to. Say what happened and where to fix it. Bambu Cloud
+# sign-in lives on the Profiles page (ProfilesPage.tsx, "Cloud Profiles" tab);
+# there is no Settings → Bambu Cloud page, which is what the old fallback text
+# told people to look for.
+_SIGN_IN_EXPIRED_MESSAGE = (
+    "Your Bambu Cloud sign-in has expired. Open the Profiles page and sign in to Bambu Cloud again."
+)
+
 _MODEL_ID_RE = re.compile(r"/models/(\d+)")
 _PROFILE_ID_RE = re.compile(r"#profileId[-=](\d+)")
 _MAX_3MF_BYTES = 200 * 1024 * 1024  # 200 MB hard cap
@@ -77,6 +94,27 @@ _REFUSED_THUMBNAIL_MIMES = ("text/html", "text/plain", "application/json")
 _shared_http_client: httpx.AsyncClient | None = None
 
 
+def _s3_ssl_context() -> ssl.SSLContext:
+    """Build the TLS context used for the S3 presigned download (#2562).
+
+    ``urllib.request`` verifies against the *OS* trust store, while httpx —
+    every other network call in Bambuddy — verifies against the bundled
+    ``certifi`` CA bundle. On Windows those two disagree: Python's
+    ``ssl.load_default_certs()`` only enumerates the roots already cached in
+    the Windows ROOT store, and Windows populates that store lazily via
+    CryptoAPI's auto-update, which Python never triggers. If the Amazon root
+    signing the S3 chain isn't cached on that machine yet, verification fails
+    with ``unable to get local issuer certificate`` — even though the
+    api.bambulab.com calls that preceded it (httpx) succeeded.
+
+    Pinning urllib to certifi makes the S3 hop trust exactly what the rest of
+    the app already trusts. Built per call rather than at import so a certifi
+    refresh doesn't require a restart; construction is cheap relative to the
+    download that follows.
+    """
+    return ssl.create_default_context(cafile=certifi.where())
+
+
 def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
     """Register an app-scoped ``httpx.AsyncClient`` for service reuse.
 
@@ -127,7 +165,7 @@ async def _download_s3_urllib(url: str, filename_fallback: str) -> tuple[bytes,
     Runs the blocking urllib call in a thread executor so we don't stall
     the event loop.
     """
-    from urllib.request import HTTPRedirectHandler, Request, build_opener
+    from urllib.request import HTTPRedirectHandler, HTTPSHandler, Request, build_opener
 
     # Don't follow redirects: the host allowlist above is only enforced on
     # the initial URL. A 302 from S3 to any other host would otherwise
@@ -136,7 +174,9 @@ async def _download_s3_urllib(url: str, filename_fallback: str) -> tuple[bytes,
         def redirect_request(self, *args, **kwargs):  # type: ignore[override]
             return None
 
-    opener = build_opener(_NoRedirect)
+    # HTTPSHandler swaps only the TLS context — the URL still reaches the
+    # transport verbatim, which is what the S3 signature depends on.
+    opener = build_opener(_NoRedirect, HTTPSHandler(context=_s3_ssl_context()))
 
     def _blocking_fetch() -> bytes:
         req = Request(url, headers={"User-Agent": _CLIENT_HEADERS["User-Agent"]})
@@ -195,7 +235,13 @@ class MakerWorldService:
         self,
         client: httpx.AsyncClient | None = None,
         auth_token: str | None = None,
+        on_auth_failure: Callable[[], Awaitable[None]] | None = None,
     ):
+        # Fired when Bambu rejects the stored token (401). MakerWorld runs on the
+        # same Bambu Cloud bearer as everything else, so a rejection here means
+        # the credential is dead app-wide — see ``build_authenticated_cloud``.
+        self._on_auth_failure = on_auth_failure
+        self._auth_failure_reported = False
         if client is not None:
             self._client = client
             self._owns_client = False
@@ -211,6 +257,26 @@ class MakerWorldService:
         if self._owns_client:
             await self._client.aclose()
 
+    async def _note_auth_failure(self, response: httpx.Response) -> None:
+        """Durably record a dead credential — only for Bambu's genuine expiry 401.
+
+        A MakerWorld 401 without the ``{"code":4,"error":"Please login."}``
+        signature is endpoint- or edge-specific noise, not an expired token;
+        invalidating on it would sign the user out of the whole cloud
+        integration on a single stray rejection (the #2562 follow-up
+        regression). Best-effort, once per service instance.
+        """
+        if not is_expiry_401(response):
+            logger.info("MakerWorld returned 401 without the expiry signature — not signing the stored token out")
+            return
+        if self._on_auth_failure is None or self._auth_failure_reported:
+            return
+        self._auth_failure_reported = True
+        try:
+            await self._on_auth_failure()
+        except Exception:
+            logger.exception("Failed to record Bambu Cloud auth failure from MakerWorld")
+
     def _headers(self) -> dict[str, str]:
         headers = dict(_CLIENT_HEADERS)
         if self._auth_token:
@@ -249,8 +315,13 @@ class MakerWorldService:
         # because the UI remedy is completely different: 401 → re-login,
         # 403 → user has to go to MakerWorld and meet the access requirement.
         if response.status_code == 401:
-            upstream = _extract_upstream_error(response)
-            raise MakerWorldAuthError(upstream or f"MakerWorld rejected the Bambu Cloud token for {path}")
+            if self._auth_token:
+                # We sent a token and Bambu refused it — the credential is dead,
+                # not merely absent. Record that before raising so the rest of the
+                # app stops claiming the user is connected.
+                await self._note_auth_failure(response)
+                raise MakerWorldAuthError(_SIGN_IN_EXPIRED_MESSAGE)
+            raise MakerWorldAuthError(f"Signing in to Bambu Cloud is required for {path}")
         if response.status_code == 403:
             upstream = _extract_upstream_error(response)
             raise MakerWorldForbiddenError(
@@ -410,10 +481,8 @@ class MakerWorldService:
             raise MakerWorldUnavailableError(f"Bambu Lab API request failed: {exc}") from exc
 
         if response.status_code == 401:
-            upstream = _extract_upstream_error(response)
-            raise MakerWorldAuthError(
-                upstream or "Bambu Lab rejected the token — sign in again in Settings → Bambu Cloud"
-            )
+            await self._note_auth_failure(response)
+            raise MakerWorldAuthError(_SIGN_IN_EXPIRED_MESSAGE)
         if response.status_code == 403:
             upstream = _extract_upstream_error(response)
             raise MakerWorldForbiddenError(upstream or f"Bambu Lab refused access to profile {profile_id}")

+ 57 - 17
backend/app/services/notification_service.py

@@ -334,7 +334,10 @@ class NotificationService:
         """
         user_key = config.get("user_key", "").strip()
         app_token = config.get("app_token", "").strip()
-        priority = config.get("priority", 0)
+        try:
+            priority = int(config.get("priority", 0))
+        except (TypeError, ValueError):
+            priority = 0
 
         if not user_key or not app_token:
             return False, "User key and app token are required"
@@ -348,6 +351,22 @@ class NotificationService:
             "priority": priority,
         }
 
+        # Emergency priority (2) keeps re-alerting until acknowledged, so
+        # Pushover *requires* retry (how often, >= 30s) and expire (when to
+        # give up, <= 10800s). Without them the API rejects the message. Only
+        # send them at priority 2 — Pushover ignores them at other priorities.
+        if priority == 2:
+            try:
+                retry = int(config.get("retry", 60))
+            except (TypeError, ValueError):
+                retry = 60
+            try:
+                expire = int(config.get("expire", 3600))
+            except (TypeError, ValueError):
+                expire = 3600
+            data["retry"] = max(30, min(retry, 10800))
+            data["expire"] = max(30, min(expire, 10800))
+
         client = await self._get_client()
 
         if image_data:
@@ -493,22 +512,43 @@ class NotificationService:
                 msg["Subject"] = f"[Bambuddy] {subject}"
                 msg.attach(MIMEText(body, "plain"))
 
-            if security == "ssl":
-                # Direct SSL connection (typically port 465)
-                server = smtplib.SMTP_SSL(smtp_server, smtp_port)
-            elif security == "starttls":
-                # STARTTLS upgrade (typically port 587)
-                server = smtplib.SMTP(smtp_server, smtp_port)
-                server.starttls()
-            else:
-                # No encryption (typically port 25) - use with caution
-                server = smtplib.SMTP(smtp_server, smtp_port)
-
-            if auth_enabled:
-                server.login(username, password)
-
-            server.sendmail(from_email, to_email, msg.as_string())
-            server.quit()
+            # smtplib is synchronous and blocking: a wedged / greylisting /
+            # firewall-dropped relay leaves recv() stuck. Two problems, two
+            # fixes (#2572):
+            #   1. No timeout — smtplib defaults to the global socket timeout,
+            #      which this app never sets, so a stuck relay blocks forever.
+            #      Pass an explicit timeout to every connect.
+            #   2. Run on the event loop — a stuck (or merely slow) send freezes
+            #      every other coroutine, including a DB session a caller is
+            #      holding open across this notification. Offload to a worker
+            #      thread so the loop stays live and the connection is released
+            #      on schedule.
+            smtp_timeout = 30.0
+            msg_str = msg.as_string()
+
+            def _blocking_send() -> None:
+                if security == "ssl":
+                    # Direct SSL connection (typically port 465)
+                    server = smtplib.SMTP_SSL(smtp_server, smtp_port, timeout=smtp_timeout)
+                elif security == "starttls":
+                    # STARTTLS upgrade (typically port 587)
+                    server = smtplib.SMTP(smtp_server, smtp_port, timeout=smtp_timeout)
+                    server.starttls()
+                else:
+                    # No encryption (typically port 25) - use with caution
+                    server = smtplib.SMTP(smtp_server, smtp_port, timeout=smtp_timeout)
+                try:
+                    if auth_enabled:
+                        server.login(username, password)
+                    server.sendmail(from_email, to_email, msg_str)
+                finally:
+                    # quit() in finally so a send error doesn't leak the socket.
+                    try:
+                        server.quit()
+                    except Exception:  # noqa: BLE001 — closing a broken connection is best-effort
+                        pass
+
+            await asyncio.to_thread(_blocking_send)
 
             return True, "Email sent successfully"
         except smtplib.SMTPAuthenticationError:

+ 235 - 279
backend/app/services/orca_cloud.py

@@ -1,33 +1,42 @@
 """
 Orca Cloud API Service
 
-Handles authentication and profile sync with the Orca Cloud (Supabase-backed).
-
-Auth shape: PKCE flow against ``auth.orcaslicer.com`` with the in-source public
-publishable key. Bambuddy generates the verifier/challenge/state, redirects the
-user's browser to Supabase's ``/auth/v1/authorize`` endpoint with
-``redirect_to=http://localhost:41172/callback``, and the user pastes the
-callback URL back into Bambuddy (the loopback URL is the only ``redirect_to``
-Orca's Supabase project actually honors as of v2.4.0-alpha — see
-OrcaSlicer/OrcaSlicer#14028 for the open feature request asking SoftFever to
-broaden this).
-
-Token shape: short-lived access JWT (1h) + rotating single-use refresh token.
-Every refresh issues a new pair and invalidates the old one — the route layer
-is responsible for atomically swapping the stored pair on each refresh, or a
-mid-refresh crash strands the user.
-
-Cloudflare protects ``api.orcaslicer.com`` with a User-Agent gate; sending an
-honest ``Bambuddy/<version>`` UA clears it. No TLS-fingerprint matching needed.
+Handles pairing and profile sync with the Orca Cloud external-app surface.
+
+Auth shape: OAuth 2.0 Device Authorization Grant (RFC 8628). Bambuddy is a
+public client (``client_id`` only, no secret) — there is no redirect URL, so
+the flow works from a LAN IP, ``localhost``, or behind a reverse proxy. The
+user approves a short ``user_code`` in their Orca Cloud settings; Bambuddy
+polls the token endpoint until a token pair is issued.
+
+    POST /oauth/device/code   -> {device_code, user_code, verification_uri,
+                                  verification_uri_complete, expires_in, interval}
+    POST /oauth/token         -> poll with grant_type=device_code, then later
+                                  refresh with grant_type=refresh_token
+
+Token shape: opaque ``oc_ext_`` access token (24h) + single-use rotating
+``oc_ext_rt_`` refresh token (90-day, renewed on each rotation). Reuse of a
+consumed refresh token beyond a ~60s server-side grace window revokes the
+whole pairing, so the route layer MUST persist the new pair atomically with
+consuming the old one. Within the grace window a lost refresh race is a no-op
+(each racer gets its own fresh pair), so single-flighting is hygiene, not a
+correctness requirement.
+
+API surface: ``oc_ext_`` tokens authorize ONLY the ``/api/v1/external/*``
+endpoints (introspection + ``/external/sync/*``). The first-party
+``/api/v1/sync/*`` surface used by the old Supabase flow is NOT reachable with
+these tokens.
+
+Cloudflare fronts ``api.orcaslicer.com`` and blocks unusual User-Agents
+(``python-urllib`` gets a ``403 "error code: 1010"``); an honest
+``Bambuddy/<version>`` UA clears it. No TLS-fingerprint matching needed.
 """
 
 from __future__ import annotations
 
-import base64
-import hashlib
 import json
 import logging
-import secrets
+import os
 from datetime import datetime, timedelta, timezone
 from typing import Any
 
@@ -35,45 +44,82 @@ import httpx
 
 logger = logging.getLogger(__name__)
 
-# Auth + API endpoints — extracted verbatim from OrcaCloudServiceAgent.cpp
-# v2.4.0-alpha. The "publishable" key is documented in-source as a public
-# client identifier (Supabase anon-key pattern); embedding it in our client
-# is by-design and not a secret leak.
-ORCA_AUTH_BASE = "https://auth.orcaslicer.com"
-ORCA_API_BASE = "https://api.orcaslicer.com"
-ORCA_ANON_KEY = "sb_publishable_lvVe_whOi80SU9BPSxM1kA_tbt9AbR_"
-
-# Loopback redirect from OrcaCloudServiceAgent.cpp. Supabase's redirect_to
-# allowlist on Orca's project only honors localhost URIs — anything else
-# silently falls through to the project Site URL after the OAuth dance.
-ORCA_REDIRECT_URI = "http://localhost:41172/callback"
-
-# Honest client identity. Same posture as Bambu Cloud: identifies Bambuddy
-# without impersonating Orca's desktop client (which would be CWE-style
-# falsified-identity and was the exact thing called out in Bambu Lab's May 2026
-# blog post about cloud-access etiquette).
+# ---------------------------------------------------------------------------
+# Endpoints + client identity (env-overridable so staging can be targeted
+# without a code change). Defaults point at production.
+# ---------------------------------------------------------------------------
+
+_DEFAULT_API_BASE = "https://api.orcaslicer.com"
+
+# Base for both the OAuth endpoints (/oauth/*) and the external API
+# (/api/v1/external/*). Override with ORCA_CLOUD_API_BASE to point at
+# staging (https://staging-api.orcaslicer.com) during testing.
+ORCA_API_BASE = os.environ.get("ORCA_CLOUD_API_BASE", _DEFAULT_API_BASE).rstrip("/")
+
+# Public client id registered with the Orca Cloud team (see the External App
+# Pairing developer guide). Not a secret — it appears in browser-visible
+# requests — but it must accompany every /oauth/device/code and /oauth/token
+# call (incl. refreshes) or the server returns ``invalid_client``. Overridable
+# only for the (unlikely) case of a separate staging registration.
+ORCA_CLIENT_ID = os.environ.get("ORCA_CLOUD_CLIENT_ID", "oc_app_e873d49ce7dbcc7dca8ba386")
+
+# Scope requested at pairing time. Bambuddy currently only READS the user's
+# Orca Cloud profiles (list + view), so we request the minimum — read-only.
+# ``sync:read`` grants pull + versions; bump to ``sync:write`` here if/when a
+# push-to-cloud feature lands (which forces existing users to re-pair, since
+# the granted scope is baked into the issued token).
+ORCA_SCOPE = os.environ.get("ORCA_CLOUD_SCOPE", "sync:read")
+
+# Honest client identity. Same posture as the Bambu Cloud client: identifies
+# Bambuddy without impersonating Orca's desktop client. Also the thing that
+# clears Cloudflare's User-Agent gate in front of the API.
 _USER_AGENT = "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)"
 
-# Refresh access tokens when they have less than this much life left, on the
-# theory that a slow downstream API call shouldn't expire the token mid-flight.
+# Refresh the access token when it has less than this much life left, so a
+# slow downstream API call doesn't expire the token mid-flight.
 _REFRESH_LEEWAY = timedelta(minutes=5)
 
-# PKCE handshake state TTL. If the user clicks "Connect" then walks away,
-# the stored verifier+state is invalid after this window — they have to
-# restart. 10 minutes is the OAuth norm for desktop-app PKCE flows.
-PENDING_PKCE_TTL = timedelta(minutes=10)
+# How long a device-code pairing attempt stays valid before the user must
+# restart. The server also enforces this (``expires_in`` on the device-code
+# response is 600s); we mirror it client-side so we stop polling a dead code.
+DEVICE_CODE_TTL = timedelta(minutes=10)
+
+
+# ---------------------------------------------------------------------------
+# Device-poll outcomes
+# ---------------------------------------------------------------------------
+
+
+class DevicePoll:
+    """String outcomes of one :meth:`OrcaCloudService.poll_token` attempt.
+
+    ``PENDING`` / ``SLOW_DOWN`` are non-terminal (keep polling; on SLOW_DOWN
+    widen the interval). ``DENIED`` / ``EXPIRED`` are terminal — the pairing
+    attempt is dead and the user must restart. ``COMPLETE`` means tokens were
+    issued and applied to the service."""
+
+    PENDING = "authorization_pending"
+    SLOW_DOWN = "slow_down"
+    DENIED = "access_denied"
+    EXPIRED = "expired_token"
+    COMPLETE = "complete"
+
+    #: Non-terminal — the frontend should poll again.
+    ONGOING = frozenset({PENDING, SLOW_DOWN})
+    #: Terminal failure — the frontend should restart the flow.
+    TERMINAL = frozenset({DENIED, EXPIRED})
 
 
 class OrcaCloudError(Exception):
-    """Base exception for Orca Cloud errors."""
+    """Base exception for Orca Cloud errors (network / unexpected server)."""
 
     pass
 
 
 class OrcaCloudAuthError(OrcaCloudError):
-    """Authentication / token-related errors. Caller should typically prompt
-    the user to reconnect — neither a fresh access token nor a refresh will
-    recover without re-authentication."""
+    """Authentication / token-related errors. The caller should typically
+    prompt the user to reconnect — neither a fresh access token nor a refresh
+    will recover without re-pairing."""
 
     pass
 
@@ -89,89 +135,18 @@ def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
     _shared_http_client = client
 
 
-# ---------------------------------------------------------------------------
-# PKCE helpers (free functions — no service-instance state needed)
-# ---------------------------------------------------------------------------
-
-
-def _b64url(data: bytes) -> str:
-    """RFC 7636-style base64url encoding, no padding."""
-    return base64.urlsafe_b64encode(data).decode().rstrip("=")
-
-
-def generate_pkce() -> tuple[str, str, str]:
-    """Generate a fresh ``(verifier, challenge, state)`` triple for one PKCE
-    handshake. The verifier is the secret kept by Bambuddy until the code
-    exchange; the challenge is sent to Supabase as ``code_challenge``; the
-    state is the CSRF nonce we'll verify against the callback.
-
-    Verifier = 32 random bytes (43 base64url chars), within RFC 7636's
-    43-128 char range. Challenge = ``base64url(sha256(verifier))``.
-    """
-    verifier = _b64url(secrets.token_bytes(32))
-    challenge = _b64url(hashlib.sha256(verifier.encode()).digest())
-    state = _b64url(secrets.token_bytes(16))
-    return verifier, challenge, state
-
-
-def build_authorize_url(challenge: str, provider: str = "google") -> str:
-    """Construct the URL the user's browser should visit to start the OAuth
-    handshake.
-
-    Notably **does not** pass a ``state`` query parameter. Supabase's GoTrue
-    uses its own internal state encoding to remember which ``redirect_to``
-    belongs to which OAuth session; a client-passed ``state`` overwrites
-    that, GoTrue can no longer decode the redirect_to from Google's
-    callback, and silently falls back to the project Site URL — which is
-    exactly the bug that broke the live test against our deployed integration.
-
-    CSRF is still protected by the PKCE flow itself: the server-side
-    ``code_verifier`` is single-use and bound to the user's session, so an
-    attacker with a code-only URL can't complete the exchange.
-    """
-    from urllib.parse import urlencode
-
-    qs = urlencode(
-        {
-            "provider": provider,
-            "redirect_to": ORCA_REDIRECT_URI,
-            "code_challenge": challenge,
-            "code_challenge_method": "S256",
-        }
-    )
-    return f"{ORCA_AUTH_BASE}/auth/v1/authorize?{qs}"
-
-
-def parse_callback_url(callback_url: str) -> tuple[str | None, str | None]:
-    """Extract ``(code, state)`` from a pasted callback URL. Both query string
-    and fragment are checked — some Supabase configurations put PKCE codes in
-    the fragment rather than the query string. Returns ``(None, None)`` if
-    nothing parses out; the route layer surfaces the user-facing error."""
-    from urllib.parse import parse_qs, urlparse
-
-    parsed = urlparse(callback_url.strip())
-    qsd = parse_qs(parsed.query)
-    code = qsd.get("code", [""])[0] or None
-    state = qsd.get("state", [""])[0] or None
-    if not code:
-        frag = parse_qs(parsed.fragment)
-        code = frag.get("code", [""])[0] or None
-        state = state or (frag.get("state", [""])[0] or None)
-    return code, state
-
-
 # ---------------------------------------------------------------------------
 # Service class
 # ---------------------------------------------------------------------------
 
 
 class OrcaCloudService:
-    """Stateful per-request client for the Orca Cloud API.
+    """Stateful per-request client for the Orca Cloud external API.
 
     Instantiated by the route layer, populated with a stored token via
     :meth:`set_tokens`, then used to call the sync endpoints. Token rotation
-    on refresh is the route layer's responsibility (see
-    :meth:`refresh` — returns the new pair, doesn't persist).
+    on refresh is the route layer's responsibility (see :meth:`refresh` —
+    mutates ``self`` and returns the new pair, but does NOT persist).
     """
 
     def __init__(self, client: httpx.AsyncClient | None = None):
@@ -227,136 +202,124 @@ class OrcaCloudService:
         self.refresh_token = None
         self.token_expiry = None
 
-    def _auth_headers(self) -> dict[str, str]:
-        """Headers for calls to ``auth.orcaslicer.com``. Always includes the
-        apikey; the ``Authorization`` header is added only if we already have
-        an access token (used by ``/logout``, not by token exchange)."""
-        headers = {
-            "User-Agent": _USER_AGENT,
-            "apikey": ORCA_ANON_KEY,
-            "Content-Type": "application/json",
-        }
-        if self.access_token:
-            headers["Authorization"] = f"Bearer {self.access_token}"
-        return headers
-
     def _api_headers(self) -> dict[str, str]:
-        """Headers for calls to ``api.orcaslicer.com``. Requires a bearer
-        token — callers should ensure the service is authenticated first."""
+        """Headers for calls to the external API. Requires a bearer token —
+        callers should ensure the service is authenticated first."""
         if not self.access_token:
             raise OrcaCloudAuthError("Orca Cloud API requires an access token")
         return {
             "User-Agent": _USER_AGENT,
-            "apikey": ORCA_ANON_KEY,
             "Authorization": f"Bearer {self.access_token}",
             "Accept": "application/json",
         }
 
     # ------------------------------------------------------------------
-    # Token lifecycle
+    # Device authorization grant (RFC 8628)
     # ------------------------------------------------------------------
 
-    async def password_login(self, email: str, password: str) -> dict[str, Any]:
-        """Direct email+password login via ``/auth/v1/token?grant_type=password``.
-
-        Whether this works depends on the Supabase project's auth config —
-        Orca's web sign-in offers email/password as one option, but their
-        desktop client refuses ``{username, password}`` payloads with
-        ``"Username/password login is disabled. Use the Orca cloud PKCE
-        flow."`` (the SDK enforces PKCE regardless of what the backend
-        allows). The actual server behaviour is what matters for Bambuddy
-        — we POST the credentials and surface whatever response we get;
-        an ``OrcaCloudAuthError`` with the verbatim Supabase error message
-        is the right signal for callers to fall back to an OAuth provider.
-        """
-        url = f"{ORCA_AUTH_BASE}/auth/v1/token?grant_type=password"
-        payload = {"email": email, "password": password}
+    async def request_device_code(
+        self,
+        scope: str = ORCA_SCOPE,
+        instance_url: str | None = None,
+        instance_label: str | None = None,
+    ) -> dict[str, Any]:
+        """Start a pairing attempt. Returns the raw device-code response
+        (``device_code``, ``user_code``, ``verification_uri``,
+        ``verification_uri_complete``, ``expires_in``, ``interval``).
+
+        ``instance_url`` / ``instance_label`` are display-only fields shown on
+        the user's approval card (anti-phishing context). The ``device_code``
+        is a secret the caller must keep server-side; only ``user_code`` and
+        the verification URIs are safe to show the user."""
+        url = f"{ORCA_API_BASE}/oauth/device/code"
+        form: dict[str, str] = {"client_id": ORCA_CLIENT_ID, "scope": scope}
+        if instance_url:
+            form["instance_url"] = instance_url
+        if instance_label:
+            form["instance_label"] = instance_label
         try:
-            resp = await self._client.post(
-                url,
-                json=payload,
-                headers={
-                    "User-Agent": _USER_AGENT,
-                    "apikey": ORCA_ANON_KEY,
-                    "Content-Type": "application/json",
-                },
-            )
+            resp = await self._client.post(url, data=form, headers={"User-Agent": _USER_AGENT})
         except httpx.HTTPError as e:
-            raise OrcaCloudError(f"Network error during Orca Cloud password login: {e}") from e
+            raise OrcaCloudError(f"Network error requesting Orca Cloud device code: {e}") from e
 
         if resp.status_code >= 400:
             detail = _describe_token_error(resp)
-            if resp.status_code in (400, 401, 403, 422):
-                raise OrcaCloudAuthError(f"Orca Cloud password login rejected: {detail}")
-            raise OrcaCloudError(f"Orca Cloud password login failed ({resp.status_code}): {detail}")
-
-        data = resp.json()
-        self._apply_token_response(data)
-        return data
+            # invalid_client means our client_id is wrong / unregistered — an
+            # operator misconfiguration, not something the user can fix.
+            if resp.status_code in (400, 401, 403):
+                raise OrcaCloudAuthError(f"Orca Cloud rejected the device-code request: {detail}")
+            raise OrcaCloudError(f"Orca Cloud device-code request failed ({resp.status_code}): {detail}")
+        return resp.json()
 
-    async def exchange_code(self, auth_code: str, code_verifier: str) -> dict[str, Any]:
-        """Exchange a PKCE auth code for tokens. Mutates ``self`` so the
-        service is ready for API calls. Returns the raw Supabase token
-        response so the route layer can persist the new credentials."""
-        url = f"{ORCA_AUTH_BASE}/auth/v1/token?grant_type=pkce"
-        payload = {"auth_code": auth_code, "code_verifier": code_verifier}
+    async def poll_token(self, device_code: str) -> tuple[str, dict[str, Any] | None]:
+        """Poll the token endpoint once for a pending device-code grant.
+
+        Returns ``(status, data)`` where ``status`` is a :class:`DevicePoll`
+        value. On :data:`DevicePoll.COMPLETE` the service is mutated with the
+        new tokens and ``data`` is the raw token response (so the caller can
+        persist it); otherwise ``data`` is ``None``.
+
+        Raises :class:`OrcaCloudError` only for genuinely unexpected responses
+        (5xx, network, or an unrecognized error code) — the four RFC error
+        codes are returned as statuses, not raised, because they're normal
+        control flow for a polling loop."""
+        url = f"{ORCA_API_BASE}/oauth/token"
+        form = {
+            "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
+            "device_code": device_code,
+            "client_id": ORCA_CLIENT_ID,
+        }
         try:
-            resp = await self._client.post(
-                url,
-                json=payload,
-                headers={
-                    "User-Agent": _USER_AGENT,
-                    "apikey": ORCA_ANON_KEY,
-                    "Content-Type": "application/json",
-                },
-            )
+            resp = await self._client.post(url, data=form, headers={"User-Agent": _USER_AGENT})
         except httpx.HTTPError as e:
-            raise OrcaCloudError(f"Network error during Orca Cloud token exchange: {e}") from e
-
-        if resp.status_code >= 400:
-            # Supabase returns ``{"error":"...", "error_description":"..."}``
-            # on most failures and ``{"msg":"..."}`` on a few. Surface
-            # whatever we can find.
-            detail = _describe_token_error(resp)
-            if resp.status_code in (400, 401, 403):
-                raise OrcaCloudAuthError(f"Orca Cloud token exchange rejected: {detail}")
-            raise OrcaCloudError(f"Orca Cloud token exchange failed ({resp.status_code}): {detail}")
-
-        data = resp.json()
-        self._apply_token_response(data)
-        return data
+            raise OrcaCloudError(f"Network error polling Orca Cloud token endpoint: {e}") from e
+
+        if resp.status_code < 400:
+            data = resp.json()
+            self._apply_token_response(data)
+            return DevicePoll.COMPLETE, data
+
+        # RFC 8628 error bodies: {"error": "authorization_pending" | ...}.
+        error = _error_code(resp)
+        if error == "authorization_pending":
+            return DevicePoll.PENDING, None
+        if error == "slow_down":
+            return DevicePoll.SLOW_DOWN, None
+        if error == "access_denied":
+            return DevicePoll.DENIED, None
+        # expired_token and invalid_grant both mean "this device code is dead,
+        # start over" — collapse them to a single terminal EXPIRED status.
+        if error in ("expired_token", "invalid_grant"):
+            return DevicePoll.EXPIRED, None
+        raise OrcaCloudError(f"Orca Cloud token poll failed ({resp.status_code}): {_describe_token_error(resp)}")
 
     async def refresh(self) -> dict[str, Any]:
         """Use the stored refresh token to obtain a fresh access/refresh pair.
 
-        Supabase issues single-use refresh tokens — the old refresh token is
-        invalidated the moment this call succeeds. The caller MUST persist the
-        new pair atomically with consuming the old one; otherwise a crash
-        between this return and the DB write strands the user. Returns the
-        raw token-response dict so the caller has the full new pair.
-        """
+        Refresh tokens are single-use — the old one is consumed the moment
+        this succeeds. The caller MUST persist the new pair atomically; a
+        crash between this return and the DB write strands the user (though
+        Orca's ~60s grace window means a *replay* of the old token within that
+        window still yields a working pair rather than revoking). Returns the
+        raw token-response dict so the caller has the full new pair."""
         if not self.refresh_token:
             raise OrcaCloudAuthError("Cannot refresh: no refresh token stored")
 
-        url = f"{ORCA_AUTH_BASE}/auth/v1/token?grant_type=refresh_token"
-        payload = {"refresh_token": self.refresh_token}
+        url = f"{ORCA_API_BASE}/oauth/token"
+        form = {
+            "grant_type": "refresh_token",
+            "refresh_token": self.refresh_token,
+            "client_id": ORCA_CLIENT_ID,
+        }
         try:
-            resp = await self._client.post(
-                url,
-                json=payload,
-                headers={
-                    "User-Agent": _USER_AGENT,
-                    "apikey": ORCA_ANON_KEY,
-                    "Content-Type": "application/json",
-                },
-            )
+            resp = await self._client.post(url, data=form, headers={"User-Agent": _USER_AGENT})
         except httpx.HTTPError as e:
             raise OrcaCloudError(f"Network error during Orca Cloud refresh: {e}") from e
 
         if resp.status_code >= 400:
             detail = _describe_token_error(resp)
-            # 400/401 typically means "refresh token rotated or revoked" —
-            # the user has to reconnect. Don't try to recover here.
+            # 400 invalid_grant on refresh = expired / already-used / the user
+            # disconnected us. Unrecoverable — clear and force a re-pair.
             if resp.status_code in (400, 401, 403):
                 self.clear_tokens()
                 raise OrcaCloudAuthError(f"Orca Cloud refresh rejected: {detail}")
@@ -368,17 +331,17 @@ class OrcaCloudService:
 
     def _apply_token_response(self, data: dict[str, Any]) -> None:
         """Update ``self.access_token`` / ``self.refresh_token`` /
-        ``self.token_expiry`` from a Supabase token-response payload. Caller
-        is still responsible for persisting the values to the DB."""
+        ``self.token_expiry`` from a token-response payload. Caller is still
+        responsible for persisting the values to the DB."""
         access = data.get("access_token")
         refresh = data.get("refresh_token")
         expires_in = data.get("expires_in")
         if not access:
             raise OrcaCloudAuthError("Orca Cloud token response missing access_token")
         self.access_token = access
-        # Supabase always rotates refresh tokens on /token calls; if the
-        # response omits one we keep the previous value to avoid stranding
-        # the session, but that shouldn't happen in practice.
+        # The token endpoint always rotates the refresh token; if a response
+        # omits one we keep the previous value to avoid stranding the session,
+        # but that shouldn't happen in practice.
         if refresh:
             self.refresh_token = refresh
         if isinstance(expires_in, (int, float)) and expires_in > 0:
@@ -387,64 +350,46 @@ class OrcaCloudService:
             self.token_expiry = None
 
     # ------------------------------------------------------------------
-    # Sync API
+    # External API
     # ------------------------------------------------------------------
 
-    async def get_user_info(self) -> dict[str, Any]:
-        """Return Supabase's user record for the current token (id, email,
-        metadata, ...). Used after token exchange to record the user's email
-        for display in Bambuddy's UI."""
-        url = f"{ORCA_AUTH_BASE}/auth/v1/user"
+    async def introspect(self) -> dict[str, Any]:
+        """Return the pairing's introspection record (``user_id``,
+        ``client_id``, ``connection_id``, ``scope``, ``expires_at``). Used
+        after pairing to record the user's id for display in Bambuddy's UI."""
+        url = f"{ORCA_API_BASE}/api/v1/external-apps/me"
         try:
-            resp = await self._client.get(url, headers=self._auth_headers())
+            resp = await self._client.get(url, headers=self._api_headers())
         except httpx.HTTPError as e:
-            raise OrcaCloudError(f"Network error fetching Orca Cloud user info: {e}") from e
+            raise OrcaCloudError(f"Network error fetching Orca Cloud introspection: {e}") from e
         if resp.status_code == 401:
-            raise OrcaCloudAuthError("Orca Cloud user fetch unauthorized — token expired or revoked")
+            raise OrcaCloudAuthError("Orca Cloud introspection unauthorized — token expired or revoked")
         if resp.status_code >= 400:
-            raise OrcaCloudError(f"Orca Cloud user fetch failed ({resp.status_code}): {resp.text[:200]}")
+            raise OrcaCloudError(f"Orca Cloud introspection failed ({resp.status_code}): {resp.text[:200]}")
         return resp.json()
 
     async def list_profiles(self) -> list[dict[str, Any]]:
-        """Return the user's Orca Cloud profiles as a flat list of
-        ``ProfileUpsert`` entries (``{id, name, content, updated_time,
-        created_time}``) — forwarded verbatim; callers pick the fields they
-        need.
-
-        Uses ``GET /api/v1/sync/pull`` with NO ``?cursor=`` parameter, which
-        is the same "first-sync bootstrap" path OrcaSlicer's own client
-        uses (``OrcaCloudServiceAgent.cpp::sync_pull``):
-
-            std::string path = ORCA_SYNC_PULL_PATH;
-            if (sync_state.last_sync_timestamp != 0) {
-                path += "?cursor=" + std::to_string(sync_state.last_sync_timestamp);
-            }
-            ...
-            // Handle 410 Gone — cursor too old, need full resync
-            if (http_code == 410) {
-                clear_sync_state();
-                path = ORCA_SYNC_PULL_PATH;  // retry without cursor
-                ...
-            }
-
-        Sending ``cursor=0`` explicitly trips ``410 cursor_too_old`` — the
-        server-side sync log doesn't reach back to the Unix epoch. Omitting
-        the parameter entirely is the documented "give me the full snapshot"
-        semantic. The previously-attempted ``/api/v1/sync/profiles`` is
-        declared as a constant in Orca's source but isn't deployed on the
-        production cloud (returns 404).
-
-        The pull response is a ``SyncPullResponse`` (``{next_cursor, upserts,
-        deletes}``); we extract ``upserts`` and ignore ``deletes`` (no prior
-        state on the client side to invalidate).
-        """
-        url = f"{ORCA_API_BASE}/api/v1/sync/pull"
+        """Return the user's Orca Cloud profiles as a flat list of profile
+        entries (``{id, name, content, updated_time, created_time}``) —
+        forwarded verbatim; callers pick the fields they need.
+
+        Uses ``GET /api/v1/external/sync/pull`` with NO ``?cursor=`` parameter,
+        the documented "full snapshot" bootstrap. Sending ``cursor=0`` instead
+        trips ``410 cursor_too_old`` (the sync log doesn't reach back to the
+        Unix epoch). The pull response is ``{next_cursor, upserts, deletes}``;
+        we return ``upserts`` and ignore the rest (no prior client state to
+        invalidate on a read-only list)."""
+        url = f"{ORCA_API_BASE}/api/v1/external/sync/pull"
         try:
             resp = await self._client.get(url, headers=self._api_headers())
         except httpx.HTTPError as e:
             raise OrcaCloudError(f"Network error listing Orca Cloud profiles: {e}") from e
         if resp.status_code == 401:
             raise OrcaCloudAuthError("Orca Cloud profile list unauthorized — token expired or revoked")
+        if resp.status_code == 410:
+            # cursor_too_old on a no-cursor request would be surprising, but
+            # surface it clearly rather than as an opaque 502.
+            raise OrcaCloudError("Orca Cloud sync cursor too old — a full resync is required")
         if resp.status_code >= 400:
             raise OrcaCloudError(f"Orca Cloud profile list failed ({resp.status_code}): {resp.text[:200]}")
         data = resp.json()
@@ -452,24 +397,21 @@ class OrcaCloudService:
             upserts = data.get("upserts")
             if isinstance(upserts, list):
                 return upserts
-            # Tolerate the shape we'd see if Orca ever rolls out a flat-list
-            # endpoint at this path — forward whatever array is on the dict.
+            # Tolerate a flat-list shape if Orca ever rolls one out here.
             for key in ("profiles", "data"):
                 value = data.get(key)
                 if isinstance(value, list):
                     return value
         if isinstance(data, list):
             return data
-        logger.warning("Orca Cloud /sync/pull returned unexpected shape: %r", type(data).__name__)
+        logger.warning("Orca Cloud /external/sync/pull returned unexpected shape: %r", type(data).__name__)
         return []
 
     async def get_profile(self, profile_id: str) -> dict[str, Any]:
-        """Fetch a single profile's full content. Orca's sync API doesn't
-        expose a per-profile GET, so we list and filter. For small profile
-        counts (the realistic case) this is fine; if it becomes a hot path
-        we'll add client-side caching at the route layer rather than hammer
-        the list endpoint.
-        """
+        """Fetch a single profile's full content. The external sync API has no
+        per-profile GET, so we list and filter. For the realistic profile
+        counts this is fine; if it becomes a hot path we'll add caching at the
+        route layer rather than hammer the pull endpoint."""
         profiles = await self.list_profiles()
         for profile in profiles:
             if str(profile.get("id")) == str(profile_id):
@@ -487,10 +429,24 @@ class OrcaCloudService:
             await self._client.aclose()
 
 
+def _error_code(resp: httpx.Response) -> str | None:
+    """Extract the RFC-style ``error`` code from a token-endpoint error body,
+    or ``None`` if the body doesn't parse as ``{"error": "..."}``."""
+    try:
+        data = resp.json()
+    except (json.JSONDecodeError, ValueError):
+        return None
+    if isinstance(data, dict):
+        err = data.get("error")
+        if isinstance(err, str) and err:
+            return err
+    return None
+
+
 def _describe_token_error(resp: httpx.Response) -> str:
-    """Best-effort extraction of a user-facing message from a Supabase token
-    endpoint error response. Tries JSON fields in order; falls back to the
-    raw body (truncated) if nothing parses."""
+    """Best-effort extraction of a user-facing message from a token-endpoint
+    error response. Tries JSON fields in order; falls back to the raw body
+    (truncated) if nothing parses."""
     try:
         data = resp.json()
     except (json.JSONDecodeError, ValueError):

+ 119 - 0
backend/app/services/plug_energy_history.py

@@ -0,0 +1,119 @@
+"""Derive Today / Yesterday from a smart plug's lifetime energy counter (#2539).
+
+Most plugs report exactly one energy number, and it is a lifetime counter: a
+Shelly's ``aenergy.total`` only ever climbs. Only Tasmota reports Today and
+Yesterday itself. So for everything else, those two numbers have to be computed
+from the difference between the counter now and the counter at a day boundary —
+which is what the hourly ``smart_plug_energy_snapshots`` rows (#941) already
+record.
+
+    today     = live_total  - counter at the most recent local midnight
+    yesterday = that midnight's counter - the previous midnight's counter
+
+Two things this is careful about:
+
+* **Local midnight, not UTC midnight.** With ``TZ=Europe/Berlin`` a UTC day
+  boundary rolls "Today" over at 01:00 or 02:00 wall-clock, which matches
+  neither what the user sees nor what the plug's own daily counter would do.
+
+* **Counters reset.** A factory reset or some firmware updates zero a Shelly's
+  ``aenergy.total``. The delta then goes negative, and a negative kWh reading is
+  worse than an absent one — so we return None and let the UI show a blank
+  rather than a number that is definitely wrong.
+"""
+
+from __future__ import annotations
+
+import logging
+from datetime import datetime, timezone
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
+from backend.app.utils.local_time import local_day_start, to_naive_utc
+
+logger = logging.getLogger(__name__)
+
+
+async def _counter_at(db: AsyncSession, plug_id: int, boundary: datetime) -> float | None:
+    """The plug's lifetime counter as of ``boundary`` — i.e. the last snapshot
+    taken at or before it. None when the plug has no snapshot that far back,
+    which is the normal state of a fresh install or a fresh upgrade.
+    """
+    result = await db.execute(
+        select(SmartPlugEnergySnapshot.lifetime_kwh)
+        .where(
+            SmartPlugEnergySnapshot.plug_id == plug_id,
+            SmartPlugEnergySnapshot.recorded_at <= to_naive_utc(boundary),
+        )
+        .order_by(SmartPlugEnergySnapshot.recorded_at.desc())
+        .limit(1)
+    )
+    return result.scalar_one_or_none()
+
+
+async def derive_today_yesterday(
+    db: AsyncSession,
+    plug_id: int,
+    live_total_kwh: float,
+    *,
+    now_utc: datetime | None = None,
+) -> tuple[float | None, float | None]:
+    """Return ``(today_kwh, yesterday_kwh)`` derived from the lifetime counter.
+
+    Either or both may be None while the snapshot history is still filling up:
+    Today needs one snapshot from before this local midnight (so it is available
+    within an hour of the first boundary the install lives through), Yesterday
+    needs one from before the midnight before that.
+    """
+    now = now_utc or datetime.now(timezone.utc)
+    midnight_today = local_day_start(now)
+    midnight_yesterday = local_day_start(now, days_ago=1)
+
+    base_today = await _counter_at(db, plug_id, midnight_today)
+    if base_today is None:
+        # No snapshot from before today began — nothing can be derived yet.
+        return None, None
+
+    today: float | None = live_total_kwh - base_today
+    if today < 0:
+        logger.info(
+            "Plug %s: lifetime counter went backwards (%.3f < %.3f) — "
+            "device counter was probably reset; reporting no value for today",
+            plug_id,
+            live_total_kwh,
+            base_today,
+        )
+        today = None
+
+    base_yesterday = await _counter_at(db, plug_id, midnight_yesterday)
+    if base_yesterday is None:
+        return today, None
+
+    yesterday: float | None = base_today - base_yesterday
+    if yesterday < 0:
+        yesterday = None
+
+    return today, yesterday
+
+
+async def fill_derived_energy(db: AsyncSession, plug_id: int, energy: dict) -> dict:
+    """Fill in Today / Yesterday on an energy dict that only has a lifetime total.
+
+    A no-op for Tasmota, which reports both itself — a device that knows its own
+    daily usage is more accurate than our hourly-snapshot arithmetic, so a value
+    already present is never overwritten.
+    """
+    total = energy.get("total")
+    if total is None:
+        return energy
+    if energy.get("today") is not None and energy.get("yesterday") is not None:
+        return energy
+
+    today, yesterday = await derive_today_yesterday(db, plug_id, float(total))
+    if energy.get("today") is None and today is not None:
+        energy["today"] = round(today, 3)
+    if energy.get("yesterday") is None and yesterday is not None:
+        energy["yesterday"] = round(yesterday, 3)
+    return energy

+ 18 - 3
backend/app/services/print_log.py

@@ -33,11 +33,26 @@ async def write_log_entry(
     thumbnail_path: str | None = None,
     created_by_id: int | None = None,
     created_by_username: str | None = None,
+    reconciled: bool = False,
 ) -> PrintLogEntry:
-    """Write a print log entry."""
-    duration = None
-    if started_at and completed_at:
+    """Write a print log entry.
+
+    ``reconciled`` marks a synthetic completion written when a stale
+    ``status="printing"`` archive is closed out at reconnect. Its real end time
+    is unknown — the print stopped somewhere during the disconnect and
+    ``completed_at`` is only the reconnect moment — so ``completed_at -
+    started_at`` would bank the entire disconnect gap as print time, adding
+    hundreds of fictitious hours across a farm of stale rows (#2592). For those
+    entries we store an explicit ``0`` ("no measured runtime") rather than a
+    fabricated duration; the stats total trusts a stored 0 instead of
+    recomputing from the stale timestamps.
+    """
+    if reconciled:
+        duration: int | None = 0
+    elif started_at and completed_at:
         duration = int((completed_at - started_at).total_seconds())
+    else:
+        duration = None
 
     entry = PrintLogEntry(
         archive_id=archive_id,

Разница между файлами не показана из-за своего большого размера
+ 689 - 48
backend/app/services/print_scheduler.py


+ 24 - 4
backend/app/services/printer_diagnostic.py

@@ -19,7 +19,7 @@ from backend.app.schemas.printer import DiagnosticCheck, PrinterDiagnosticResult
 from backend.app.services.camera import get_camera_port
 from backend.app.services.discovery import is_running_in_docker
 from backend.app.services.printer_manager import printer_manager
-from backend.app.utils.printer_models import has_external_storage
+from backend.app.utils.printer_models import has_external_storage, has_remote_storage_toggle
 
 logger = logging.getLogger(__name__)
 
@@ -204,13 +204,33 @@ async def run_connection_diagnostic(
     # and A1 Mini). They never set home_flag bit 11, so a naive read of
     # `store_to_sdcard` would fall through to a false `fail` for every
     # A1-series user (#1703).
+    #
+    # Some models (P1-series) DO have a slot but no reachable control to turn
+    # the option on: the Bambu Studio toggle only appears when the printer
+    # publishes `support_save_remote_print_file_to_storage`, which current
+    # P1 firmware never does, and the P1S/P1P have no screen. For those,
+    # `store_to_sdcard` is stuck False with no way to fix it — report `skip`
+    # (with a reason the UI explains) instead of a permanently-red `fail`
+    # (#2524).
     state = printer_manager.get_status(printer.id) if printer else None
-    model_has_slot = has_external_storage(getattr(printer, "model", None)) if printer else True
+    model = getattr(printer, "model", None) if printer else None
+    model_has_slot = has_external_storage(model) if printer else True
+    store_to_sdcard = getattr(state, "store_to_sdcard", None) if state else None
     if not model_has_slot or state is None or not state.connected:
         checks.append(DiagnosticCheck(id="external_storage", status="skip"))
-    elif getattr(state, "store_to_sdcard", None) is True:
+    elif store_to_sdcard is True:
         checks.append(DiagnosticCheck(id="external_storage", status="pass"))
-    elif getattr(state, "store_to_sdcard", None) is False:
+    elif store_to_sdcard is False and not has_remote_storage_toggle(model):
+        # Slot present but no way to enable it on this firmware — don't nag
+        # with an unresolvable fail; explain why via the reason param.
+        checks.append(
+            DiagnosticCheck(
+                id="external_storage",
+                status="skip",
+                params={"reason": "unsupported_model"},
+            )
+        )
+    elif store_to_sdcard is False:
         checks.append(DiagnosticCheck(id="external_storage", status="fail"))
     else:
         # State exists but the field was never populated — skip rather than

+ 164 - 14
backend/app/services/printer_manager.py

@@ -209,27 +209,48 @@ _DRYING_MIN_FIRMWARE: dict[str, str] = {
     "O1C2": "01.02.00.00",  # H2C dual-nozzle SSDP model code
     "X1": "01.09.00.00",
     "X1C": "01.09.00.00",
-    "P1P": "01.08.00.00",
-    "P1S": "01.08.00.00",
     "P2S": "01.02.00.00",
     "N7": "01.02.00.00",  # P2S internal model code
 }
 # Models that definitely don't support AMS drying (no AMS 2 Pro / AMS-HT compatibility)
 _DRYING_UNSUPPORTED_MODELS = frozenset({"A1", "A1MINI", "A1-MINI", "A1 MINI", "O1S", "N1", "N2S"})
 
+# Models whose AMS can dry, but only from the printer's own touchscreen. Bambu's P1
+# manual is explicit: "P1S connected AMS drying functions may only be controlled from
+# the P1S screen." The firmware still answers `ams_filament_drying` with
+# result: success and then does nothing — the reporter of #2533 sent it three times
+# on an idle P1S with an AMS 2 Pro and the unit never left dry_status 0. Bambuddy
+# originally listed P1P/P1S here as fw-gated (01.08+, #292); that version is when P1
+# firmware gained AMS 2 Pro *support*, not remote drying, and it was never verified
+# against a live P1. Nothing we can send will start a cycle, so we don't offer to.
+_DRYING_SCREEN_ONLY_MODELS = frozenset({"P1P", "P1S"})
+
+
+def drying_screen_only(model: str | None) -> bool:
+    """True when the model's AMS dries only via the printer's own screen (#2533).
+
+    Distinct from "unsupported": these printers *can* dry, and Bambuddy still shows
+    a cycle started on the printer. They just can't be commanded to start or stop
+    one remotely, so the UI explains that instead of silently dropping the control.
+    """
+    if not model:
+        return False
+    return model.strip().upper() in _DRYING_SCREEN_ONLY_MODELS
+
 
 def supports_drying(model: str | None, firmware: str | None) -> bool:
-    """Check if a printer model supports AMS drying commands.
+    """Check if a printer model accepts remote AMS drying commands.
 
     Known models with confirmed min firmware get version-gated.
-    Known unsupported models are blocked.
+    Known unsupported models, and models that only dry from their own screen,
+    are blocked.
     All other models (H2D Pro, X1E, future models) are allowed —
     the command fails gracefully with result: "fail" if unsupported.
     """
     if not model:
         return False
     model_upper = model.strip().upper()
-    if model_upper in _DRYING_UNSUPPORTED_MODELS:
+    if model_upper in _DRYING_UNSUPPORTED_MODELS or model_upper in _DRYING_SCREEN_ONLY_MODELS:
         return False
     if model_upper in _DRYING_MIN_FIRMWARE:
         return bool(firmware and firmware >= _DRYING_MIN_FIRMWARE[model_upper])
@@ -306,6 +327,7 @@ class PrinterManager:
         self._on_layer_change: Callable[[int, int], None] | None = None
         self._on_bed_temp_update: Callable[[int, float], None] | None = None
         self._on_drying_complete: Callable[[int, int], None] | None = None
+        self._on_assignment_verified: Callable[[int, int, int, bool, dict], None] | None = None
         self._loop: asyncio.AbstractEventLoop | None = None
         # Track who started the current print (Issue #206)
         self._current_print_user: dict[int, dict] = {}  # {printer_id: {"user_id": int, "username": str}}
@@ -492,6 +514,15 @@ class PrinterManager:
         """
         self._on_drying_complete = callback
 
+    def set_assignment_verified_callback(self, callback: Callable[[int, int, int, bool, dict], None]):
+        """Set callback for spool-assignment read-back verification (#2582).
+
+        Receives ``(printer_id, ams_id, tray_id, verified, detail)``. Fires once
+        per assignment either when the tray telemetry confirms the pushed
+        filament id or when the verification window elapses without it.
+        """
+        self._on_assignment_verified = callback
+
     def _schedule_async(self, coro):
         """Schedule an async coroutine from a sync context.
 
@@ -555,6 +586,10 @@ class PrinterManager:
             if self._on_drying_complete:
                 self._schedule_async(self._on_drying_complete(printer_id, ams_id))
 
+        def on_assignment_verified(ams_id: int, tray_id: int, verified: bool, detail: dict):
+            if self._on_assignment_verified:
+                self._schedule_async(self._on_assignment_verified(printer_id, ams_id, tray_id, verified, detail))
+
         client = BambuMQTTClient(
             ip_address=printer.ip_address,
             serial_number=printer.serial_number,
@@ -569,6 +604,7 @@ class PrinterManager:
             on_drying_complete=on_drying_complete,
             on_print_running_observed=on_print_running_observed,
             on_finish_photo_moment=on_finish_photo_moment,
+            on_assignment_verified=on_assignment_verified,
         )
 
         client.connect()
@@ -662,6 +698,11 @@ class PrinterManager:
 
         This is used when we know the printer power was cut (e.g., smart plug turned off)
         to immediately update the UI without waiting for MQTT timeout.
+
+        The mark is a presumption, not a fact: the plug may not actually feed
+        the printer. ``BambuMQTTClient.mark_power_off`` records the state it
+        overwrites so the client can undo it as soon as the printer sends
+        another report (#2629).
         """
         import logging
 
@@ -669,10 +710,8 @@ class PrinterManager:
 
         if printer_id in self._clients:
             client = self._clients[printer_id]
-            if client.state.connected:
+            if client.mark_power_off():
                 logger.info("Marking printer %s as offline (smart plug power off)", printer_id)
-                client.state.connected = False
-                client.state.state = "unknown"
                 # Trigger the status change callback to broadcast via WebSocket
                 if self._on_status_change:
                     self._schedule_async(self._on_status_change(printer_id, client.state))
@@ -683,13 +722,13 @@ class PrinterManager:
         filename: str,
         plate_id: int = 1,
         ams_mapping: list[int] | None = None,
-        bed_levelling: bool = True,
-        flow_cali: bool = False,
+        bed_levelling: str = "auto",
+        flow_cali: str = "auto",
         vibration_cali: bool = True,
         layer_inspect: bool = False,
         timelapse: bool = False,
         use_ams: bool = True,
-        nozzle_offset_cali: bool = False,
+        nozzle_offset_cali: str = "auto",
         nozzle_mapping: str | None = None,
     ) -> bool:
         """Start a print on a connected printer.
@@ -976,6 +1015,67 @@ def resolve_plate_id(state) -> int | None:
     return parse_plate_id(state.gcode_file)
 
 
+def resolve_expected_tray(
+    raw_slot: int | None,
+    ams_layout: list[tuple[int, bool]],
+    mapping_raw: object,
+) -> int | None:
+    """Globalise a raw firmware ``tray_tar``/``tray_pre`` value for the runout UI (#2587).
+
+    The firmware reports the target/previous slot as a bare number whose meaning
+    depends on the AMS layout (see ``PrinterState.tray_tar``). This mirrors the
+    ``tray_now`` handling so the resolved ID lines up with what the AMS graphic
+    already highlights via ``ams_id*4 + slot``.
+
+    ``ams_layout`` is a list of ``(ams_id, is_ams_ht)`` for the connected units.
+
+    - ``255``/``-1`` (none/idle) -> ``None``
+    - ``254`` (external spool) -> ``254``
+    - ``128``-``135`` (AMS-HT) -> already global, returned as-is
+    - ``0``-``3`` local slot:
+        * exactly one regular AMS -> ``ams_id*4 + slot``
+        * several regular AMS -> resolved via the snow-encoded ``mapping`` field
+          (each entry = ``ams_hw_id*256 + slot``; ``65535`` = unmapped), or
+          ``None`` when it stays ambiguous (honest "can't determine")
+        * no regular AMS -> ``None``
+    - ``4``-``15`` -> already a global regular-AMS ID, returned as-is
+
+    Returns ``None`` for anything it can't place, so the caller surfaces a
+    "check the printer" message instead of pointing at the wrong slot.
+    """
+    if raw_slot is None or raw_slot in (255, -1):
+        return None
+    if raw_slot == 254:
+        return 254
+    if 128 <= raw_slot <= 135:
+        return raw_slot
+    if 0 <= raw_slot <= 3:
+        regular = [ams_id for ams_id, is_ht in ams_layout if not is_ht]
+        if len(regular) == 1:
+            return regular[0] * 4 + raw_slot
+        if len(regular) > 1:
+            if not isinstance(mapping_raw, list):
+                return None
+            candidates: set[int] = set()
+            for value in mapping_raw:
+                if not isinstance(value, int) or value >= 65535:
+                    continue
+                ams_hw_id = value >> 8
+                slot = value & 0xFF
+                if 0 <= ams_hw_id <= 3 and (slot & 0x03) == raw_slot:
+                    candidates.add(ams_hw_id * 4 + raw_slot)
+                elif 128 <= ams_hw_id <= 135 and raw_slot == 0:
+                    candidates.add(ams_hw_id)
+            return candidates.pop() if len(candidates) == 1 else None
+        return None
+    if 4 <= raw_slot <= 15:
+        return raw_slot
+    # 24-27 = A2L AMS-Lite (normalised unit 6) global tray ids, already resolved.
+    if 24 <= raw_slot <= 27:
+        return raw_slot
+    return None
+
+
 def printer_state_to_dict(
     state: PrinterState,
     printer_id: int | None = None,
@@ -1218,6 +1318,28 @@ def printer_state_to_dict(
         "ams_status_main": state.ams_status_main,
         "ams_status_sub": state.ams_status_sub,
         "tray_now": state.tray_now,
+        # Runout / filament-replacement guidance (#2587). Only meaningful while
+        # PAUSED — resolve the firmware's target/previous slot to a global tray ID
+        # so the AMS graphic can highlight the slot the print now expects and name
+        # the one that ran out. None when idle, not paused, or unresolvable.
+        "expected_tray": (
+            resolve_expected_tray(
+                state.tray_tar,
+                [(u["id"], u.get("is_ams_ht", False)) for u in ams_units],
+                raw_data.get("mapping"),
+            )
+            if state.state == "PAUSE"
+            else None
+        ),
+        "previous_tray": (
+            resolve_expected_tray(
+                state.tray_pre,
+                [(u["id"], u.get("is_ams_ht", False)) for u in ams_units],
+                raw_data.get("mapping"),
+            )
+            if state.state == "PAUSE"
+            else None
+        ),
         # Per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left
         "ams_extruder_map": ams_extruder_map,
         # WiFi signal strength
@@ -1263,6 +1385,7 @@ def printer_state_to_dict(
         # AMS drying support
         "supports_drying": supports_drying(model, state.firmware_version),
         "supports_drying_while_printing": supports_drying_while_printing(model, state.firmware_version),
+        "drying_screen_only": drying_screen_only(model),
         # 1-indexed plate number parsed from gcode_file (e.g. /Metadata/plate_2.gcode).
         # Pushed via WebSocket so the printer card picks up plate transitions within
         # a multi-plate 3MF without waiting for the 30 s REST poll (#881 follow-up).
@@ -1298,9 +1421,36 @@ printer_manager = PrinterManager()
 
 
 async def init_printer_connections(db: AsyncSession):
-    """Initialize connections to all active printers."""
+    """Initialize connections to all active printers.
+
+    Connections are started concurrently. ``connect_printer()`` is non-blocking
+    apart from a fixed 1-second settle wait — ``BambuMQTTClient.connect()`` only
+    calls ``connect_async()`` + ``loop_start()``, so the handshake happens on a
+    background thread and the coroutine's only real cost is that ``sleep(1)``. A
+    serial loop therefore spent one whole second per printer inside the FastAPI
+    lifespan *before* the ASGI server begins serving: on a large farm that was
+    ~100s of dead air before port 8000 responded (issue #2572, reporter's
+    93-printer farm). Gathering overlaps the settle waits so the whole step takes
+    ~1s regardless of fleet size. Exceptions are isolated per printer with
+    ``return_exceptions=True`` so one unreachable row can't abort the rest — or
+    startup itself, which the old serial loop's un-caught await would have done.
+
+    All columns ``connect_printer`` reads are eagerly loaded by the SELECT above
+    and touched synchronously before its trailing ``await``, so no concurrent
+    lazy-load is triggered on the shared session.
+    """
     result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
     printers = result.scalars().all()
 
-    for printer in printers:
-        await printer_manager.connect_printer(printer)
+    outcomes = await asyncio.gather(
+        *(printer_manager.connect_printer(printer) for printer in printers),
+        return_exceptions=True,
+    )
+    for printer, outcome in zip(printers, outcomes, strict=True):
+        if isinstance(outcome, Exception):
+            logger.warning(
+                "Failed to connect printer %s (%s) at startup: %s",
+                printer.id,
+                printer.name,
+                outcome,
+            )

+ 45 - 22
backend/app/services/rest_smart_plug.py

@@ -193,12 +193,25 @@ class RESTSmartPlugService:
     async def get_energy(self, plug: "SmartPlug") -> dict | None:
         """Get energy monitoring data.
 
-        Each value (power, energy) can come from its own URL or fall back to the shared status URL.
-        Multipliers are applied to convert units (e.g., Wh → kWh with multiplier 0.001).
+        Each value can come from its own URL or fall back to the shared status URL.
+        Multipliers convert units (e.g. Wh → kWh with multiplier 0.001).
+
+        Two distinct energy counters, because devices differ in which they have
+        (#2539):
+
+        - ``rest_energy_path`` — energy used **today**, resetting at midnight.
+        - ``rest_energy_total_path`` — a **lifetime** counter that never resets.
+          A Shelly exposes only this one (``aenergy.total``, in Wh). Reading it as
+          "today" is wrong all day long, and leaves Total and the hourly snapshots
+          — which the Statistics page's date filters run on — permanently empty.
+
+        Yesterday is not read from the device: no REST device we know of reports
+        it. It is derived from the lifetime counter's snapshots instead, in
+        ``services.plug_energy_history``.
 
         Returns dict with energy data or None if not available.
         """
-        if not plug.rest_power_path and not plug.rest_energy_path:
+        if not plug.rest_power_path and not plug.rest_energy_path and not plug.rest_energy_total_path:
             return None
 
         headers = self._parse_headers(plug.rest_headers)
@@ -206,30 +219,40 @@ class RESTSmartPlugService:
 
         power_url = plug.rest_power_url or plug.rest_status_url if plug.rest_power_path else None
         energy_url = plug.rest_energy_url or plug.rest_status_url if plug.rest_energy_path else None
+        # The lifetime counter almost always rides on the same response as the
+        # today counter (one Shelly RPC call returns both `apower` and
+        # `aenergy.total`), so it shares the energy URL and the dedupe below
+        # collapses them into a single fetch.
+        total_url = plug.rest_energy_url or plug.rest_status_url if plug.rest_energy_total_path else None
 
-        # Fetch data — deduplicate when both resolve to the same URL
+        # Fetch data — deduplicate when several resolve to the same URL
         fetched: dict[str, Any] = {}
 
-        for url in {power_url, energy_url} - {None}:
+        for url in {power_url, energy_url, total_url} - {None}:
             fetched[url] = await self._fetch_json(url, headers)
 
-        # Extract power value
-        if plug.rest_power_path and power_url and fetched.get(power_url) is not None:
-            raw = self._extract_json_path(fetched[power_url], plug.rest_power_path)
-            if raw is not None:
-                try:
-                    energy["power"] = float(raw) * (plug.rest_power_multiplier or 1.0)
-                except (ValueError, TypeError):
-                    pass
-
-        # Extract energy value
-        if plug.rest_energy_path and energy_url and fetched.get(energy_url) is not None:
-            raw = self._extract_json_path(fetched[energy_url], plug.rest_energy_path)
-            if raw is not None:
-                try:
-                    energy["today"] = float(raw) * (plug.rest_energy_multiplier or 1.0)
-                except (ValueError, TypeError):
-                    pass
+        def _read(path: str | None, url: str | None, multiplier: float | None) -> float | None:
+            if not path or not url or fetched.get(url) is None:
+                return None
+            raw = self._extract_json_path(fetched[url], path)
+            if raw is None:
+                return None
+            try:
+                return float(raw) * (multiplier or 1.0)
+            except (ValueError, TypeError):
+                return None
+
+        power = _read(plug.rest_power_path, power_url, plug.rest_power_multiplier)
+        if power is not None:
+            energy["power"] = power
+
+        today = _read(plug.rest_energy_path, energy_url, plug.rest_energy_multiplier)
+        if today is not None:
+            energy["today"] = today
+
+        total = _read(plug.rest_energy_total_path, total_url, plug.rest_energy_total_multiplier)
+        if total is not None:
+            energy["total"] = total
 
         return energy if energy else None
 

+ 6 - 0
backend/app/services/slice_dispatch.py

@@ -30,6 +30,10 @@ class SliceJob:
     kind: Literal["library_file", "archive"]
     source_id: int
     source_name: str
+    # JWT user id that started the job, for per-row scoping of the polling
+    # endpoint. None for API-key / auth-disabled callers (no per-row identity);
+    # those jobs are visible only to READ_ALL pollers — see slice_jobs.py.
+    owner_id: int | None = None
     status: SliceJobStatus = "pending"
     created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
     started_at: datetime | None = None
@@ -68,6 +72,7 @@ class SliceDispatchService:
         kind: Literal["library_file", "archive"],
         source_id: int,
         source_name: str,
+        owner_id: int | None = None,
         run: Callable[[int], Awaitable[dict[str, Any]]],
     ) -> SliceJob:
         """Register a new slice job and start it on the event loop.
@@ -83,6 +88,7 @@ class SliceDispatchService:
                 kind=kind,
                 source_id=source_id,
                 source_name=source_name,
+                owner_id=owner_id,
             )
             self._next_id += 1
             self._jobs[job.id] = job

+ 33 - 12
backend/app/services/slicer_3mf_convert.py

@@ -237,20 +237,31 @@ def merge_plate_3mfs(
 
 def substitute_unused_plate_filaments(source_3mf_bytes: bytes, plate_id: int | None, items: list[str]) -> list[str]:
     """Replace any filament-list entry whose 1-indexed slot isn't used by
-    ``plate_id`` with the entry at slot 1 (index 0).
+    ``plate_id`` with the entry from the plate's lowest *used* slot.
 
     Why: the slice modal lets the user pick a filament profile per slot,
     but each plate in a multi-plate project only uses a subset of those
     slots. The modal labels the unused rows "not used by this plate" yet
     still submits their dropdown values. BambuStudio then validates every
-    loaded filament for material compatibility — PLA in a used slot +
-    ABS defaulted into an unused slot trips
-    "the temperature difference of the filaments used is too large"
-    (exit 194), even though the plate's G-code never touches the ABS
-    slot. Substituting unused entries with slot 1's filament keeps the
-    per-filament array length intact (so the source 3MF's per-slot
-    references stay valid) while making the loaded-filament set
-    materially homogeneous, so the validator passes.
+    loaded filament — for material compatibility (PLA in a used slot +
+    ABS defaulted into an unused slot trips "the temperature difference
+    of the filaments used is too large", exit 194) and for printer
+    compatibility ("filament preset X (slot N) is not compatible with
+    printer Y", exit -5) — even though the plate's G-code never touches
+    the unused slot. Substituting unused entries with a used slot's
+    filament keeps the per-filament array length intact (so the source
+    3MF's per-slot references stay valid) while making the loaded set
+    both materially homogeneous and printer-correct, so both validators
+    pass.
+
+    The anchor is the lowest used slot, NOT slot 1 (#2628). Slot 1 is
+    itself unused on plenty of plates, and anchoring there did the two
+    things this function exists to prevent: the substitution became a
+    no-op for the slot that needed it most, and — with more than one
+    unused slot — it propagated slot 1's own preset (in the reported
+    case an ``@Bambu Lab H2D`` profile baked into the source 3MF) into
+    every other unused slot, blocking an A1 slice on slots the plate
+    doesn't even use.
 
     The substitution is a no-op when:
     - ``plate_id`` is None (we can't determine which slots are unused),
@@ -288,16 +299,26 @@ def substitute_unused_plate_filaments(source_3mf_bytes: bytes, plate_id: int | N
         # than to silently rewrite them.
         return items
     out = list(items)
+    # Anchor on the lowest used slot that actually exists in the list. A
+    # plate can reference a slot beyond the submitted list (a truncated or
+    # mismatched pick set) — those can't be an anchor, and if none of the
+    # used slots is in range there is nothing trustworthy to copy from, so
+    # leave the user's picks alone rather than inventing a substitution.
+    in_range_used = sorted(s for s in used if 1 <= s <= len(out))
+    if not in_range_used:
+        return items
+    anchor_slot = in_range_used[0]
     substituted = []
     for idx in range(len(out)):
         slot = idx + 1
         if slot not in used:
             substituted.append(slot)
-            out[idx] = out[0]
+            out[idx] = out[anchor_slot - 1]
     if substituted:
         logger.info(
-            "Substituted slot-1 filament for unused slot(s) %s on plate %s "
-            "(avoids loaded-filament temp-spread validator)",
+            "Substituted slot-%s filament for unused slot(s) %s on plate %s "
+            "(avoids loaded-filament temp-spread and printer-compatibility validators)",
+            anchor_slot,
             substituted,
             plate_id,
         )

+ 49 - 29
backend/app/services/smart_plug_manager.py

@@ -13,6 +13,7 @@ from backend.app.services.homeassistant import homeassistant_service
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.rest_smart_plug import rest_smart_plug_service
 from backend.app.services.tasmota import tasmota_service
+from backend.app.utils.local_time import next_local_hour, to_naive_utc, utcnow_naive
 
 if TYPE_CHECKING:
     from backend.app.models.smart_plug import SmartPlug
@@ -98,26 +99,34 @@ class SmartPlugManager:
             await asyncio.sleep(60)
 
     async def _snapshot_loop(self):
-        """Background loop that captures each plug's lifetime energy counter hourly.
-
-        Powers date-range queries in "total consumption" energy mode (#941). Takes
-        a snapshot shortly after startup so the first bucket isn't empty, then
-        every hour.
+        """Background loop that captures each plug's lifetime energy counter.
+
+        Powers date-range queries in "total consumption" energy mode (#941) and,
+        since #2539, the derived Today / Yesterday figures for every plug that
+        reports only a cumulative counter.
+
+        Ticks on the local hour rather than every 3600s from boot. That is what
+        makes the derivation exact: a drifting timer leaves the last snapshot
+        before midnight up to an hour early, and an hour of a printer's draw is
+        a real number of watt-hours to lose off the day boundary. Aligning to the
+        *local* hour also lands a tick on local midnight in the half-hour-offset
+        timezones (India, Nepal), where midnight is not on a UTC hour at all.
         """
-        # Short warm-up delay so other services finish booting; still gives us
-        # an initial snapshot well before the first hour mark.
+        # Short warm-up delay so other services finish booting; still gives us an
+        # initial snapshot well before the first boundary.
         await asyncio.sleep(30)
         while True:
             try:
                 await self._capture_energy_snapshots()
             except Exception as e:
                 logger.error("Error in energy snapshot capture: %s", e)
-            await asyncio.sleep(3600)  # 1 hour
+
+            now = datetime.now(timezone.utc)
+            delay = (next_local_hour(now) - now).total_seconds()
+            await asyncio.sleep(max(delay, 60))
 
     async def _capture_energy_snapshots(self):
         """Capture one energy snapshot row per plug with a usable lifetime counter."""
-        from datetime import timezone
-
         from backend.app.core.database import async_session
         from backend.app.models.smart_plug import SmartPlug
         from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
@@ -128,7 +137,10 @@ class SmartPlugManager:
             if not plugs:
                 return
 
-            now = datetime.now(timezone.utc)
+            # Naive UTC: the column is naive, and asyncpg rejects an aware value
+            # outright (SQLite quietly drops the offset, which is why this went
+            # unnoticed — on Postgres the whole capture raised).
+            now = utcnow_naive()
             captured = 0
             for plug in plugs:
                 # MQTT plugs only publish a "today" counter that resets at midnight —
@@ -146,8 +158,9 @@ class SmartPlugManager:
                     continue
                 lifetime = energy.get("total")
                 if lifetime is None:
-                    # MQTT / REST plugs that only expose "today" can't be used for
-                    # cumulative snapshots — skip them.
+                    # The plug exposes no cumulative counter — a REST plug with only
+                    # rest_energy_path set, say. Nothing to snapshot, and its Today
+                    # comes straight from the device anyway.
                     continue
                 db.add(
                     SmartPlugEnergySnapshot(
@@ -189,7 +202,7 @@ class SmartPlugManager:
                         success = await service.turn_on(plug)
                         if success:
                             plug.last_state = "ON"
-                            plug.last_checked = datetime.now(timezone.utc)
+                            plug.last_checked = utcnow_naive()
                             self._last_schedule_check[plug.id] = f"on:{current_time}"
 
                 # Check if we should turn off
@@ -200,10 +213,10 @@ class SmartPlugManager:
                         success = await service.turn_off(plug)
                         if success:
                             plug.last_state = "OFF"
-                            plug.last_checked = datetime.now(timezone.utc)
+                            plug.last_checked = utcnow_naive()
                             self._last_schedule_check[plug.id] = f"off:{current_time}"
-                            # Mark printer offline if linked
-                            if plug.printer_id:
+                            # Mark printer offline if this plug feeds it (#2629)
+                            if plug.printer_id and plug.controls_printer_power:
                                 printer_manager.mark_printer_offline(plug.printer_id)
 
             await db.commit()
@@ -245,7 +258,7 @@ class SmartPlugManager:
 
                 if success:
                     plug.last_state = "ON"
-                    plug.last_checked = datetime.now(timezone.utc)
+                    plug.last_checked = utcnow_naive()
                     plug.auto_off_executed = False  # Reset flag when turning on
             except Exception as e:
                 logger.warning("Failed to turn on plug '%s' for printer %s: %s", plug.name, printer_id, e)
@@ -397,6 +410,7 @@ class SmartPlugManager:
                 plug.password,
                 printer_id,
                 delay_seconds,
+                controls_printer_power=plug.controls_printer_power,
                 rest_off_url=plug.rest_off_url if plug.plug_type == "rest" else None,
                 rest_off_body=plug.rest_off_body if plug.plug_type == "rest" else None,
                 rest_method=plug.rest_method if plug.plug_type == "rest" else None,
@@ -416,6 +430,7 @@ class SmartPlugManager:
         printer_id: int,
         delay_seconds: int,
         *,
+        controls_printer_power: bool = True,
         rest_off_url: str | None = None,
         rest_off_body: str | None = None,
         rest_method: str | None = None,
@@ -463,8 +478,10 @@ class SmartPlugManager:
             # Mark auto_off_executed in database and update printer status
             if success:
                 await self._mark_auto_off_executed(plug_id)
-                # Mark the printer as offline immediately
-                printer_manager.mark_printer_offline(printer_id)
+                # Mark the printer as offline immediately — but only when this
+                # plug actually feeds the printer (#2629).
+                if controls_printer_power:
+                    printer_manager.mark_printer_offline(printer_id)
 
         except asyncio.CancelledError:
             logger.debug("Delayed turn-off cancelled for plug %s", plug_id)
@@ -491,6 +508,7 @@ class SmartPlugManager:
                 plug.password,
                 printer_id,
                 temp_threshold,
+                controls_printer_power=plug.controls_printer_power,
                 rest_off_url=plug.rest_off_url if plug.plug_type == "rest" else None,
                 rest_off_body=plug.rest_off_body if plug.plug_type == "rest" else None,
                 rest_method=plug.rest_method if plug.plug_type == "rest" else None,
@@ -510,6 +528,7 @@ class SmartPlugManager:
         printer_id: int,
         temp_threshold: int,
         *,
+        controls_printer_power: bool = True,
         rest_off_url: str | None = None,
         rest_off_body: str | None = None,
         rest_method: str | None = None,
@@ -590,8 +609,10 @@ class SmartPlugManager:
                         # Mark auto_off_executed in database and update printer status
                         if success:
                             await self._mark_auto_off_executed(plug_id)
-                            # Mark the printer as offline immediately
-                            printer_manager.mark_printer_offline(printer_id)
+                            # Mark the printer as offline immediately — but only
+                            # when this plug actually feeds the printer (#2629).
+                            if controls_printer_power:
+                                printer_manager.mark_printer_offline(printer_id)
 
                         break
 
@@ -617,7 +638,7 @@ class SmartPlugManager:
                 plug = result.scalar_one_or_none()
                 if plug:
                     plug.auto_off_pending = pending
-                    plug.auto_off_pending_since = datetime.now(timezone.utc) if pending else None
+                    plug.auto_off_pending_since = utcnow_naive() if pending else None
                     await db.commit()
                     logger.debug("Marked plug %s auto_off_pending=%s", plug_id, pending)
         except Exception as e:
@@ -639,7 +660,7 @@ class SmartPlugManager:
                     plug.auto_off_pending = False  # Clear pending state
                     plug.auto_off_pending_since = None
                     plug.last_state = "OFF"
-                    plug.last_checked = datetime.now(timezone.utc)
+                    plug.last_checked = utcnow_naive()
                     await db.commit()
                     if plug.auto_off_persistent:
                         logger.info("Auto-off executed for plug %s (persistent, stays enabled)", plug_id)
@@ -685,10 +706,8 @@ class SmartPlugManager:
                 for plug in pending_plugs:
                     # Check how long it's been pending (timeout after 2 hours)
                     if plug.auto_off_pending_since:
-                        pending_since = plug.auto_off_pending_since
-                        if pending_since.tzinfo is None:
-                            pending_since = pending_since.replace(tzinfo=timezone.utc)
-                        elapsed = (datetime.now(timezone.utc) - pending_since).total_seconds()
+                        pending_since = to_naive_utc(plug.auto_off_pending_since)
+                        elapsed = (utcnow_naive() - pending_since).total_seconds()
                         if elapsed > 7200:  # 2 hours
                             logger.warning(
                                 f"Auto-off for plug '{plug.name}' was pending for {elapsed / 60:.0f} minutes, "
@@ -728,7 +747,8 @@ class SmartPlugManager:
                         success = await service.turn_off(plug)
                         if success:
                             await self._mark_auto_off_executed(plug.id)
-                            printer_manager.mark_printer_offline(plug.printer_id)
+                            if plug.controls_printer_power:
+                                printer_manager.mark_printer_offline(plug.printer_id)
 
                 if pending_plugs:
                     logger.info("Resumed %s pending auto-off(s)", len(pending_plugs))

+ 3 - 0
backend/app/services/spool_assignment_notifications.py

@@ -27,6 +27,9 @@ def _slot_label_from_global_tray(global_tray_id: int) -> str:
         return "Ext-R"
     if global_tray_id >= 128:
         return f"HT-{chr(65 + (global_tray_id - 128))}"
+    # 24-27 = A2L AMS-Lite (normalised unit 6); see a2l-am-unit-16.
+    if 24 <= global_tray_id <= 27:
+        return f"Lite-{(global_tray_id % 4) + 1}"
     ams_id = global_tray_id // 4
     tray_id = global_tray_id % 4
     return f"{chr(65 + ams_id)}{tray_id + 1}"

+ 109 - 32
backend/app/services/spoolman_tracking.py

@@ -471,6 +471,7 @@ async def _report_spool_usage_for_slots(
     printer_serial: str = "",
     printer_id: int | None = None,
     slot_colors_out: dict[int, str] | None = None,
+    slot_materials_out: dict[int, str] | None = None,
 ) -> int:
     """Report usage to Spoolman for a list of (slot_id, grams) pairs.
 
@@ -511,10 +512,12 @@ async def _report_spool_usage_for_slots(
 
         spool_id_to_use: int | None = None
         resolution_path = ""
-        # color_hex of the resolved spool's filament, for the #1494 archive
-        # colour rewrite. The tag path already has the full spool object;
-        # the slot-assignment path only yields an id and is fetched below.
+        # color_hex + material of the resolved spool's filament, for the #1494
+        # archive colour rewrite and the #2563 type rewrite. The tag path
+        # already has the full spool object; the slot-assignment path only
+        # yields an id and is fetched below.
         spool_color_hex: str | None = None
+        spool_material: str | None = None
 
         spool_tag = _resolve_spool_tag(tray_info, printer_serial, global_tray_id)
         if spool_tag:
@@ -523,6 +526,7 @@ async def _report_spool_usage_for_slots(
                 spool_id_to_use = spool["id"]
                 resolution_path = "tag"
                 spool_color_hex = (spool.get("filament") or {}).get("color_hex")
+                spool_material = (spool.get("filament") or {}).get("material")
 
         if spool_id_to_use is None and printer_id is not None:
             ams_id, tray_id = _global_tray_id_to_ams_slot(global_tray_id)
@@ -538,19 +542,27 @@ async def _report_spool_usage_for_slots(
             )
             continue
 
-        # Record the spool's filament colour for the archive rewrite (#1494).
-        # The slot-assignment path resolved only an id, so fetch the spool.
-        # Strictly best-effort: a colour-fetch failure must never abort the
-        # weight reporting for the remaining slots, so the catch is broad.
-        if slot_colors_out is not None:
-            if spool_color_hex is None:
+        # Record the spool's filament colour + material for the archive
+        # rewrites (#1494, #2563). The slot-assignment path resolved only an
+        # id, so fetch the spool once for whichever value is still missing.
+        # Strictly best-effort: a fetch failure must never abort the weight
+        # reporting for the remaining slots, so the catch is broad.
+        if slot_colors_out is not None or slot_materials_out is not None:
+            need_color = slot_colors_out is not None and spool_color_hex is None
+            need_material = slot_materials_out is not None and spool_material is None
+            if need_color or need_material:
                 try:
-                    full_spool = await client.get_spool(spool_id_to_use)
-                    spool_color_hex = (full_spool.get("filament") or {}).get("color_hex")
-                except Exception as exc:  # noqa: BLE001 — colour is non-critical
-                    logger.debug("[SPOOLMAN] Slot %s: could not fetch spool colour: %s", slot_id, exc)
-            if spool_color_hex:
+                    _fil = (await client.get_spool(spool_id_to_use)).get("filament") or {}
+                    if need_color:
+                        spool_color_hex = _fil.get("color_hex")
+                    if need_material:
+                        spool_material = _fil.get("material")
+                except Exception as exc:  # noqa: BLE001 — colour/material are non-critical
+                    logger.debug("[SPOOLMAN] Slot %s: could not fetch spool filament: %s", slot_id, exc)
+            if slot_colors_out is not None and spool_color_hex:
                 slot_colors_out[slot_id] = spool_color_hex
+            if slot_materials_out is not None and spool_material:
+                slot_materials_out[slot_id] = spool_material
 
         try:
             await client.use_spool(spool_id_to_use, grams_used)
@@ -582,6 +594,7 @@ async def _report_spool_usage_split_by_tray_changes(
     printer_serial: str,
     printer_id: int,
     slot_colors_out: dict[int, str] | None = None,
+    slot_materials_out: dict[int, str] | None = None,
 ) -> tuple[int, set[int]]:
     """Split each slot's grams across ``tray_changes`` and charge per-segment.
 
@@ -634,6 +647,7 @@ async def _report_spool_usage_split_by_tray_changes(
             spool_id_to_use: int | None = None
             resolution_path = ""
             spool_color_hex: str | None = None
+            spool_material: str | None = None
 
             spool_tag = _resolve_spool_tag(tray_info, printer_serial, tray_global) if tray_info else ""
             if spool_tag:
@@ -642,6 +656,7 @@ async def _report_spool_usage_split_by_tray_changes(
                     spool_id_to_use = spool["id"]
                     resolution_path = "tag"
                     spool_color_hex = (spool.get("filament") or {}).get("color_hex")
+                    spool_material = (spool.get("filament") or {}).get("material")
 
             if spool_id_to_use is None:
                 seg_ams_id, seg_tray_id = _global_tray_id_to_ams_slot(tray_global)
@@ -659,19 +674,27 @@ async def _report_spool_usage_split_by_tray_changes(
                 )
                 continue
 
-            # Colour rewrite (#1494) — first segment for a slot wins. The
-            # UI displays a single colour per slot, so later segments on the
-            # same slot don't overwrite (a backup swap can be a different
-            # colour but the archive card stays consistent with the origin).
-            if slot_colors_out is not None and slot_id not in slot_colors_out:
-                if spool_color_hex is None:
-                    try:
-                        full_spool = await client.get_spool(spool_id_to_use)
-                        spool_color_hex = (full_spool.get("filament") or {}).get("color_hex")
-                    except Exception as exc:  # noqa: BLE001 — colour is non-critical
-                        logger.debug("[SPOOLMAN] Split slot %s: could not fetch spool colour: %s", slot_id, exc)
-                if spool_color_hex:
-                    slot_colors_out[slot_id] = spool_color_hex
+            # Colour (#1494) + material (#2563) rewrite — first segment for a
+            # slot wins. The UI displays a single colour/type per slot, so
+            # later segments on the same slot don't overwrite (a backup swap
+            # can differ but the archive card stays consistent with the origin).
+            need_color = slot_colors_out is not None and slot_id not in slot_colors_out and spool_color_hex is None
+            need_material = (
+                slot_materials_out is not None and slot_id not in slot_materials_out and spool_material is None
+            )
+            if need_color or need_material:
+                try:
+                    _fil = (await client.get_spool(spool_id_to_use)).get("filament") or {}
+                    if need_color:
+                        spool_color_hex = _fil.get("color_hex")
+                    if need_material:
+                        spool_material = _fil.get("material")
+                except Exception as exc:  # noqa: BLE001 — colour/material are non-critical
+                    logger.debug("[SPOOLMAN] Split slot %s: could not fetch spool filament: %s", slot_id, exc)
+            if slot_colors_out is not None and slot_id not in slot_colors_out and spool_color_hex:
+                slot_colors_out[slot_id] = spool_color_hex
+            if slot_materials_out is not None and slot_id not in slot_materials_out and spool_material:
+                slot_materials_out[slot_id] = spool_material
 
             try:
                 await client.use_spool(spool_id_to_use, round(segment_grams, 2))
@@ -978,6 +1001,7 @@ async def report_usage(printer_id: int, archive_id: int):
         _layer_denom_hint = _total_layers or _current_layer
 
         slot_colors: dict[int, str] = {}
+        slot_materials: dict[int, str] = {}
         handled_global_tray_ids: set[int] = set()
         spools_updated = 0
 
@@ -1022,6 +1046,7 @@ async def report_usage(printer_id: int, archive_id: int):
                     printer_serial,
                     printer_id=printer_id,
                     slot_colors_out=slot_colors,
+                    slot_materials_out=slot_materials,
                 )
                 spools_updated += split_updated
                 handled_global_tray_ids |= split_handled
@@ -1037,6 +1062,7 @@ async def report_usage(printer_id: int, archive_id: int):
                     printer_serial,
                     printer_id=printer_id,
                     slot_colors_out=slot_colors,
+                    slot_materials_out=slot_materials,
                 )
                 # Track which physical slots the 3MF path already covered so
                 # Path 2 doesn't double-charge them.
@@ -1060,6 +1086,7 @@ async def report_usage(printer_id: int, archive_id: int):
                 handled_global_tray_ids=handled_global_tray_ids,
                 archive_id=archive_id,
                 slot_colors_out=slot_colors,
+                slot_materials_out=slot_materials,
             )
             spools_updated += fallback_updates
 
@@ -1073,6 +1100,10 @@ async def report_usage(printer_id: int, archive_id: int):
         # value (#1494) — mirrors the built-in inventory path in usage_tracker.
         await _apply_spool_colors_to_archive(db, archive_id, filament_usage, slot_colors)
 
+        # Same for the material: a slot mapped to a differently-typed spool than
+        # it was sliced for otherwise records the sliced type (#2563).
+        await _apply_spool_types_to_archive(db, archive_id, filament_usage, slot_materials)
+
 
 async def _report_remain_delta_for_slots(
     client,
@@ -1083,6 +1114,7 @@ async def _report_remain_delta_for_slots(
     handled_global_tray_ids: set[int],
     archive_id: int,
     slot_colors_out: dict[int, str] | None = None,
+    slot_materials_out: dict[int, str] | None = None,
 ) -> int:
     """AMS remain%-delta path: write ``(start - current) * filament.weight``
     grams to Spoolman for slots the 3MF path didn't cover.
@@ -1167,15 +1199,19 @@ async def _report_remain_delta_for_slots(
             continue
 
         spools_updated += 1
+        # No 3MF slot_id for this path — use the AMS slot key so the maps can
+        # still be inspected by callers if needed. The archive rewrites
+        # (#1494 colour, #2563 type) key on 3MF slot_ids, so remain-delta-only
+        # prints intentionally don't participate (matches usage_tracker's
+        # slot_id=None).
         if slot_colors_out is not None:
             color = filament.get("color_hex")
             if color:
-                # No 3MF slot_id for this path — use the AMS slot key so the
-                # colour map can still be inspected by callers if needed.
-                # The archive-colour rewrite (#1494) keys on 3MF slot_ids so
-                # remain-delta-only prints intentionally don't participate
-                # in that rewrite (matches usage_tracker's slot_id=None).
                 slot_colors_out[-(global_tray_id + 1)] = color
+        if slot_materials_out is not None:
+            material = filament.get("material")
+            if material:
+                slot_materials_out[-(global_tray_id + 1)] = material
         logger.info(
             "[SPOOLMAN] Archive %s AMS%d-T%d: %.2fg via remain-delta (%d%% of %.0fg) -> spool %s",
             archive_id,
@@ -1230,3 +1266,44 @@ async def _apply_spool_colors_to_archive(
         )
         archive.filament_color = joined
         await db.commit()
+
+
+async def _apply_spool_types_to_archive(
+    db,
+    archive_id: int,
+    filament_usage: list[dict],
+    slot_materials: dict[int, str],
+) -> None:
+    """Overwrite an archive's ``filament_type`` with the materials of the
+    Spoolman spools that fed the print (#2563).
+
+    All-or-nothing, exactly like the colour path and the built-in inventory
+    path: the type is only rewritten when every used slot resolved to a spool
+    that carries a material, so a partial match never drops slots from the
+    archive or the material statistics.
+    """
+    if not slot_materials:
+        return
+
+    from backend.app.models.archive import PrintArchive
+    from backend.app.services.usage_tracker import _archive_types_from_spools
+
+    results = [{"slot_id": sid, "material": material} for sid, material in slot_materials.items()]
+    types = _archive_types_from_spools(filament_usage, results)
+    if not types:
+        return
+
+    archive = (await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))).scalar_one_or_none()
+    if archive is None:
+        return
+
+    joined = ",".join(types)
+    if joined != archive.filament_type:
+        logger.info(
+            "[SPOOLMAN] Archive %s filament_type %r -> %r (from Spoolman spools)",
+            archive_id,
+            archive.filament_type,
+            joined,
+        )
+        archive.filament_type = joined
+        await db.commit()

+ 56 - 0
backend/app/services/usage_tracker.py

@@ -117,6 +117,47 @@ def _archive_colors_from_spools(filament_usage: list[dict], results: list[dict])
     return ordered
 
 
+def _archive_types_from_spools(filament_usage: list[dict], results: list[dict]) -> list[str] | None:
+    """Slot-ordered, de-duplicated materials for an archive's ``filament_type``,
+    taken from the inventory spools that actually fed the print (#2563).
+
+    The slicer's 3MF records the filament type it was *sliced for*. When the
+    user manually maps a slot to a differently-typed loaded spool in the Print
+    dialog — a PLA slice routed to the only loaded PETG slot — that sliced type
+    misclassifies the run in the archive card, the Print Log and the material
+    statistics, even though the deduction correctly hit the PETG spool. Once
+    usage tracking has resolved every used slot to an inventory spool, the
+    spool's declared material is the authoritative record of what was consumed,
+    the same reasoning that already adopts the spool colour (#1494).
+
+    Returns ``None`` — leave the 3MF type untouched — unless *every* slot with
+    non-zero usage was matched to a spool that carries a material. All-or-
+    nothing, exactly like ``_archive_colors_from_spools``: a partial rewrite
+    would silently drop the unmatched slots' types from the archive (and the
+    material stats).
+    """
+    used_slots = {u["slot_id"] for u in filament_usage if u.get("used_g", 0) > 0 and u.get("slot_id") is not None}
+    if not used_slots:
+        return None
+
+    slot_material: dict[int, str] = {}
+    for r in results:
+        slot_id = r.get("slot_id")
+        material = (r.get("material") or "").strip()
+        if slot_id is not None and material:
+            slot_material.setdefault(slot_id, material)
+
+    if not used_slots.issubset(slot_material):
+        return None
+
+    ordered: list[str] = []
+    for slot_id in sorted(used_slots):
+        material = slot_material[slot_id]
+        if material not in ordered:
+            ordered.append(material)
+    return ordered
+
+
 def _match_slots_by_color(
     filament_usage: list[dict],
     ams_raw: dict | list | None,
@@ -1396,4 +1437,19 @@ async def _track_from_3mf(
                 )
                 archive.filament_color = joined
 
+        # Adopt the matched spools' materials too (#2563) — a slot mapped to a
+        # differently-typed spool than it was sliced for otherwise records the
+        # sliced type in the archive, Print Log and material stats.
+        spool_types = _archive_types_from_spools(filament_usage, results)
+        if spool_types:
+            joined_types = ",".join(spool_types)
+            if joined_types != archive.filament_type:
+                logger.info(
+                    "[UsageTracker] 3MF: archive %s filament_type %r -> %r (from inventory spools)",
+                    archive_id,
+                    archive.filament_type,
+                    joined_types,
+                )
+                archive.filament_type = joined_types
+
     return results

+ 64 - 5
backend/app/services/virtual_printer/manager.py

@@ -128,6 +128,31 @@ _SLICER_OPTIONS_WAIT_TIMEOUT = 5.0
 # scheduler tick interval before dispatch picks the item up.
 _RECENT_QUEUE_ITEM_TTL = 30.0
 
+# BambuStudio's tri-state calibration options (bed_leveling / flow_cali /
+# nozzle_offset_cali) travel on the project_file command as a bool plus an int
+# companion — off=0, on=1, auto=2 (getValueInt parity). The int carries the full
+# state; the bool is true only for "on".
+_TRISTATE_INT = {0: "off", 1: "on", 2: "auto"}
+
+
+def _tristate_from_slicer(data: dict, bool_field: str, int_field: str) -> str | None:
+    """Reconstruct off/on/auto from a captured slicer project_file dict.
+
+    Prefer the int companion (auto_bed_leveling / extrude_cali_flag / etc.) which
+    carries all three states; fall back to the bool field (on/off only); return
+    None when the slicer sent neither so the caller can use its own default.
+    """
+    if int_field in data:
+        try:
+            resolved = _TRISTATE_INT.get(int(data[int_field]))
+        except (TypeError, ValueError):
+            resolved = None
+        if resolved is not None:
+            return resolved
+    if bool_field in data:
+        return "on" if bool(data[bool_field]) else "off"
+    return None
+
 
 def _get_serial_for_model(model: str, serial_suffix: str) -> str:
     """Get serial number for the given model and suffix."""
@@ -411,9 +436,16 @@ class VirtualPrinterInstance:
         # `nozzles_info` is intentionally not stamped — column kept for
         # legacy rows but never written; see PrintQueueItem.nozzles_info.
         patch: dict = {}
+        # Tri-state options (off/on/auto) — reconstruct from the int companion.
+        for bool_field, int_field, column in (
+            ("bed_leveling", "auto_bed_leveling", "bed_levelling"),
+            ("flow_cali", "extrude_cali_flag", "flow_cali"),
+        ):
+            resolved = _tristate_from_slicer(data, bool_field, int_field)
+            if resolved is not None:
+                patch[column] = resolved
+        # On/off options.
         for mqtt_field, column in (
-            ("bed_leveling", "bed_levelling"),
-            ("flow_cali", "flow_cali"),
             ("vibration_cali", "vibration_cali"),
             ("layer_inspect", "layer_inspect"),
             ("timelapse", "timelapse"),
@@ -714,6 +746,19 @@ class VirtualPrinterInstance:
                 def _bool_setting(value: str | None, default: bool) -> bool:
                     return value.lower() == "true" if value is not None else default
 
+                def _tristate_setting(value: str | None, default: str) -> str:
+                    """Tri-state workflow default, coercing legacy true/false rows."""
+                    if value is None:
+                        return default
+                    low = value.strip().lower()
+                    if low in ("on", "off", "auto"):
+                        return low
+                    if low in ("true", "1"):
+                        return "on"
+                    if low in ("false", "0"):
+                        return "off"
+                    return default
+
                 def _slicer_or(field_mqtt: str, settings_default: bool) -> bool:
                     """Slicer's MQTT value if present, else the settings default.
 
@@ -725,13 +770,27 @@ class VirtualPrinterInstance:
                         return bool(slicer_opts[field_mqtt])
                     return settings_default
 
+                def _slicer_tristate(bool_field: str, int_field: str, settings_default: str) -> str:
+                    """Slicer's tri-state (off/on/auto) if present, else the default."""
+                    if slicer_opts is not None:
+                        resolved = _tristate_from_slicer(slicer_opts, bool_field, int_field)
+                        if resolved is not None:
+                            return resolved
+                    return settings_default
+
                 # Note the MQTT field names differ from Bambuddy's column
                 # names: MQTT uses `bed_leveling` (single L) while the
                 # column / settings key use `bed_levelling` (double L).
-                bed_levelling = _slicer_or(
-                    "bed_leveling", _bool_setting(await get_setting(db, "default_bed_levelling"), True)
+                bed_levelling = _slicer_tristate(
+                    "bed_leveling",
+                    "auto_bed_leveling",
+                    _tristate_setting(await get_setting(db, "default_bed_levelling"), "auto"),
+                )
+                flow_cali = _slicer_tristate(
+                    "flow_cali",
+                    "extrude_cali_flag",
+                    _tristate_setting(await get_setting(db, "default_flow_cali"), "auto"),
                 )
-                flow_cali = _slicer_or("flow_cali", _bool_setting(await get_setting(db, "default_flow_cali"), False))
                 vibration_cali = _slicer_or(
                     "vibration_cali", _bool_setting(await get_setting(db, "default_vibration_cali"), True)
                 )

+ 101 - 26
backend/app/services/virtual_printer/mqtt_server.py

@@ -11,6 +11,7 @@ import json
 import logging
 import socket
 import ssl
+import time
 from collections.abc import Callable
 from pathlib import Path
 from typing import TYPE_CHECKING
@@ -41,6 +42,27 @@ _AUTH_RATE_LIMIT_WINDOW_SECONDS = 60.0
 # commands without ever consuming responses can't leak memory.
 _PENDING_REQUEST_MAX_ENTRIES = 256
 
+# Target-printer gcode_states for which the VP mirrors live print progress to
+# the slicer (#1887). BambuStudio and OrcaSlicer gate BOTH the Device-tab
+# progress panel and the Send button on the same predicate —
+# `MachineObject::is_in_printing()`, i.e. gcode_state in
+# {RUNNING, PAUSE, SLICING, PREPARE} — so reporting the real state verbatim
+# would show progress at the cost of blocking Send for as long as the printer
+# prints. That is exactly the #1558 regression. `FINISH` is the one state that
+# renders the progress panel (`is_in_printing() || print_status == "FINISH"` in
+# StatusPanel::update_subtask) while leaving Send enabled, so the mirror keeps
+# reporting FINISH and only fills in the numbers underneath it.
+_MIRRORED_PRINT_STATES = frozenset({"RUNNING", "PAUSE"})
+
+# How long after the last upload-state transition the VP keeps echoing the
+# slicer's own filename back at it before switching the report over to whatever
+# the target printer is really printing. The slicer releases its in-flight-job
+# lock when it sees gcode_state=FINISH carrying the subtask_name it uploaded
+# (#1280 / #1658); swapping in the printer's filename while that handshake is
+# still in flight wedges the send modal at "Downloading". 5 s covers the 1.5 s
+# `_schedule_finish_release` timer plus several 1 Hz pushes.
+_UPLOAD_SETTLE_SECONDS = 5.0
+
 # Model code → product_name for version response (must match what slicer expects)
 MODEL_PRODUCT_NAMES = {
     "BL-P001": "X1 Carbon",
@@ -245,6 +267,11 @@ class SimpleMQTTServer:
         self._gcode_state = "IDLE"
         self._current_file = ""
         self._prepare_percent = "0"
+        # Monotonic timestamp of the last upload-state transition, so the live-
+        # progress mirror can tell whether the slicer is still waiting on its
+        # own upload handshake. Starts at -inf: a VP that has never seen an
+        # upload has no handshake to protect and can mirror immediately.
+        self._state_changed_at = float("-inf")
 
         # MQTT bridge for non-proxy modes — set by VirtualPrinterInstance after start().
         # When the bridge is_active, real printer pushes are fanned out to slicers and
@@ -895,6 +922,11 @@ class SimpleMQTTServer:
         push_status shape against what it expects from the printer model, and
         the synthetic stub introduced fields the real H2D doesn't have (storage,
         the wrong chamber_temper shape, etc.) which trip the check.
+
+        While the target printer is actually printing and the VP has no upload
+        handshake of its own in flight, the live-progress fields are mirrored
+        through instead of zeroed, under a forced gcode_state=FINISH — see
+        ``_mirroring_live_progress`` for why that specific state (#1887).
         """
         try:
             self._sequence_id += 1
@@ -913,14 +945,23 @@ class SimpleMQTTServer:
                 print_block["sequence_id"] = str(self._sequence_id)
                 print_block["command"] = "push_status"
                 print_block["msg"] = 0
-                print_block["gcode_state"] = self._gcode_state
-                print_block["gcode_file"] = self._current_file
-                print_block["gcode_file_prepare_percent"] = self._prepare_percent
-                if self._current_file:
-                    print_block["subtask_name"] = self._current_file.replace(".3mf", "")
-                else:
-                    # Don't override real subtask_name with empty if no upload pending.
+                mirroring = self._mirroring_live_progress(cached)
+                if mirroring:
+                    # gcode_file / subtask_name / the progress fields stay as the
+                    # printer reported them — the slicer renders what is really
+                    # on the bed. FINISH keeps the Send button enabled.
+                    print_block["gcode_state"] = "FINISH"
+                    print_block["gcode_file_prepare_percent"] = "100"
                     print_block.setdefault("subtask_name", "")
+                else:
+                    print_block["gcode_state"] = self._gcode_state
+                    print_block["gcode_file"] = self._current_file
+                    print_block["gcode_file_prepare_percent"] = self._prepare_percent
+                    if self._current_file:
+                        print_block["subtask_name"] = self._current_file.replace(".3mf", "")
+                    else:
+                        # Don't override real subtask_name with empty if no upload pending.
+                        print_block.setdefault("subtask_name", "")
                 # Storage-availability indicators the slicer's "Send" pre-flight reads
                 # (#1228). P1S/A1-class firmware doesn't always include these in
                 # push_status (no SD card inserted, older field shapes), and BambuStudio
@@ -936,21 +977,25 @@ class SimpleMQTTServer:
                 print_block["sdcard"] = True
                 print_block.setdefault("storage", {"free": 1_000_000_000, "total": 32_000_000_000})
                 # Live-progress fields the slicer's Send pre-flight reads
-                # (#1558). When the real target printer is mid-print, the
-                # cached push_status carries the real values for these
-                # fields and the slicer reads the VP as "busy" — refusing
-                # Send — even though gcode_state above is forced to IDLE.
-                # For VP usage the VP isn't actually running the print
-                # the printer is, so these need to mirror the synthetic
-                # stub's idle values. Same shape as #1228 (storage) — the
-                # cached-branch override set just needed extending.
-                print_block["mc_print_stage"] = ""
-                print_block["mc_percent"] = 0
-                print_block["mc_remaining_time"] = 0
-                print_block["stg"] = []
-                print_block["stg_cur"] = 0
-                print_block["layer_num"] = 0
-                print_block["total_layer_num"] = 0
+                # (#1558). When the VP reports itself idle, these have to read
+                # idle too: the cached push_status carries the printer's real
+                # values, and a report that says gcode_state=IDLE while
+                # mc_percent>0 / stg_cur>0 is internally contradictory — the
+                # slicer takes it as busy and blocks Send. Same shape as #1228
+                # (storage). When the mirror is on, gcode_state=FINISH agrees
+                # with a non-zero progress set, so they pass through instead.
+                if not mirroring:
+                    print_block["mc_print_stage"] = ""
+                    print_block["mc_percent"] = 0
+                    print_block["mc_remaining_time"] = 0
+                    print_block["stg"] = []
+                    print_block["stg_cur"] = 0
+                    print_block["layer_num"] = 0
+                    print_block["total_layer_num"] = 0
+                # print_error is never mirrored: StatusPanel raises a modal error
+                # dialog for a non-zero code, and the VP is not the machine that
+                # threw it — the user's own printer card in Bambuddy reports the
+                # fault. Zero it in both branches.
                 print_block["print_error"] = 0
                 status = {"print": print_block}
                 dump_wire(self.vp_name, "out", status)
@@ -1126,6 +1171,36 @@ class SimpleMQTTServer:
         self._gcode_state = state
         self._current_file = filename
         self._prepare_percent = prepare_percent
+        self._state_changed_at = time.monotonic()
+
+    def _mirroring_live_progress(self, cached: dict) -> bool:
+        """True when the report should carry the target printer's live progress.
+
+        The slicers gate the Device-tab progress panel and the Send button on
+        the same predicate (``MachineObject::is_in_printing()``), so the VP
+        cannot report the printer's real gcode_state without also telling the
+        slicer it is too busy to accept a job — which is the whole point of a
+        non-proxy VP, and was the #1558 regression. Reporting FINISH instead
+        renders the panel (StatusPanel checks ``is_in_printing() ||
+        print_status == "FINISH"``) and leaves Send enabled, so the mirror is
+        FINISH plus the printer's real numbers.
+
+        Two things suppress it:
+
+        * The VP's own upload state machine owns the report while a job is
+          being handed over (PREPARE), and for a short settle window after —
+          the slicer only releases its in-flight-job lock once it sees FINISH
+          carrying the ``subtask_name`` it just uploaded (#1280 / #1658), and
+          swapping in the printer's filename mid-handshake wedges the send
+          modal at "Downloading".
+        * The printer isn't printing, in which case there is no progress to
+          show and the VP's own state is the honest thing to report.
+        """
+        if self._gcode_state == "PREPARE":
+            return False
+        if time.monotonic() - self._state_changed_at < _UPLOAD_SETTLE_SECONDS:
+            return False
+        return str(cached.get("gcode_state") or "").upper() in _MIRRORED_PRINT_STATES
 
     async def _publish_to_report(
         self, writer: asyncio.StreamWriter, payload: dict, serial: str = "", log_event: bool = True
@@ -1207,10 +1282,10 @@ class SimpleMQTTServer:
         self, writer: asyncio.StreamWriter, sequence_id: str, filename: str, serial: str | None = None
     ) -> None:
         """Send project_file acknowledgment matching real Bambu printer behavior."""
-        # Update state so periodic status pushes reflect preparation
-        self._gcode_state = "PREPARE"
-        self._current_file = filename
-        self._prepare_percent = "0"
+        # Update state so periodic status pushes reflect preparation. Goes
+        # through set_gcode_state so the live-progress mirror sees the
+        # transition and holds off until the upload handshake has settled.
+        self.set_gcode_state("PREPARE", filename=filename, prepare_percent="0")
 
         try:
             # Send command acknowledgment — slicer expects to see

+ 112 - 0
backend/app/utils/local_time.py

@@ -0,0 +1,112 @@
+"""Local-timezone helpers.
+
+Bambuddy has no timezone *setting* — it takes the container's ``TZ`` env var,
+the same value the support package reports. Anything that has to reason about a
+calendar day ("today", "yesterday", "run the backup at 03:00") needs this,
+because a day boundary computed in UTC rolls over at 01:00 or 02:00 wall-clock
+for most of Europe, which is neither what the user sees nor what their smart
+plug's own daily counter does.
+
+Lived in ``services/local_backup`` until #2539, when the smart-plug energy
+history needed the same day boundary and reaching into another service's private
+helper stopped being defensible.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+from datetime import datetime, timedelta, timezone, tzinfo
+from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
+
+logger = logging.getLogger(__name__)
+
+
+def local_zone() -> tzinfo:
+    """Resolve the local timezone from the ``TZ`` env var.
+
+    Falls back to UTC when ``TZ`` is unset or unrecognised, so a missing value
+    degrades to the legacy behaviour rather than crashing.
+
+    On Windows the embedded Python in our installer doesn't carry an IANA tz
+    database, so ``ZoneInfo(...)`` — including ``ZoneInfo("UTC")`` — raises
+    ``ZoneInfoNotFoundError`` unless the ``tzdata`` PyPI package is installed.
+    requirements.txt pins ``tzdata`` on win32, but to stay resilient on installs
+    that haven't refreshed deps we fall through to the stdlib
+    ``datetime.timezone.utc`` as a last resort; it satisfies every
+    ``astimezone`` / ``str()`` call site without needing the IANA DB.
+    """
+    tz_name = os.environ.get("TZ", "").strip()
+    if tz_name:
+        try:
+            return ZoneInfo(tz_name)
+        except ZoneInfoNotFoundError:
+            logger.warning("Unrecognised TZ env value %r, falling back to UTC", tz_name)
+    try:
+        return ZoneInfo("UTC")
+    except ZoneInfoNotFoundError:
+        return timezone.utc
+
+
+def utcnow_naive() -> datetime:
+    """Current UTC time, tzinfo stripped.
+
+    Bambuddy's ``DateTime`` columns are naive and hold UTC; only the few that
+    genuinely need an offset are declared ``DateTime(timezone=True)``. SQLite
+    silently tolerates an aware value written to a naive column (its bind
+    processor reads the fields and drops the offset), which is why aware writes
+    survived here for so long — but **asyncpg rejects them outright** with
+    ``DataError: invalid input for query argument``, so on Postgres the write
+    raises. Use this for anything destined for a naive column.
+    """
+    return datetime.now(timezone.utc).replace(tzinfo=None)
+
+
+def to_naive_utc(dt: datetime | None) -> datetime | None:
+    """Normalise a datetime to naive UTC for binding against a naive column.
+
+    Accepts naive (assumed already UTC) or aware; returns None unchanged.
+    """
+    if dt is None:
+        return None
+    if dt.tzinfo is None:
+        return dt
+    return dt.astimezone(timezone.utc).replace(tzinfo=None)
+
+
+def local_day_start(now_utc: datetime, *, days_ago: int = 0) -> datetime:
+    """Return midnight local time, ``days_ago`` days back, as a UTC instant.
+
+    ``days_ago=0`` is the midnight that began the current local day; ``1`` is the
+    one before it. Subtracting whole days from the *local* wall clock rather than
+    from the UTC instant is what keeps this correct across a DST transition, where
+    a calendar day is 23 or 25 hours long, not 24.
+
+    ``fold=0`` resolves the ambiguous wall-clock hour at DST fall-back to the
+    earlier instance. The spring-forward gap cannot bite here: the synthesized
+    time is always midnight, and no timezone in the IANA database skips it.
+    """
+    tz = local_zone()
+    local_now = now_utc.astimezone(tz)
+    local_midnight = local_now.replace(hour=0, minute=0, second=0, microsecond=0, fold=0)
+    if days_ago:
+        # Step back in local days, then re-pin to midnight: (midnight - 24h) can
+        # land at 23:00 or 01:00 of the previous day across a DST change.
+        local_midnight = (local_midnight - timedelta(days=days_ago)).replace(
+            hour=0, minute=0, second=0, microsecond=0, fold=0
+        )
+    return local_midnight.astimezone(timezone.utc)
+
+
+def next_local_hour(now_utc: datetime) -> datetime:
+    """Return the next top-of-the-hour *local* time, as a UTC instant.
+
+    Aligning to the local hour rather than the UTC hour is deliberate: it
+    guarantees a tick lands exactly on local midnight in every timezone,
+    including the half- and quarter-hour offsets (India, Nepal, Chatham) where
+    local midnight is not on a UTC hour boundary at all.
+    """
+    tz = local_zone()
+    local_now = now_utc.astimezone(tz)
+    local_next = (local_now + timedelta(hours=1)).replace(minute=0, second=0, microsecond=0, fold=0)
+    return local_next.astimezone(timezone.utc)

+ 73 - 0
backend/app/utils/printer_models.py

@@ -137,6 +137,27 @@ NO_EXTERNAL_STORAGE_MODELS = frozenset(
 )
 
 
+# Models that HAVE a MicroSD slot but expose NO reachable control to enable
+# the "Store sent files on external storage" option. The toggle only renders
+# in Bambu Studio when the printer publishes the
+# `support_save_remote_print_file_to_storage` capability in its live status;
+# current P1-series firmware (through 01.10.00.00) never publishes it, and
+# the P1S/P1P have no on-printer screen, so `store_to_sdcard` (home_flag bit
+# 11) is stuck at False with no way for the user to change it. The
+# external_storage diagnostic must therefore skip (not fail) on these models
+# — a hard fail would be permanently unresolvable (#2524). If a future
+# firmware surfaces the capability, remove the model here and the check
+# reactivates. Bambu Lab's own storage-cache wiki lists P1 Series as "Not
+# Supported", corroborating this.
+NO_REMOTE_STORAGE_TOGGLE_MODELS = frozenset(
+    [
+        # Display names (uppercase, no spaces)
+        "P1S",
+        "P1P",
+    ]
+)
+
+
 # Models with an ethernet port.
 # X1, P1P, A1, A1 Mini do NOT have ethernet.
 ETHERNET_MODELS = frozenset(
@@ -214,6 +235,23 @@ def has_external_storage(model: str | None) -> bool:
     return normalized not in NO_EXTERNAL_STORAGE_MODELS
 
 
+def has_remote_storage_toggle(model: str | None) -> bool:
+    """Return True if the model exposes a reachable control for the
+    "Store sent files on external storage" option.
+
+    False for P1-series (has an SD slot, but no on-printer screen and no
+    published `support_save_remote_print_file_to_storage` capability, so the
+    Bambu Studio toggle never renders). The external_storage diagnostic uses
+    this to skip rather than report an unresolvable fail (#2524). Defaults to
+    True for unknown models so the check keeps working on anything not
+    explicitly listed.
+    """
+    if not model:
+        return True
+    normalized = model.strip().upper().replace(" ", "").replace("-", "")
+    return normalized not in NO_REMOTE_STORAGE_TOGGLE_MODELS
+
+
 def is_dual_nozzle_model(model: str | None) -> bool:
     """Return True if the printer model has two nozzles (H2D family / X2D)."""
     if not model:
@@ -243,6 +281,41 @@ def get_rod_type(model: str | None) -> str | None:
     return None
 
 
+# G-code interchange families (#2578). A sliced 3MF may target a different
+# model ONLY within its family: same kinematics, build volume and G-code
+# dialect. X1/P1 series are the one proven-interchangeable group (256mm
+# CoreXY, single nozzle — mixed farms intentionally run X1-sliced jobs on
+# P1S/P1P). Everything else is exact-match only; extend deliberately, never
+# by assumption — a wrong entry here dispatches G-code onto hardware it was
+# not sliced for.
+# Short display names only (uppercase, no spaces) — is_gcode_compatible()
+# resolves internal codes (C11, O1D, ...) to short names before lookup.
+GCODE_COMPAT_FAMILIES = (frozenset(["X1", "X1C", "X1E", "P1P", "P1S"]),)
+
+
+def is_gcode_compatible(sliced_for_model: str | None, target_model: str | None) -> bool:
+    """Return True when G-code sliced for one model may be dispatched to the other.
+
+    Unknown/missing metadata on either side returns True — we can only
+    validate what the 3MF declares, and legacy files without
+    ``sliced_for_model`` must keep working.
+    """
+    if not sliced_for_model or not target_model:
+        return True
+
+    def _norm(model: str) -> str:
+        # Internal codes (e.g. "C11") → short names first, so "C11" vs "X1C"
+        # compares equal instead of leaning on family membership.
+        resolved = PRINTER_MODEL_ID_MAP.get(model.strip(), model)
+        return resolved.strip().upper().replace(" ", "").replace("-", "")
+
+    a = _norm(sliced_for_model)
+    b = _norm(target_model)
+    if a == b:
+        return True
+    return any(a in family and b in family for family in GCODE_COMPAT_FAMILIES)
+
+
 def normalize_printer_model_id(model_id: str | None) -> str | None:
     """Convert printer_model_id (internal code) to normalized short name.
 

+ 180 - 132
backend/app/utils/threemf_tools.py

@@ -11,7 +11,10 @@ import logging
 import math
 import re
 import zipfile
+from collections import OrderedDict
+from dataclasses import dataclass, field
 from pathlib import Path
+from threading import Lock
 
 import defusedxml.ElementTree as ET
 
@@ -424,6 +427,178 @@ def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | Non
         return None
 
 
+@dataclass(frozen=True)
+class PlateMetadata:
+    """Combined per-plate slice_info.config values from a single 3MF parse.
+
+    Bundles the three fields the queue listing needs so a queue poll opens and
+    parses each 3MF once instead of three times (#2573). ``filament_usage`` is
+    the full per-filament list (other callers — usage tracking, Spoolman — need
+    it); ``filament_used_grams`` is its ``used_g`` sum, precomputed here so the
+    queue path doesn't re-sum on every hit.
+    """
+
+    print_time_seconds: int | None = None
+    filament_usage: list[dict] = field(default_factory=list)
+    bed_type: str | None = None
+    filament_used_grams: float = 0.0
+
+
+_EMPTY_PLATE_METADATA = PlateMetadata()
+
+# Revision-keyed cache for parsed per-plate metadata. Queue polling re-lists the
+# same unchanged 3MFs every few seconds per connected client (#2573); without a
+# cache each row costs a ZIP open + XML parse. The key includes the file's
+# mtime_ns and size so a replaced or edited file transparently gets a fresh
+# entry — no manual invalidation needed. Bounded LRU + lock so it stays small
+# and is safe to touch from worker threads.
+_PLATE_METADATA_CACHE: "OrderedDict[tuple, PlateMetadata]" = OrderedDict()
+_PLATE_METADATA_CACHE_LOCK = Lock()
+_PLATE_METADATA_CACHE_MAX = 512
+
+
+def clear_plate_metadata_cache() -> None:
+    """Drop all cached per-plate metadata (used by tests)."""
+    with _PLATE_METADATA_CACHE_LOCK:
+        _PLATE_METADATA_CACHE.clear()
+
+
+def _parse_plate_metadata_uncached(file_path: Path, plate_id: int | None) -> PlateMetadata:
+    """Open the 3MF once and pull print time, filament usage and bed type.
+
+    Replicates the per-field ``plate_id=None`` behaviour of the three legacy
+    helpers exactly: usage collects every ``<filament>`` in the file, while
+    print time and bed type come from the first ``<plate>``.
+    """
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            if "Metadata/slice_info.config" not in zf.namelist():
+                return _EMPTY_PLATE_METADATA
+            content = zf.read("Metadata/slice_info.config").decode()
+            root = ET.fromstring(content)
+    except Exception as e:
+        logger.warning("Failed to read plate metadata from %s: %s", file_path, e)
+        return _EMPTY_PLATE_METADATA
+
+    def _plate_index(plate_elem) -> int | None:
+        for meta in plate_elem.findall("metadata"):
+            if meta.get("key") == "index":
+                try:
+                    return int(meta.get("value", "0"))
+                except ValueError:
+                    return None
+        return None
+
+    def _collect_filaments(plate_elem) -> list[dict]:
+        out: list[dict] = []
+        for f in plate_elem.findall("filament"):
+            filament_id = f.get("id")
+            # Both the used_g float() and the id int() must stay inside the guard:
+            # a non-numeric id or used_g is silently skipped (matches the legacy
+            # helpers, which tolerated garbage rows rather than raising — a raise
+            # here would 500 the whole queue listing).
+            try:
+                used_amount = float(f.get("used_g", "0"))
+                if filament_id:
+                    out.append(
+                        {
+                            "slot_id": int(filament_id),
+                            "used_g": used_amount,
+                            "type": f.get("type", ""),
+                            "color": f.get("color", ""),
+                        }
+                    )
+            except (ValueError, TypeError):
+                continue
+        return out
+
+    print_time: int | None = None
+    bed_type: str | None = None
+    filament_usage: list[dict] = []
+    matched_plate = None
+
+    if plate_id is not None:
+        for plate_elem in root.findall(".//plate"):
+            if _plate_index(plate_elem) == plate_id:
+                matched_plate = plate_elem
+                break
+    else:
+        matched_plate = root.find(".//plate")
+
+    if matched_plate is not None:
+        for meta in matched_plate.findall("metadata"):
+            key = meta.get("key")
+            if key == "prediction" and print_time is None:
+                try:
+                    print_time = int(meta.get("value", "0"))
+                except ValueError:
+                    print_time = None
+            elif key == "curr_bed_type" and meta.get("value"):
+                bed_type = (meta.get("value") or "").strip()
+
+    if plate_id is not None:
+        if matched_plate is not None:
+            filament_usage = _collect_filaments(matched_plate)
+    else:
+        # Legacy plate_id=None usage: every filament in the file, not just plate 1.
+        for f in root.findall(".//filament"):
+            filament_id = f.get("id")
+            # int()/float() both guarded — a garbage id/used_g row is skipped, not raised.
+            try:
+                used_amount = float(f.get("used_g", "0"))
+                if filament_id:
+                    filament_usage.append(
+                        {
+                            "slot_id": int(filament_id),
+                            "used_g": used_amount,
+                            "type": f.get("type", ""),
+                            "color": f.get("color", ""),
+                        }
+                    )
+            except (ValueError, TypeError):
+                continue
+
+    return PlateMetadata(
+        print_time_seconds=print_time,
+        filament_usage=filament_usage,
+        bed_type=bed_type,
+        filament_used_grams=sum(f["used_g"] for f in filament_usage),
+    )
+
+
+def extract_plate_metadata_from_3mf(file_path: Path, plate_id: int | None = None) -> PlateMetadata:
+    """Return combined per-plate metadata, cached by file revision (#2573).
+
+    The result is keyed by ``(path, plate_id, mtime_ns, size)`` so an unchanged
+    file is parsed at most once; a replaced/edited file re-parses automatically.
+    The returned ``PlateMetadata`` is shared and MUST be treated as read-only —
+    callers that need a mutable filament list get a copy from the wrappers below.
+    """
+    file_path = Path(file_path)
+    try:
+        stat = file_path.stat()
+    except OSError:
+        # File missing/unreadable: parse (which will return empty) but don't
+        # cache — the file may appear later and we don't want a sticky miss.
+        return _parse_plate_metadata_uncached(file_path, plate_id)
+
+    key = (str(file_path), plate_id, stat.st_mtime_ns, stat.st_size)
+    with _PLATE_METADATA_CACHE_LOCK:
+        cached = _PLATE_METADATA_CACHE.get(key)
+        if cached is not None:
+            _PLATE_METADATA_CACHE.move_to_end(key)
+            return cached
+
+    metadata = _parse_plate_metadata_uncached(file_path, plate_id)
+
+    with _PLATE_METADATA_CACHE_LOCK:
+        _PLATE_METADATA_CACHE[key] = metadata
+        _PLATE_METADATA_CACHE.move_to_end(key)
+        while len(_PLATE_METADATA_CACHE) > _PLATE_METADATA_CACHE_MAX:
+            _PLATE_METADATA_CACHE.popitem(last=False)
+    return metadata
+
+
 def extract_filament_usage_from_3mf(file_path: Path, plate_id: int | None = None) -> list[dict]:
     """Extract per-filament total usage from 3MF slice_info.config.
 
@@ -438,68 +613,9 @@ def extract_filament_usage_from_3mf(file_path: Path, plate_id: int | None = None
         List of filament usage dictionaries:
         [{"slot_id": 1, "used_g": 50.5, "type": "PLA", "color": "#FF0000"}, ...]
     """
-    filament_usage = []
-    try:
-        with zipfile.ZipFile(file_path, "r") as zf:
-            if "Metadata/slice_info.config" not in zf.namelist():
-                return []
-
-            content = zf.read("Metadata/slice_info.config").decode()
-            root = ET.fromstring(content)
-
-            if plate_id is not None:
-                # Find the plate element with matching index
-                for plate_elem in root.findall(".//plate"):
-                    plate_index = None
-                    for meta in plate_elem.findall("metadata"):
-                        if meta.get("key") == "index":
-                            try:
-                                plate_index = int(meta.get("value", "0"))
-                            except ValueError:
-                                pass
-                            break
-
-                    if plate_index == plate_id:
-                        for f in plate_elem.findall("filament"):
-                            filament_id = f.get("id")
-                            used_g = f.get("used_g", "0")
-                            try:
-                                used_amount = float(used_g)
-                                if filament_id:
-                                    filament_usage.append(
-                                        {
-                                            "slot_id": int(filament_id),
-                                            "used_g": used_amount,
-                                            "type": f.get("type", ""),
-                                            "color": f.get("color", ""),
-                                        }
-                                    )
-                            except (ValueError, TypeError):
-                                pass
-                        break
-            else:
-                # No plate_id specified - extract all filaments
-                for f in root.findall(".//filament"):
-                    filament_id = f.get("id")
-                    used_g = f.get("used_g", "0")
-                    try:
-                        used_amount = float(used_g)
-                        if filament_id:
-                            filament_usage.append(
-                                {
-                                    "slot_id": int(filament_id),
-                                    "used_g": used_amount,
-                                    "type": f.get("type", ""),
-                                    "color": f.get("color", ""),
-                                }
-                            )
-                    except (ValueError, TypeError):
-                        pass  # Skip filament entries with unparseable usage values
-
-    except Exception:
-        pass  # Return whatever usage data was collected before the error
-
-    return filament_usage
+    # Delegate to the cached combined parse (#2573). Return fresh dicts so callers
+    # that mutate the list don't corrupt the shared cached PlateMetadata.
+    return [dict(f) for f in extract_plate_metadata_from_3mf(file_path, plate_id).filament_usage]
 
 
 def extract_print_time_from_3mf(file_path: Path, plate_id: int | None = None) -> int | None:
@@ -518,46 +634,7 @@ def extract_print_time_from_3mf(file_path: Path, plate_id: int | None = None) ->
     Returns:
         Predicted print time in seconds, or None if not found / unparseable.
     """
-    try:
-        with zipfile.ZipFile(file_path, "r") as zf:
-            if "Metadata/slice_info.config" not in zf.namelist():
-                return None
-
-            content = zf.read("Metadata/slice_info.config").decode()
-            root = ET.fromstring(content)
-
-            if plate_id is not None:
-                for plate_elem in root.findall(".//plate"):
-                    plate_index = None
-                    for meta in plate_elem.findall("metadata"):
-                        if meta.get("key") == "index":
-                            try:
-                                plate_index = int(meta.get("value", "0"))
-                            except ValueError:
-                                pass  # Skip plate with unparseable index
-                            break
-
-                    if plate_index == plate_id:
-                        for meta in plate_elem.findall("metadata"):
-                            if meta.get("key") == "prediction":
-                                try:
-                                    return int(meta.get("value", "0"))
-                                except ValueError:
-                                    return None
-                        break
-            else:
-                plate_elem = root.find(".//plate")
-                if plate_elem is not None:
-                    for meta in plate_elem.findall("metadata"):
-                        if meta.get("key") == "prediction":
-                            try:
-                                return int(meta.get("value", "0"))
-                            except ValueError:
-                                return None
-    except Exception as e:
-        logger.warning("Failed to extract print time from %s: %s", file_path, e)
-
-    return None
+    return extract_plate_metadata_from_3mf(file_path, plate_id).print_time_seconds
 
 
 def extract_bed_type_from_3mf(file_path: Path, plate_id: int | None = None) -> str | None:
@@ -578,36 +655,7 @@ def extract_bed_type_from_3mf(file_path: Path, plate_id: int | None = None) -> s
     Returns:
         Bed type string (e.g. "Textured PEI Plate"), or None if not found.
     """
-    try:
-        with zipfile.ZipFile(file_path, "r") as zf:
-            if "Metadata/slice_info.config" not in zf.namelist():
-                return None
-
-            content = zf.read("Metadata/slice_info.config").decode()
-            root = ET.fromstring(content)
-
-            for plate_elem in root.findall(".//plate"):
-                plate_index = None
-                bed_value: str | None = None
-                for meta in plate_elem.findall("metadata"):
-                    key = meta.get("key")
-                    if key == "index":
-                        try:
-                            plate_index = int(meta.get("value", "0"))
-                        except ValueError:
-                            pass  # Skip plate with unparseable index
-                    elif key == "curr_bed_type" and meta.get("value"):
-                        bed_value = (meta.get("value") or "").strip()
-
-                if plate_id is None:
-                    # First plate wins when no plate_id is requested.
-                    return bed_value
-                if plate_index == plate_id:
-                    return bed_value
-    except Exception:
-        pass  # Return None on any failure rather than raising — caller decides
-
-    return None
+    return extract_plate_metadata_from_3mf(file_path, plate_id).bed_type
 
 
 # Header values exposed as `{placeholder}` substitutions inside snippets.

+ 16 - 0
backend/tests/conftest.py

@@ -95,6 +95,22 @@ def reset_spoolman_location_sync_cache():
     _spoolman_location_sync_cache_clear()
 
 
+@pytest.fixture(autouse=True)
+def reset_auth_enabled_cache():
+    """Drop the module-level auth-enabled cache between tests (issue #2572).
+
+    ``is_auth_enabled`` caches an enabled=True result for a TTL. Without this
+    reset a test that enables auth would leave ``True`` cached, so a later test
+    running in auth-disabled mode (without going through ``set_auth_enabled``)
+    would wrongly see auth as enabled until the TTL expired — order-dependent
+    flakiness."""
+    from backend.app.core.auth import invalidate_auth_enabled_cache
+
+    invalidate_auth_enabled_cache()
+    yield
+    invalidate_auth_enabled_cache()
+
+
 @pytest.fixture(scope="session")
 def event_loop():
     """Create an instance of the default event loop for each test session."""

+ 26 - 0
backend/tests/integration/test_camera_api.py

@@ -811,3 +811,29 @@ class TestCameraAPI:
         assert response.status_code == 200
         result = response.json()
         assert result["cameras"] == []
+
+
+class TestCameraStreamPoolHygiene:
+    """Regression guard for the camera-stream DB-connection leak (issue #2572)."""
+
+    def test_camera_stream_does_not_hold_a_get_db_session(self):
+        """The MJPEG stream endpoint must NOT take a ``Depends(get_db)`` session.
+
+        ``get_db`` is a ``yield`` dependency, so its session stays open until the
+        response body is fully consumed — for a live MJPEG stream that is the
+        whole time the browser tab is open (hours), pinning one pooled DB
+        connection per open camera tab per printer. The endpoint fetches the
+        printer in a short-lived ``async with async_session()`` instead and
+        releases the connection before streaming. If someone re-adds a
+        ``Depends(get_db)`` param, this fails.
+        """
+        import inspect
+
+        from backend.app.api.routes.camera import camera_stream, get_db
+
+        for name, param in inspect.signature(camera_stream).parameters.items():
+            dependency = getattr(param.default, "dependency", None)
+            assert dependency is not get_db, (
+                f"camera_stream re-introduced a get_db-held session via parameter {name!r} — "
+                "it would stay open for the entire stream (issue #2572)"
+            )

+ 203 - 0
backend/tests/integration/test_camwall_api.py

@@ -0,0 +1,203 @@
+"""Integration tests for the token-authenticated Cam Wall feed (#2531).
+
+The feature's whole reason for existing as a separate endpoint (rather than
+letting a token through to ``GET /printers``) is that a kiosk URL is not a
+secret. So the tests that matter here are the negative ones: what a Cam Wall
+token *cannot* reach, and what the payload *does not* contain.
+"""
+
+from __future__ import annotations
+
+import pytest
+from httpx import AsyncClient
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+async def _setup_admin(async_client: AsyncClient, *, suffix: str) -> str:
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={
+            "auth_enabled": True,
+            "admin_username": f"camwalladmin{suffix}",
+            "admin_password": "AdminPass1!",
+        },
+    )
+    login = await async_client.post(
+        "/api/v1/auth/login",
+        json={"username": f"camwalladmin{suffix}", "password": "AdminPass1!"},
+    )
+    return login.json()["access_token"]
+
+
+async def _mint(async_client: AsyncClient, jwt: str, *, scope: str, name: str = "kiosk") -> str:
+    response = await async_client.post(
+        "/api/v1/auth/tokens",
+        headers={"Authorization": f"Bearer {jwt}"},
+        json={"name": name, "expires_in_days": 30, "scope": scope},
+    )
+    assert response.status_code == 201, response.text
+    assert response.json()["scope"] == scope
+    return response.json()["token"]
+
+
+@pytest.fixture
+async def printer_row(db_session):
+    """Insert the printer straight into the DB.
+
+    POST /printers probes the real device before it will store a row, and there
+    is no printer on the other end of a test run.
+    """
+    from backend.app.models.printer import Printer
+
+    printer = Printer(
+        name="Wall P1S",
+        ip_address="192.168.1.77",
+        access_code="12345678",
+        serial_number="01P00A000000001",
+        model="P1S",
+    )
+    db_session.add(printer)
+    await db_session.commit()
+    return printer
+
+
+class TestCamWallFeedAuth:
+    async def test_no_token_is_rejected(self, async_client: AsyncClient):
+        await _setup_admin(async_client, suffix="_notoken")
+        response = await async_client.get("/api/v1/camwall/printers")
+        assert response.status_code == 401
+
+    async def test_garbage_token_is_rejected(self, async_client: AsyncClient):
+        await _setup_admin(async_client, suffix="_garbage")
+        response = await async_client.get("/api/v1/camwall/printers?token=bblt_aaaaaaaa_nope")
+        assert response.status_code == 401
+
+    async def test_camera_stream_token_cannot_reach_the_feed(self, async_client: AsyncClient):
+        """The point of the separate scope.
+
+        ``camera_stream`` tokens are already in the wild, minted by users who
+        agreed to hand out *video*. Shipping the Cam Wall must not retroactively
+        grant them the ability to enumerate printers by name.
+        """
+        jwt = await _setup_admin(async_client, suffix="_wrongscope")
+        stream_token = await _mint(async_client, jwt, scope="camera_stream")
+
+        response = await async_client.get(f"/api/v1/camwall/printers?token={stream_token}")
+        assert response.status_code == 401
+
+    async def test_camwall_token_reaches_the_feed(self, async_client: AsyncClient, printer_row):
+        jwt = await _setup_admin(async_client, suffix="_rightscope")
+        camwall_token = await _mint(async_client, jwt, scope="camwall")
+
+        response = await async_client.get(f"/api/v1/camwall/printers?token={camwall_token}")
+        assert response.status_code == 200, response.text
+        body = response.json()
+        assert len(body) == 1
+        assert body[0]["name"] == "Wall P1S"
+
+    async def test_revoked_camwall_token_is_rejected(self, async_client: AsyncClient):
+        jwt = await _setup_admin(async_client, suffix="_revoked")
+        created = await async_client.post(
+            "/api/v1/auth/tokens",
+            headers={"Authorization": f"Bearer {jwt}"},
+            json={"name": "kiosk", "expires_in_days": 30, "scope": "camwall"},
+        )
+        camwall_token = created.json()["token"]
+        await async_client.delete(
+            f"/api/v1/auth/tokens/{created.json()['id']}",
+            headers={"Authorization": f"Bearer {jwt}"},
+        )
+
+        response = await async_client.get(f"/api/v1/camwall/printers?token={camwall_token}")
+        assert response.status_code == 401
+
+
+class TestCamWallFeedPayload:
+    async def test_payload_withholds_secrets_and_filenames(self, async_client: AsyncClient, printer_row):
+        """A URL taped to a TV must not disclose more than the picture does.
+
+        Serial number and IP ride along on the ordinary printer list even for
+        non-secret callers, and the filename names the customer's part. None of
+        the three may appear here.
+        """
+        jwt = await _setup_admin(async_client, suffix="_payload")
+        camwall_token = await _mint(async_client, jwt, scope="camwall")
+
+        response = await async_client.get(f"/api/v1/camwall/printers?token={camwall_token}")
+        assert response.status_code == 200
+        entry = response.json()[0]
+
+        for leaked in ("serial_number", "ip_address", "access_code", "subtask_name", "gcode_file"):
+            assert leaked not in entry, f"{leaked} must not be served to a kiosk token"
+
+        assert set(entry) == {
+            "id",
+            "name",
+            "camera_rotation",
+            "connected",
+            "state",
+            "progress",
+            "remaining_time",
+            "layer_num",
+            "total_layers",
+            "hms_errors",
+        }
+
+    async def test_disconnected_printer_reports_connected_false(self, async_client: AsyncClient, printer_row):
+        """No MQTT client is running in tests, so the printer has no state at
+        all — the tile must render as offline rather than blank.
+        """
+        jwt = await _setup_admin(async_client, suffix="_offline")
+        camwall_token = await _mint(async_client, jwt, scope="camwall")
+
+        response = await async_client.get(f"/api/v1/camwall/printers?token={camwall_token}")
+        entry = response.json()[0]
+        assert entry["connected"] is False
+        assert entry["state"] is None
+        assert entry["hms_errors"] == []
+
+
+class TestCamWallTokenReachesTheVideo:
+    """A wall that can list the tiles but not fill them is useless — the same
+    token has to satisfy the camera-stream gate.
+    """
+
+    async def test_camwall_token_passes_the_camera_stream_gate(self, async_client: AsyncClient):
+        from backend.app.core.auth import verify_camera_stream_token
+
+        jwt = await _setup_admin(async_client, suffix="_video")
+        camwall_token = await _mint(async_client, jwt, scope="camwall")
+
+        assert await verify_camera_stream_token(camwall_token) is True
+
+    async def test_camera_stream_token_still_passes_its_own_gate(self, async_client: AsyncClient):
+        """Regression guard on #1108: widening the accepted scopes must not have
+        broken the tokens that were already working.
+        """
+        from backend.app.core.auth import verify_camera_stream_token
+
+        jwt = await _setup_admin(async_client, suffix="_video_legacy")
+        stream_token = await _mint(async_client, jwt, scope="camera_stream")
+
+        assert await verify_camera_stream_token(stream_token) is True
+
+    async def test_camwall_gate_rejects_a_camera_stream_token(self, async_client: AsyncClient):
+        from backend.app.core.auth import verify_camwall_token
+
+        jwt = await _setup_admin(async_client, suffix="_gate_narrow")
+        stream_token = await _mint(async_client, jwt, scope="camera_stream")
+
+        assert await verify_camwall_token(stream_token) is False
+
+
+class TestScopeValidation:
+    async def test_unknown_scope_is_rejected_at_mint(self, async_client: AsyncClient):
+        jwt = await _setup_admin(async_client, suffix="_badscope")
+        response = await async_client.post(
+            "/api/v1/auth/tokens",
+            headers={"Authorization": f"Bearer {jwt}"},
+            json={"name": "x", "expires_in_days": 30, "scope": "printers_write"},
+        )
+        assert response.status_code == 400
+        assert "unsupported scope" in response.json()["detail"].lower()

+ 13 - 2
backend/tests/integration/test_cloud_auth.py

@@ -583,11 +583,22 @@ class TestCloudRouteRegionPlumbing:
     @pytest.mark.integration
     async def test_cloud_status_exposes_stored_region(self, async_client: AsyncClient):
         """GET /cloud/status returns the stored region so the UI can render
-        'Connected (China)' after a reload."""
+        'Connected (China)' after a reload.
+
+        ``validate_token`` is stubbed because the endpoint now asks Bambu whether
+        the stored token is still accepted rather than assuming it is — without
+        the stub this test would make a live call to api.bambulab.cn with a fake
+        token, get a 401, and correctly report the session as expired. Region
+        plumbing is what's under test here.
+        """
         from backend.app.api.routes.cloud import store_token
         from backend.app.core.database import async_session
+        from backend.app.services.bambu_cloud import BambuCloudService
 
-        with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
+        with (
+            patch("backend.app.core.auth.is_auth_enabled", return_value=False),
+            patch.object(BambuCloudService, "validate_token", AsyncMock(return_value=True)),
+        ):
             async with async_session() as db:
                 await store_token(db, "cn-token", "token-auth", "china", user=None)
 

+ 277 - 0
backend/tests/integration/test_cloud_token_auth_migration.py

@@ -0,0 +1,277 @@
+"""Cloud-credential migration across the auth on/off boundary (#2530).
+
+``get_stored_token`` reads global ``Settings`` rows when auth is disabled and
+``User.cloud_token`` when it's enabled. Toggling auth therefore switches which
+store the ``/cloud/*`` routes consult. Without an explicit hand-off the token
+is stranded in the store nobody reads: ``build_authenticated_cloud`` returns
+``None``, Phase 2 of ``get_filament_info`` is skipped entirely, and the caller
+sees a ``200`` full of local-preset fallbacks with no sign the cloud was never
+contacted. That silent degradation is what #2530 actually reported.
+
+These tests pin the hand-off in both directions, and — just as importantly —
+pin the two cases where Bambuddy must refuse to guess who owns a credential.
+"""
+
+import logging
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.api.routes import cloud as cloud_routes
+from backend.app.api.routes.cloud import (
+    CLOUD_EMAIL_KEY,
+    CLOUD_REGION_KEY,
+    CLOUD_TOKEN_KEY,
+    get_stored_token,
+)
+from backend.app.core.auth import get_password_hash
+from backend.app.models.settings import Settings
+from backend.app.models.user import User
+from backend.app.services.bambu_cloud import BambuCloudError
+
+
+async def _seed_global_token(db: AsyncSession, token: str = "tok-global", region: str = "china") -> None:
+    db.add(Settings(key=CLOUD_TOKEN_KEY, value=token))
+    db.add(Settings(key=CLOUD_EMAIL_KEY, value="owner@example.com"))
+    db.add(Settings(key=CLOUD_REGION_KEY, value=region))
+    await db.commit()
+
+
+async def _global_rows(db: AsyncSession) -> dict[str, str]:
+    rows = (
+        (
+            await db.execute(
+                select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY]))
+            )
+        )
+        .scalars()
+        .all()
+    )
+    return {r.key: r.value for r in rows}
+
+
+async def _make_admin(db: AsyncSession, username: str) -> User:
+    user = User(
+        username=username,
+        password_hash=get_password_hash("AdminPass1!"),
+        role="admin",
+        is_active=True,
+    )
+    db.add(user)
+    await db.commit()
+    await db.refresh(user)
+    return user
+
+
+# ---------------------------------------------------------------------------
+# auth OFF -> ON
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_setup_migrates_global_token_to_created_admin(async_client: AsyncClient, db_session: AsyncSession):
+    """The reporter's exact path: link cloud with auth off, then enable auth."""
+    await _seed_global_token(db_session)
+
+    resp = await async_client.post(
+        "/api/v1/auth/setup",
+        json={"auth_enabled": True, "admin_username": "admin", "admin_password": "AdminPass1!"},
+    )
+    assert resp.status_code == 200, resp.text
+
+    admin = (await db_session.execute(select(User).where(User.role == "admin"))).scalar_one()
+    token, email, region = await get_stored_token(db_session, admin)
+    assert token == "tok-global"
+    assert email == "owner@example.com"
+    assert region == "china", "region must survive the hop, not silently reset to global"
+
+    # Credential must not be left at rest in a table nothing reads any more.
+    assert await _global_rows(db_session) == {}
+
+
+@pytest.mark.asyncio
+async def test_setup_migrates_to_sole_pre_existing_admin(async_client: AsyncClient, db_session: AsyncSession):
+    """Re-enabling auth when exactly one admin already exists has one obvious owner."""
+    admin = await _make_admin(db_session, "solo")
+    await _seed_global_token(db_session, token="tok-solo")
+
+    resp = await async_client.post("/api/v1/auth/setup", json={"auth_enabled": True})
+    assert resp.status_code == 200, resp.text
+    assert resp.json()["admin_created"] is False
+
+    await db_session.refresh(admin)
+    token, _, _ = await get_stored_token(db_session, admin)
+    assert token == "tok-solo"
+    assert await _global_rows(db_session) == {}
+
+
+@pytest.mark.asyncio
+async def test_setup_refuses_to_guess_owner_when_multiple_admins(async_client: AsyncClient, db_session: AsyncSession):
+    """Two admins, one credential: handing it to either is a security decision we don't make."""
+    a = await _make_admin(db_session, "admin_a")
+    b = await _make_admin(db_session, "admin_b")
+    await _seed_global_token(db_session, token="tok-ambiguous")
+
+    resp = await async_client.post("/api/v1/auth/setup", json={"auth_enabled": True})
+    assert resp.status_code == 200, resp.text
+
+    await db_session.refresh(a)
+    await db_session.refresh(b)
+    assert a.cloud_token is None
+    assert b.cloud_token is None
+    # Left intact so the operator can re-link rather than lose it.
+    assert (await _global_rows(db_session))[CLOUD_TOKEN_KEY] == "tok-ambiguous"
+
+
+@pytest.mark.asyncio
+async def test_setup_with_auth_disabled_leaves_global_token_untouched(
+    async_client: AsyncClient, db_session: AsyncSession
+):
+    """Completing setup while declining auth must not move anything."""
+    await _seed_global_token(db_session, token="tok-stay")
+
+    resp = await async_client.post("/api/v1/auth/setup", json={"auth_enabled": False})
+    assert resp.status_code == 200, resp.text
+
+    assert (await _global_rows(db_session))[CLOUD_TOKEN_KEY] == "tok-stay"
+
+
+# ---------------------------------------------------------------------------
+# auth ON -> OFF
+# ---------------------------------------------------------------------------
+
+
+async def _admin_bearer(async_client: AsyncClient, username: str = "admin") -> str:
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={"auth_enabled": True, "admin_username": username, "admin_password": "AdminPass1!"},
+    )
+    login = await async_client.post(
+        "/api/v1/auth/login",
+        json={"username": username, "password": "AdminPass1!"},
+    )
+    return login.json()["access_token"]
+
+
+@pytest.mark.asyncio
+async def test_disable_auth_migrates_admin_token_to_global(async_client: AsyncClient, db_session: AsyncSession):
+    bearer = await _admin_bearer(async_client)
+    admin = (await db_session.execute(select(User).where(User.role == "admin"))).scalar_one()
+    admin.cloud_token = "tok-user"
+    admin.cloud_email = "user@example.com"
+    admin.cloud_region = "china"
+    await db_session.commit()
+
+    resp = await async_client.post("/api/v1/auth/disable", headers={"Authorization": f"Bearer {bearer}"})
+    assert resp.status_code == 200, resp.text
+
+    rows = await _global_rows(db_session)
+    assert rows[CLOUD_TOKEN_KEY] == "tok-user"
+    assert rows[CLOUD_REGION_KEY] == "china"
+
+    await db_session.refresh(admin)
+    assert admin.cloud_token is None, "credential must not be duplicated across both stores"
+
+    # And the no-auth read path now finds it.
+    token, _, _ = await get_stored_token(db_session, None)
+    assert token == "tok-user"
+
+
+@pytest.mark.asyncio
+async def test_disable_auth_does_not_clobber_existing_global_token(async_client: AsyncClient, db_session: AsyncSession):
+    """A stale global row is still somebody's credential — refuse rather than overwrite."""
+    bearer = await _admin_bearer(async_client)
+    admin = (await db_session.execute(select(User).where(User.role == "admin"))).scalar_one()
+    admin.cloud_token = "tok-user"
+    await db_session.commit()
+    await _seed_global_token(db_session, token="tok-preexisting")
+
+    resp = await async_client.post("/api/v1/auth/disable", headers={"Authorization": f"Bearer {bearer}"})
+    assert resp.status_code == 200, resp.text
+
+    assert (await _global_rows(db_session))[CLOUD_TOKEN_KEY] == "tok-preexisting"
+    await db_session.refresh(admin)
+    assert admin.cloud_token == "tok-user", "admin keeps their token when we decline to migrate"
+
+
+@pytest.mark.asyncio
+async def test_disable_auth_with_no_cloud_token_is_a_noop(async_client: AsyncClient, db_session: AsyncSession):
+    bearer = await _admin_bearer(async_client)
+
+    resp = await async_client.post("/api/v1/auth/disable", headers={"Authorization": f"Bearer {bearer}"})
+    assert resp.status_code == 200, resp.text
+    assert await _global_rows(db_session) == {}
+
+
+# ---------------------------------------------------------------------------
+# Log-level classification for cloud preset misses (#2530)
+# ---------------------------------------------------------------------------
+
+
+def test_bambu_cloud_error_carries_status_code():
+    """The 400-vs-fault distinction depends on this attribute existing."""
+    from backend.app.services.bambu_cloud import BambuCloudError
+
+    assert BambuCloudError("boom").status_code is None
+    assert BambuCloudError("boom", status_code=400).status_code == 400
+    # Every pre-existing raise site passes a bare message; must not break.
+    assert str(BambuCloudError("Request failed: timeout")) == "Request failed: timeout"
+
+
+class _StubCloud:
+    """Minimal stand-in for BambuCloudService that fails every preset lookup."""
+
+    def __init__(self, error: Exception):
+        self._error = error
+        self.is_authenticated = True
+
+    async def get_setting_detail(self, setting_id: str) -> dict:
+        raise self._error
+
+    async def close(self) -> None:
+        return None
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("error", "expected_level"),
+    [
+        (BambuCloudError("missing", status_code=400), logging.DEBUG),
+        (BambuCloudError("unauthorized", status_code=401), logging.WARNING),
+        (BambuCloudError("bad gateway", status_code=502), logging.WARNING),
+        (BambuCloudError("Request failed: timeout"), logging.WARNING),
+    ],
+    ids=["expected-400-miss", "expired-token", "cloud-outage", "transport-failure"],
+)
+async def test_preset_miss_logs_at_debug_but_faults_stay_at_warning(
+    monkeypatch, caplog, db_session: AsyncSession, error: Exception, expected_level: int
+):
+    """A preset the catalog doesn't carry is routine; an expired token is a fault.
+
+    Drives the real ``get_filament_info`` route so the classification is exercised
+    where it lives, not re-derived in the test.
+    """
+    monkeypatch.setattr(cloud_routes, "build_authenticated_cloud", _stub_builder(error))
+    # Phase 1 would otherwise short-circuit the cloud call on a warm cache.
+    monkeypatch.setattr(cloud_routes, "_filament_cache", {})
+
+    with caplog.at_level(logging.DEBUG, logger="backend.app.api.routes.cloud"):
+        result = await cloud_routes.get_filament_info(setting_ids=["GFL05"], db=db_session, current_user=None)
+
+    records = [r for r in caplog.records if "Failed to get cloud preset" in r.getMessage()]
+    assert len(records) == 1, "the miss must be logged exactly once"
+    assert records[0].levelno == expected_level
+    assert "GFL05" in records[0].getMessage()
+    assert "GFSL05" in records[0].getMessage(), "the translated API ID stays in the message"
+
+    # Whatever the level, the endpoint still answers and falls through to Phase 3.
+    assert isinstance(result, dict)
+
+
+def _stub_builder(error: Exception):
+    async def _build(db, user):
+        return _StubCloud(error)
+
+    return _build

+ 70 - 0
backend/tests/integration/test_drying_screen_only.py

@@ -0,0 +1,70 @@
+"""P1-series AMS drying is screen-only — the API must refuse it (#2533).
+
+Bambu's P1 manual states that "P1S connected AMS drying functions may only be
+controlled from the P1S screen". The firmware still answers
+``ams_filament_drying`` with ``result: success`` and then ignores it, which is
+exactly what the reporter saw: three commands accepted on an idle P1S with an
+AMS 2 Pro, and the unit never left ``dry_status: 0``.
+
+So a command we can't fulfil must be refused rather than acked, and that has to
+hold for stop as well as start — a cycle a P1S user started at the printer can
+only be ended there.
+"""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+
+@pytest.fixture
+def mqtt_send():
+    """Watch the MQTT command so we can assert nothing was published."""
+    with patch(
+        "backend.app.services.printer_manager.printer_manager.send_drying_command",
+        new=MagicMock(return_value=True),
+    ) as m:
+        yield m
+
+
+@pytest.fixture
+def live_state():
+    """A connected printer on firmware new enough that only the model gates drying."""
+    state = MagicMock()
+    state.firmware_version = "01.10.00.00"
+    state.raw_data = {"ams": [{"id": 0, "module_type": "n3f", "tray": []}]}
+    with patch(
+        "backend.app.services.printer_manager.printer_manager.get_status",
+        new=MagicMock(return_value=state),
+    ) as m:
+        yield m
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+@pytest.mark.parametrize("model", ["P1S", "P1P"])
+@pytest.mark.parametrize("action", ["start", "stop"])
+async def test_screen_only_model_refuses_drying(
+    async_client: AsyncClient, printer_factory, mqtt_send, live_state, model, action
+):
+    printer = await printer_factory(model=model)
+
+    response = await async_client.post(f"/api/v1/printers/{printer.id}/drying/{action}?ams_id=0")
+
+    assert response.status_code == 400
+    assert "screen" in response.json()["detail"].lower()
+    # And nothing went out on the wire — an ack the printer would drop is worse
+    # than a refusal, because it leaves the user believing drying is running.
+    mqtt_send.assert_not_called()
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+@pytest.mark.parametrize("action", ["start", "stop"])
+async def test_commandable_model_still_dries(async_client: AsyncClient, printer_factory, mqtt_send, live_state, action):
+    printer = await printer_factory(model="X1C")
+
+    response = await async_client.post(f"/api/v1/printers/{printer.id}/drying/{action}?ams_id=0")
+
+    assert response.status_code == 200
+    mqtt_send.assert_called_once()

+ 98 - 0
backend/tests/integration/test_external_folders_api.py

@@ -295,6 +295,104 @@ class TestExternalFolderScan:
         assert result["removed"] == 1
         assert result["added"] == 0
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_scan_indexes_pre_existing_markdown(
+        self, async_client: AsyncClient, db_session, external_folder, external_dir
+    ):
+        """Scan should index a README.md already on disk (#2520 item 1).
+
+        Markdown dropped into the folder by external tools (not the Upload
+        dialog) must be picked up so the Folder Readme panel can show it.
+        """
+        (external_dir / "README.md").write_text("# Fishing Floats\n\nDescription.")
+
+        response = await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
+        assert response.status_code == 200
+        # 4 supported files from the fixture + the new README.md
+        assert response.json()["added"] == 5
+
+        response = await async_client.get(f"/api/v1/library/files?folder_id={external_folder['id']}")
+        root_filenames = {f["filename"] for f in response.json()}
+        assert "README.md" in root_filenames
+
+        # Readme panel can now resolve it.
+        response = await async_client.get(f"/api/v1/library/folders/{external_folder['id']}/readme")
+        assert response.status_code == 200
+        assert response.json()["filename"] == "README.md"
+        assert "Fishing Floats" in response.json()["content"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_scan_preserves_uploaded_markdown(self, async_client: AsyncClient, db_session, tmp_path):
+        """Scanning must not delete an uploaded README.md (#2520 destructive-cleanup bug).
+
+        Before the fix, .md was absent from _SCANNABLE_EXTENSIONS, so an
+        uploaded markdown record was never re-found during the walk and the
+        cleanup pass purged it — the Readme panel then 404'd and hid.
+        """
+        import io
+
+        writable_dir = tmp_path / "writable"
+        writable_dir.mkdir()
+        response = await async_client.post(
+            "/api/v1/library/folders/external",
+            json={"name": "Writable", "external_path": str(writable_dir), "readonly": False},
+        )
+        folder = response.json()
+
+        upload = await async_client.post(
+            f"/api/v1/library/files?folder_id={folder['id']}",
+            files={"file": ("README.md", io.BytesIO(b"# Model\n\nHello"), "text/markdown")},
+        )
+        assert upload.status_code in (200, 201)
+
+        # Panel works before the scan.
+        readme = await async_client.get(f"/api/v1/library/folders/{folder['id']}/readme")
+        assert readme.status_code == 200
+
+        # The scan that used to nuke the record.
+        scan = await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
+        assert scan.status_code == 200
+        assert scan.json()["removed"] == 0
+
+        # Record and panel survive.
+        readme = await async_client.get(f"/api/v1/library/folders/{folder['id']}/readme")
+        assert readme.status_code == 200
+        assert readme.json()["filename"] == "README.md"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_scan_preserves_non_scannable_file_on_disk(self, async_client: AsyncClient, db_session, tmp_path):
+        """Cleanup must gate on disk presence, not scannable-extension membership (#2520).
+
+        Any uploaded file whose extension is outside _SCANNABLE_EXTENSIONS
+        (here a .txt) stays on disk, so its DB record must survive a scan
+        rather than being treated as deleted.
+        """
+        import io
+
+        writable_dir = tmp_path / "writable_txt"
+        writable_dir.mkdir()
+        response = await async_client.post(
+            "/api/v1/library/folders/external",
+            json={"name": "Writable Txt", "external_path": str(writable_dir), "readonly": False},
+        )
+        folder = response.json()
+
+        upload = await async_client.post(
+            f"/api/v1/library/files?folder_id={folder['id']}",
+            files={"file": ("notes.txt", io.BytesIO(b"keep me"), "text/plain")},
+        )
+        assert upload.status_code in (200, 201)
+
+        scan = await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
+        assert scan.status_code == 200
+        assert scan.json()["removed"] == 0
+
+        files = await async_client.get(f"/api/v1/library/files?folder_id={folder['id']}")
+        assert "notes.txt" in {f["filename"] for f in files.json()}
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_scan_non_external_folder_fails(self, async_client: AsyncClient, db_session):

+ 83 - 0
backend/tests/integration/test_inventory_assign.py

@@ -885,6 +885,89 @@ class TestAssignSpoolEmptySlotPreConfig:
         await db_session.refresh(pre_assignment)
         assert pre_assignment.fingerprint_type == "PLA"
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_auto_unlink_broadcasts_assignment_change(
+        self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
+    ):
+        """#2575 follow-up: when on_ams_change auto-unlinks a stale external-spool
+        assignment, it must broadcast spool_assignment_changed. Only the manual
+        REST endpoints did, so open browsers kept rendering the unlinked spool —
+        the reporter read that as "the fix didn't work" when the DB was correct."""
+        from unittest.mock import AsyncMock
+
+        from sqlalchemy import select
+
+        from backend.app.main import on_ams_change
+        from backend.app.models.spool_assignment import SpoolAssignment
+
+        printer = await printer_factory(name="X1C")
+        spool = await spool_factory(slicer_filament="GFU01", material="TPU")
+
+        # TPU inventory spool assigned to the external slot (ams_id=255, tray 0)
+        assignment = SpoolAssignment(
+            spool_id=spool.id,
+            printer_id=printer.id,
+            ams_id=255,
+            tray_id=0,
+            fingerprint_color="000000FF",
+            fingerprint_type="TPU",
+        )
+        db_session.add(assignment)
+        await db_session.commit()
+
+        # The printer's external spool now reports ABS — the assignment is stale.
+        vt_tray = [
+            {
+                "id": "254",
+                "tray_type": "ABS",
+                "tray_color": "000000FF",
+                "tag_uid": "0000000000000000",
+                "tray_uuid": "00000000000000000000000000000000",
+            }
+        ]
+        status = _make_mock_status(ams_data=[], vt_tray=vt_tray)
+        printer_info = MagicMock(name="X1C", serial_number="00M00A391800004")
+
+        with (
+            patch("backend.app.main.printer_manager") as mock_pm_main,
+            patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
+            patch("backend.app.main.mqtt_relay") as mock_relay,
+            patch("backend.app.main.ws_manager") as mock_ws,
+        ):
+            mock_pm_main.get_printer.return_value = printer_info
+            mock_pm_main.get_status.return_value = status
+            mock_pm_main.get_client.return_value = MagicMock()
+            mock_pm_main.get_model.return_value = "X1C"
+            mock_pm_inv.get_client.return_value = MagicMock()
+            mock_pm_inv.get_status.return_value = status
+            mock_relay.on_ams_change = AsyncMock()
+            mock_ws.send_printer_status = AsyncMock()
+            mock_ws.broadcast = AsyncMock()
+
+            await on_ams_change(printer.id, [])
+
+            # The stale TPU assignment on the now-ABS external slot was unlinked...
+            gone = await db_session.execute(
+                select(SpoolAssignment).where(
+                    SpoolAssignment.printer_id == printer.id,
+                    SpoolAssignment.ams_id == 255,
+                    SpoolAssignment.tray_id == 0,
+                )
+            )
+            assert gone.scalar_one_or_none() is None
+
+            # ...and the frontend was told about it.
+            change_events = [
+                c.args[0]
+                for c in mock_ws.broadcast.await_args_list
+                if c.args and isinstance(c.args[0], dict) and c.args[0].get("type") == "spool_assignment_changed"
+            ]
+            assert change_events, "auto-unlink must broadcast spool_assignment_changed"
+            assert change_events[0]["printer_id"] == printer.id
+            assert change_events[0]["ams_id"] == 255
+            assert change_events[0]["tray_id"] == 0
+
 
 class TestAssignSpoolEmptyDetection:
     """Bambu firmware reports tray.state — 11=loaded, 9=empty, 10=spool present

+ 97 - 0
backend/tests/integration/test_library_slice_api.py

@@ -534,6 +534,103 @@ class TestSliceLibraryFile:
         assert "Metadata/cut_information.xml" in names
         assert "3D/3dmodel.model" in names
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_use_embedded_settings_skips_profile_triplet(
+        self, async_client: AsyncClient, db_session, slice_test_setup
+    ):
+        # "Slice as designed" (#2611): with use_embedded_settings the 3MF is
+        # sliced on its own project_settings.config — no --load-settings — so
+        # the sidecar request carries ONLY the model file, never the
+        # printer/process/filament profile parts. Succeeds on the first call
+        # (no crash-fallback), and the result is flagged used_embedded_settings.
+        src_3mf_path = slice_test_setup["tmp_path"] / "library" / "files" / "designed.3mf"
+        src_3mf_path.write_bytes(_make_3mf_with_settings({"wall_loops": "5"}))
+        threemf = LibraryFile(
+            filename="designed.3mf",
+            file_path=str(src_3mf_path.relative_to(slice_test_setup["tmp_path"])),
+            file_type="3mf",
+            file_size=src_3mf_path.stat().st_size,
+        )
+        db_session.add(threemf)
+        await db_session.commit()
+        await db_session.refresh(threemf)
+
+        captured: dict = {}
+        call_count = {"n": 0}
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            call_count["n"] += 1
+            captured["body"] = request.content
+            return httpx.Response(
+                status_code=200,
+                content=b"PK\x03\x04 fake-3mf",
+                headers={
+                    "x-print-time-seconds": "100",
+                    "x-filament-used-g": "1.0",
+                    "x-filament-used-mm": "100",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+        response = await async_client.post(
+            f"/api/v1/library/files/{threemf.id}/slice",
+            json={
+                "printer_preset_id": slice_test_setup["printer_id"],
+                "process_preset_id": slice_test_setup["process_id"],
+                "filament_preset_id": slice_test_setup["filament_id"],
+                "use_embedded_settings": True,
+            },
+        )
+        assert response.status_code == 202
+        final = await _wait_for_job(async_client, response.json()["job_id"])
+        assert final["status"] == "completed", final
+        assert final["result"]["used_embedded_settings"] is True
+        assert call_count["n"] == 1  # embedded path taken directly, no fallback retry
+
+        # The multipart body must NOT carry any profile part — that is the
+        # whole point of the mode. Their presence would mean --load-settings
+        # ran and overrode the designer's embedded settings.
+        body = captured["body"]
+        assert b"printerProfile" not in body
+        assert b"presetProfile" not in body
+        assert b"filamentProfile" not in body
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_use_embedded_settings_ignored_for_stl(self, async_client: AsyncClient, slice_test_setup):
+        # An STL has no embedded project settings to honour, so the flag is a
+        # no-op: the normal profile path runs and the triplet is forwarded.
+        captured: dict = {}
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            captured["body"] = request.content
+            return httpx.Response(
+                status_code=200,
+                content=b"PK\x03\x04 fake-3mf",
+                headers={
+                    "x-print-time-seconds": "1",
+                    "x-filament-used-g": "0",
+                    "x-filament-used-mm": "0",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+        response = await async_client.post(
+            f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
+            json={
+                "printer_preset_id": slice_test_setup["printer_id"],
+                "process_preset_id": slice_test_setup["process_id"],
+                "filament_preset_id": slice_test_setup["filament_id"],
+                "use_embedded_settings": True,
+            },
+        )
+        assert response.status_code == 202
+        final = await _wait_for_job(async_client, response.json()["job_id"])
+        assert final["status"] == "completed", final
+        assert final["result"]["used_embedded_settings"] is False
+        assert b"printerProfile" in captured["body"]  # profile path still ran
+
 
 # ---------------------------------------------------------------------------
 # GET /slice-jobs/{id}

+ 3 - 3
backend/tests/integration/test_makerworld_apikey_auth.py

@@ -119,7 +119,7 @@ class TestStatusEndpoint:
             headers={"X-API-Key": key},
         )
         assert resp.status_code == 200, resp.text
-        assert resp.json() == {"has_cloud_token": True, "can_download": True}
+        assert resp.json() == {"has_cloud_token": True, "can_download": True, "sign_in_expired": False}
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -143,7 +143,7 @@ class TestStatusEndpoint:
             headers={"X-API-Key": key},
         )
         assert resp.status_code == 200
-        assert resp.json() == {"has_cloud_token": False, "can_download": False}
+        assert resp.json() == {"has_cloud_token": False, "can_download": False, "sign_in_expired": False}
 
 
 class TestResolveEndpoint:
@@ -299,4 +299,4 @@ class TestJwtPathUnchanged:
             headers={"Authorization": f"Bearer {admin_token}"},
         )
         assert resp.status_code == 200
-        assert resp.json() == {"has_cloud_token": True, "can_download": True}
+        assert resp.json() == {"has_cloud_token": True, "can_download": True, "sign_in_expired": False}

+ 70 - 39
backend/tests/integration/test_oidc_icon_blob_roundtrip.py

@@ -1,54 +1,85 @@
-"""Type-mapping coverage for the OIDC icon BLOB column (#1333).
-
-Bambuddy's ``create_backup_zip`` rebuilds the SQLite backup schema from
-``Base.metadata`` when the source database is PostgreSQL. The column-type
-mapping previously fell through to ``TEXT`` for any unknown SQLAlchemy
-type — including ``LargeBinary`` / ``BYTEA`` — which corrupts non-UTF8
-icon bytes during the PG → SQLite-ZIP round trip.
-
-These tests exercise the extracted ``_sqlalchemy_type_to_sqlite_type``
-helper directly so the regression guard doesn't depend on a full backup
-pipeline. The SQLite source path is just ``shutil.copy2`` of the live
-.db file and is therefore unaffected by the type mapping.
+"""Backup-schema fidelity for the PG→SQLite portable export (#1333, #2526).
+
+Bambuddy's ``create_backup_zip`` rebuilds the SQLite backup schema when the
+source database is PostgreSQL. It now uses ``Base.metadata.create_all()``
+against a SQLite engine — the same DDL a native SQLite install gets — rather
+than a hand-rolled ``name + type`` CREATE TABLE. The old rebuild dropped two
+things that these tests pin:
+
+* ``LargeBinary`` fell through to ``TEXT``, corrupting non-UTF8 OIDC icon
+  bytes during the round trip (#1333). ``create_all`` renders it as ``BLOB``.
+* ``NOT NULL`` / ``DEFAULT`` / FK / ``UNIQUE`` were all dropped, so a
+  Postgres→SQLite restore left ``server_default`` columns (e.g.
+  ``spoolbuddy_devices.created_at``) with no ``DEFAULT`` — later inserts
+  wrote ``NULL`` and 500'd on read (#2526). ``create_all`` emits the default.
+
+The SQLite *source* path is just ``shutil.copy2`` of the live .db file and is
+therefore unaffected — these guards only matter for the PostgreSQL branch.
 """
 
 import hashlib
 import sqlite3
 
 import pytest
-from sqlalchemy import Column, LargeBinary
+from sqlalchemy import create_engine
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.api.routes.settings import _sqlalchemy_type_to_sqlite_type
+from backend.app.core.database import Base
 from backend.tests._fixtures.oidc_icon import PNG_BYTES as _PNG_BYTES
 
 
-class TestTypeMapping:
-    """Unit-level coverage of the helper that backups use for PG→SQLite."""
+def _build_backup_schema(db_path) -> dict[str, dict]:
+    """Build the portable SQLite schema exactly as create_backup_zip's
+    PostgreSQL branch does, then return ``{table: {col: PRAGMA row}}``.
 
-    def test_largebinary_maps_to_blob(self):
-        # Direct from a SQLAlchemy LargeBinary column — this is exactly
-        # what the create_backup_zip loop calls str() on.
-        col = Column(LargeBinary)
-        assert _sqlalchemy_type_to_sqlite_type(str(col.type)) == "BLOB"
-
-    @pytest.mark.parametrize(
-        "type_repr",
-        ["BLOB", "BYTEA", "BYTEA(1024)", "VARBINARY", "BINARY", "binary varying"],
-    )
-    def test_binary_type_strings_map_to_blob(self, type_repr):
-        assert _sqlalchemy_type_to_sqlite_type(type_repr) == "BLOB"
-
-    def test_integer_unchanged(self):
-        assert _sqlalchemy_type_to_sqlite_type("INTEGER") == "INTEGER"
-        assert _sqlalchemy_type_to_sqlite_type("BIGINT") == "INTEGER"
-
-    def test_boolean_unchanged(self):
-        assert _sqlalchemy_type_to_sqlite_type("BOOLEAN") == "BOOLEAN"
-
-    def test_unknown_falls_back_to_text(self):
-        assert _sqlalchemy_type_to_sqlite_type("VARCHAR(500)") == "TEXT"
-        assert _sqlalchemy_type_to_sqlite_type("DATETIME") == "TEXT"
+    PRAGMA table_info rows are ``(cid, name, type, notnull, dflt_value, pk)``.
+    """
+    engine = create_engine(f"sqlite:///{db_path}")
+    try:
+        Base.metadata.create_all(engine)
+    finally:
+        engine.dispose()
+
+    conn = sqlite3.connect(str(db_path))
+    try:
+        schema: dict[str, dict] = {}
+        tables = [
+            row[0]
+            for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
+        ]
+        for table in tables:
+            schema[table] = {row[1]: row for row in conn.execute(f"PRAGMA table_info({table})")}  # noqa: S608
+        return schema
+    finally:
+        conn.close()
+
+
+class TestBackupSchemaFidelity:
+    """The real backup-schema builder (metadata.create_all on SQLite),
+    inspected via sqlite_master, keeps the constraints the old name+type
+    rebuild dropped."""
+
+    def test_icon_data_column_is_blob(self, tmp_path):
+        # #1333 — LargeBinary must render as BLOB, not TEXT, or non-UTF8
+        # OIDC icon bytes are corrupted on the PG→SQLite round trip.
+        schema = _build_backup_schema(tmp_path / "schema.db")
+        assert schema["oidc_providers"]["icon_data"][2] == "BLOB"
+
+    def test_server_default_column_keeps_default(self, tmp_path):
+        # #2526 — a server_default=func.now() column must carry a DEFAULT so
+        # inserts that omit it (SQLAlchemy does, for server-side defaults)
+        # don't write NULL after a Postgres→SQLite restore.
+        schema = _build_backup_schema(tmp_path / "schema.db")
+        created_at = schema["spoolbuddy_devices"]["created_at"]
+        assert created_at[4] is not None, "created_at lost its DEFAULT clause"
+        assert "CURRENT_TIMESTAMP" in str(created_at[4]).upper()
+
+    def test_not_null_column_keeps_not_null(self, tmp_path):
+        # #2526 — NOT NULL columns must stay NOT NULL. A single-column PK is
+        # implicitly NOT NULL, so assert on a non-PK required column.
+        schema = _build_backup_schema(tmp_path / "schema.db")
+        # notnull flag is index 3 of the PRAGMA row.
+        assert schema["spoolbuddy_devices"]["device_id"][3] == 1
 
 
 class TestSqliteBinaryRoundtrip:

+ 176 - 0
backend/tests/integration/test_orca_cloud_device.py

@@ -0,0 +1,176 @@
+"""Integration tests for the Orca Cloud device-pairing routes.
+
+Covers the /device/start -> /device/poll pairing loop, its terminal outcomes,
+token persistence, and status/logout — all in auth-disabled mode (the global
+Settings-table fallback), with the service's network calls patched out.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta, timezone
+from unittest.mock import patch
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.services import orca_cloud as orca_service
+from backend.app.services.orca_cloud import DevicePoll, OrcaCloudService
+
+AUTH_DISABLED = "backend.app.core.auth.is_auth_enabled"
+
+
+@pytest.fixture(autouse=True)
+def _dummy_shared_client():
+    """Register a throwaway shared HTTP client so per-request
+    OrcaCloudService() instances don't spin up (and leak) a real one — the
+    network methods are patched anyway."""
+    from unittest.mock import MagicMock
+
+    orca_service.set_shared_http_client(MagicMock())
+    yield
+    orca_service.set_shared_http_client(None)
+
+
+_DEVICE_CODE_RESPONSE = {
+    "device_code": "DEV-SECRET-1",
+    "user_code": "ABCD-EF12",
+    "verification_uri": "https://cloud.orcaslicer.com/app/settings",
+    "verification_uri_complete": "https://cloud.orcaslicer.com/app/settings?user_code=ABCD-EF12",
+    "expires_in": 600,
+    "interval": 5,
+}
+
+
+async def _start(async_client: AsyncClient):
+    with (
+        patch(AUTH_DISABLED, return_value=False),
+        patch.object(OrcaCloudService, "request_device_code", return_value=dict(_DEVICE_CODE_RESPONSE)),
+    ):
+        return await async_client.post("/api/v1/orca-cloud/device/start")
+
+
+class TestDeviceStart:
+    @pytest.mark.asyncio
+    async def test_start_returns_user_code_and_hides_device_code(self, async_client: AsyncClient):
+        resp = await _start(async_client)
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["user_code"] == "ABCD-EF12"
+        assert body["interval"] == 5
+        assert body["verification_uri_complete"].endswith("user_code=ABCD-EF12")
+        # The device_code is a secret and must NOT be echoed to the client.
+        assert "device_code" not in body
+
+
+class TestDevicePoll:
+    @pytest.mark.asyncio
+    async def test_poll_without_pending_is_400(self, async_client: AsyncClient):
+        with patch(AUTH_DISABLED, return_value=False):
+            resp = await async_client.post("/api/v1/orca-cloud/device/poll")
+        assert resp.status_code == 400
+
+    @pytest.mark.asyncio
+    async def test_poll_pending_reports_in_progress(self, async_client: AsyncClient):
+        await _start(async_client)
+        with (
+            patch(AUTH_DISABLED, return_value=False),
+            patch.object(OrcaCloudService, "poll_token", return_value=(DevicePoll.PENDING, None)),
+        ):
+            resp = await async_client.post("/api/v1/orca-cloud/device/poll")
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["status"] == DevicePoll.PENDING
+        assert body["connected"] is False
+
+    @pytest.mark.asyncio
+    async def test_poll_complete_persists_tokens_and_connects(self, async_client: AsyncClient):
+        await _start(async_client)
+
+        async def fake_complete(self, device_code):
+            assert device_code == "DEV-SECRET-1"  # the stored secret is used
+            self.access_token = "oc_ext_new"
+            self.refresh_token = "oc_ext_rt_new"
+            self.token_expiry = datetime.now(timezone.utc) + timedelta(seconds=86400)
+            return DevicePoll.COMPLETE, {"access_token": "oc_ext_new"}
+
+        with (
+            patch(AUTH_DISABLED, return_value=False),
+            patch.object(OrcaCloudService, "poll_token", new=fake_complete),
+            patch.object(OrcaCloudService, "introspect", return_value={"user_id": "user-123"}),
+        ):
+            resp = await async_client.post("/api/v1/orca-cloud/device/poll")
+            assert resp.status_code == 200
+            body = resp.json()
+            assert body["status"] == DevicePoll.COMPLETE
+            assert body["connected"] is True
+            assert body["user_id"] == "user-123"
+
+            # Status now reflects the connection, and the pending state is
+            # cleared (a fresh poll finds nothing pending -> 400).
+            status = await async_client.get("/api/v1/orca-cloud/status")
+            assert status.json()["connected"] is True
+            again = await async_client.post("/api/v1/orca-cloud/device/poll")
+            assert again.status_code == 400
+
+    @pytest.mark.asyncio
+    async def test_poll_denied_clears_pending(self, async_client: AsyncClient):
+        await _start(async_client)
+        with (
+            patch(AUTH_DISABLED, return_value=False),
+            patch.object(OrcaCloudService, "poll_token", return_value=(DevicePoll.DENIED, None)),
+        ):
+            resp = await async_client.post("/api/v1/orca-cloud/device/poll")
+        assert resp.json()["status"] == DevicePoll.DENIED
+        # Pending cleared -> next poll has nothing to poll.
+        with patch(AUTH_DISABLED, return_value=False):
+            again = await async_client.post("/api/v1/orca-cloud/device/poll")
+        assert again.status_code == 400
+
+    @pytest.mark.asyncio
+    async def test_poll_expires_by_ttl_without_network(self, async_client: AsyncClient):
+        """A pending code older than DEVICE_CODE_TTL is reported expired
+        without even calling the token endpoint. Shrinking the TTL to a
+        negative window makes any just-created pending state 'stale'."""
+        await _start(async_client)
+
+        # poll_token must NOT be called; if it were, this would blow up.
+        def _boom(*a, **k):
+            raise AssertionError("poll_token should not be called for an expired code")
+
+        with (
+            patch(AUTH_DISABLED, return_value=False),
+            patch("backend.app.api.routes.orca_cloud.DEVICE_CODE_TTL", timedelta(seconds=-1)),
+            patch.object(OrcaCloudService, "poll_token", new=_boom),
+        ):
+            resp = await async_client.post("/api/v1/orca-cloud/device/poll")
+        assert resp.status_code == 200
+        assert resp.json()["status"] == DevicePoll.EXPIRED
+        # And the expired pending state is cleared.
+        with patch(AUTH_DISABLED, return_value=False):
+            again = await async_client.post("/api/v1/orca-cloud/device/poll")
+        assert again.status_code == 400
+
+
+class TestLogout:
+    @pytest.mark.asyncio
+    async def test_logout_clears_connection(self, async_client: AsyncClient):
+        await _start(async_client)
+
+        async def fake_complete(self, device_code):
+            self.access_token = "oc_ext_new"
+            self.refresh_token = "oc_ext_rt_new"
+            self.token_expiry = datetime.now(timezone.utc) + timedelta(seconds=86400)
+            return DevicePoll.COMPLETE, {"access_token": "oc_ext_new"}
+
+        with (
+            patch(AUTH_DISABLED, return_value=False),
+            patch.object(OrcaCloudService, "poll_token", new=fake_complete),
+            patch.object(OrcaCloudService, "introspect", return_value={"user_id": "u"}),
+        ):
+            await async_client.post("/api/v1/orca-cloud/device/poll")
+
+        with patch(AUTH_DISABLED, return_value=False):
+            out = await async_client.post("/api/v1/orca-cloud/logout")
+            assert out.status_code == 200
+            status = await async_client.get("/api/v1/orca-cloud/status")
+            assert status.json()["connected"] is False

+ 216 - 0
backend/tests/integration/test_overlay_status_api.py

@@ -0,0 +1,216 @@
+"""Integration tests for the token-authenticated streaming-overlay feed (#2613).
+
+Like the Cam Wall feed, the overlay endpoint exists as its own scope-gated
+route because a kiosk/OBS URL is not a secret. But it is deliberately *wider*
+than the Cam Wall: it names the file being printed (the overlay draws the part
+on screen). So the tests that matter are the scope boundaries — an overlay
+token must not reach the Cam Wall feed and vice versa, a camwall token must not
+reach the overlay feed (that would leak the filename it is trusted to hide) —
+plus the positive path and the disconnected-printer shape.
+"""
+
+from __future__ import annotations
+
+import pytest
+from httpx import AsyncClient
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+async def _setup_admin(async_client: AsyncClient, *, suffix: str) -> str:
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={
+            "auth_enabled": True,
+            "admin_username": f"overlayadmin{suffix}",
+            "admin_password": "AdminPass1!",
+        },
+    )
+    login = await async_client.post(
+        "/api/v1/auth/login",
+        json={"username": f"overlayadmin{suffix}", "password": "AdminPass1!"},
+    )
+    return login.json()["access_token"]
+
+
+async def _mint(async_client: AsyncClient, jwt: str, *, scope: str, name: str = "obs") -> str:
+    response = await async_client.post(
+        "/api/v1/auth/tokens",
+        headers={"Authorization": f"Bearer {jwt}"},
+        json={"name": name, "expires_in_days": 30, "scope": scope},
+    )
+    assert response.status_code == 201, response.text
+    assert response.json()["scope"] == scope
+    return response.json()["token"]
+
+
+@pytest.fixture
+async def printer_row(db_session):
+    """Insert the printer straight into the DB.
+
+    POST /printers probes the real device before it will store a row, and there
+    is no printer on the other end of a test run.
+    """
+    from backend.app.models.printer import Printer
+
+    printer = Printer(
+        name="Stream P1S",
+        ip_address="192.168.1.88",
+        access_code="12345678",
+        serial_number="01P00A000000002",
+        model="P1S",
+    )
+    db_session.add(printer)
+    await db_session.commit()
+    return printer
+
+
+class TestOverlayFeedAuth:
+    async def test_no_token_is_rejected(self, async_client: AsyncClient, printer_row):
+        await _setup_admin(async_client, suffix="_notoken")
+        response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status")
+        assert response.status_code == 401
+
+    async def test_garbage_token_is_rejected(self, async_client: AsyncClient, printer_row):
+        await _setup_admin(async_client, suffix="_garbage")
+        response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token=bblt_aaaaaaaa_nope")
+        assert response.status_code == 401
+
+    async def test_camera_stream_token_cannot_reach_the_feed(self, async_client: AsyncClient, printer_row):
+        """A ``camera_stream`` token was handed out for video alone — it must not
+        acquire the live print status (and filename) just because a new feature
+        shipped.
+        """
+        jwt = await _setup_admin(async_client, suffix="_streamscope")
+        stream_token = await _mint(async_client, jwt, scope="camera_stream")
+
+        response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={stream_token}")
+        assert response.status_code == 401
+
+    async def test_camwall_token_cannot_reach_the_feed(self, async_client: AsyncClient, printer_row):
+        """The crux of a *separate* scope from camwall.
+
+        A Cam Wall token is trusted precisely because it can never name the part
+        being printed. The overlay feed does name it, so a camwall token must be
+        rejected here — otherwise every wall token silently gains filename
+        visibility.
+        """
+        jwt = await _setup_admin(async_client, suffix="_camwallscope")
+        camwall_token = await _mint(async_client, jwt, scope="camwall")
+
+        response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={camwall_token}")
+        assert response.status_code == 401
+
+    async def test_overlay_token_reaches_the_feed(self, async_client: AsyncClient, printer_row):
+        jwt = await _setup_admin(async_client, suffix="_rightscope")
+        overlay_token = await _mint(async_client, jwt, scope="overlay")
+
+        response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
+        assert response.status_code == 200, response.text
+        assert response.json()["name"] == "Stream P1S"
+
+    async def test_revoked_overlay_token_is_rejected(self, async_client: AsyncClient, printer_row):
+        jwt = await _setup_admin(async_client, suffix="_revoked")
+        created = await async_client.post(
+            "/api/v1/auth/tokens",
+            headers={"Authorization": f"Bearer {jwt}"},
+            json={"name": "obs", "expires_in_days": 30, "scope": "overlay"},
+        )
+        overlay_token = created.json()["token"]
+        await async_client.delete(
+            f"/api/v1/auth/tokens/{created.json()['id']}",
+            headers={"Authorization": f"Bearer {jwt}"},
+        )
+
+        response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
+        assert response.status_code == 401
+
+
+class TestOverlayFeedPayload:
+    async def test_payload_shape_includes_filename_fields(self, async_client: AsyncClient, printer_row):
+        """Unlike the Cam Wall, the overlay *does* carry the filename fields —
+        that is what distinguishes the scope. Assert the exact key set so the
+        payload can't silently grow to leak more than the overlay draws.
+        """
+        jwt = await _setup_admin(async_client, suffix="_payload")
+        overlay_token = await _mint(async_client, jwt, scope="overlay")
+
+        response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
+        assert response.status_code == 200
+        entry = response.json()
+
+        # Never the secrets — the URL is on a public stream.
+        for leaked in ("serial_number", "ip_address", "access_code"):
+            assert leaked not in entry, f"{leaked} must not be served to an overlay token"
+
+        assert set(entry) == {
+            "id",
+            "name",
+            "camera_rotation",
+            "connected",
+            "state",
+            "current_print",
+            "gcode_file",
+            "progress",
+            "remaining_time",
+            "layer_num",
+            "total_layers",
+            "stg_cur_name",
+            "time_format",
+        }
+
+    async def test_disconnected_printer_reports_connected_false(self, async_client: AsyncClient, printer_row):
+        """No MQTT client runs in tests, so the printer has no state — the
+        overlay must render its offline state rather than erroring.
+        """
+        jwt = await _setup_admin(async_client, suffix="_offline")
+        overlay_token = await _mint(async_client, jwt, scope="overlay")
+
+        response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
+        entry = response.json()
+        assert entry["connected"] is False
+        assert entry["state"] is None
+        assert entry["current_print"] is None
+
+    async def test_unknown_printer_is_404_not_401(self, async_client: AsyncClient):
+        """A valid token for a printer id that doesn't exist is a 404 — the token
+        passed the gate, the resource simply isn't there.
+        """
+        jwt = await _setup_admin(async_client, suffix="_404")
+        overlay_token = await _mint(async_client, jwt, scope="overlay")
+
+        response = await async_client.get(f"/api/v1/printers/99999/overlay-status?token={overlay_token}")
+        assert response.status_code == 404
+
+
+class TestOverlayTokenReachesTheVideo:
+    """The overlay draws the camera feed, so the same token has to satisfy the
+    camera-stream gate.
+    """
+
+    async def test_overlay_token_passes_the_camera_stream_gate(self, async_client: AsyncClient):
+        from backend.app.core.auth import verify_camera_stream_token
+
+        jwt = await _setup_admin(async_client, suffix="_video")
+        overlay_token = await _mint(async_client, jwt, scope="overlay")
+
+        assert await verify_camera_stream_token(overlay_token) is True
+
+    async def test_overlay_gate_rejects_camera_stream_and_camwall(self, async_client: AsyncClient):
+        from backend.app.core.auth import verify_overlay_token
+
+        jwt = await _setup_admin(async_client, suffix="_gate")
+        stream_token = await _mint(async_client, jwt, scope="camera_stream")
+        camwall_token = await _mint(async_client, jwt, scope="camwall", name="wall")
+
+        assert await verify_overlay_token(stream_token) is False
+        assert await verify_overlay_token(camwall_token) is False
+
+    async def test_camwall_gate_rejects_an_overlay_token(self, async_client: AsyncClient):
+        """Symmetric guard: the new scope must not widen the Cam Wall either."""
+        from backend.app.core.auth import verify_camwall_token
+
+        jwt = await _setup_admin(async_client, suffix="_gate_camwall")
+        overlay_token = await _mint(async_client, jwt, scope="overlay")
+
+        assert await verify_camwall_token(overlay_token) is False

+ 365 - 0
backend/tests/integration/test_ownership_permissions.py

@@ -1272,3 +1272,368 @@ class TestReadIDORClosure(TestOwnershipPermissionsSetup):
         # change auth-enable/disable behavior. Pin not-404 to avoid masking a
         # regression where auth-disabled callers would lose access.
         assert response.status_code in (200, 401)
+
+
+# Every archive WRITE sub-resource route: (id, http method, path suffix, request kwargs).
+# The ownership gate (_ensure_archive_visible) fires immediately after the fetch,
+# before any resource-specific logic, so a not-owned / ownerless row 404s regardless
+# of whether the timelapse / photo / source / f3d actually exists. Upload routes still
+# need a body so FastAPI reaches the handler instead of 422-ing on the missing File(...).
+_WRITE_SUBRESOURCE_ROUTES = [
+    ("favorite", "post", "/favorite", {}),
+    ("timelapse_delete", "delete", "/timelapse", {}),
+    ("photo_upload", "post", "/photos", {"files": {"file": ("x.jpg", b"\x89PNG\r\n\x1a\n", "image/jpeg")}}),
+    ("photo_delete", "delete", "/photos/nonexistent.jpg", {}),
+    ("project_page", "patch", "/project-page", {"json": {"title": "hijacked"}}),
+    ("source_upload", "post", "/source", {"files": {"file": ("x.3mf", b"PK\x03\x04", "application/octet-stream")}}),
+    ("source_delete", "delete", "/source", {}),
+    ("f3d_upload", "post", "/f3d", {"files": {"file": ("x.f3d", b"f3d-bytes", "application/octet-stream")}}),
+    ("f3d_delete", "delete", "/f3d", {}),
+]
+
+
+class TestWriteSubResourceIDORClosure(TestOwnershipPermissionsSetup):
+    """Regression tests for the archive write SUB-RESOURCE IDOR.
+
+    The read sub-resource routes were closed under maziggy/bambuddy-security #2
+    via ``_ensure_archive_visible``, but the *write* sub-resource routes
+    (favorite, timelapse, photos, project-page, source, f3d) were left gating
+    on the bare ``RequirePermissionIfAuthEnabled(ARCHIVES_*_OWN)`` scope and
+    fetched the row by id only — never comparing ``created_by_id`` to the
+    caller. An operator holding only ``ARCHIVES_*_OWN`` (or an API key with
+    ``can_manage_archives``) could delete/overwrite files on ANY user's
+    archive, most severely rewriting the project-page metadata inside another
+    user's ``.3mf`` on disk. Each route is now gated by
+    ``require_ownership_permission`` + ``_ensure_archive_visible`` → 404 (not
+    403, to stay non-enumerable and match the read side) on a not-owned or
+    ownerless row.
+    """
+
+    @pytest.mark.parametrize(
+        "name,method,suffix,kwargs",
+        _WRITE_SUBRESOURCE_ROUTES,
+        ids=[r[0] for r in _WRITE_SUBRESOURCE_ROUTES],
+    )
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_write_others_archive_subresource(
+        self,
+        async_client: AsyncClient,
+        auth_setup,
+        archive_factory,
+        printer_factory,
+        db_session,
+        name,
+        method,
+        suffix,
+        kwargs,
+    ):
+        """SECURITY.md rule 4: right credentials, wrong ownership → 404.
+
+        operator1 (ARCHIVES_*_OWN) targeting a route on admin's archive.
+        """
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            print_name="Admin's Archive",
+            created_by_id=auth_setup["admin_user"]["id"],
+        )
+        response = await getattr(async_client, method)(
+            f"/api/v1/archives/{archive.id}{suffix}",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            **kwargs,
+        )
+        assert response.status_code == 404, f"{name}: expected 404, got {response.status_code}"
+
+    @pytest.mark.parametrize(
+        "name,method,suffix,kwargs",
+        _WRITE_SUBRESOURCE_ROUTES,
+        ids=[r[0] for r in _WRITE_SUBRESOURCE_ROUTES],
+    )
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_write_ownerless_archive_subresource(
+        self,
+        async_client: AsyncClient,
+        auth_setup,
+        archive_factory,
+        printer_factory,
+        db_session,
+        name,
+        method,
+        suffix,
+        kwargs,
+    ):
+        """Ownerless rows (created_by_id = null, legacy data) require *_ALL — an
+        operator with only *_OWN has no 'I own this' claim, so fail closed → 404."""
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            print_name="Ownerless Archive",
+            created_by_id=None,
+        )
+        response = await getattr(async_client, method)(
+            f"/api/v1/archives/{archive.id}{suffix}",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            **kwargs,
+        )
+        assert response.status_code == 404, f"{name}: expected 404, got {response.status_code}"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_can_favorite_own_archive(
+        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
+    ):
+        """Positive control: the owner still gets through the new gate. Favorite
+        is the one write sub-resource that needs no pre-existing file, so it
+        cleanly proves the *_OWN happy path returns 200 (not a false 404)."""
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            print_name="Operator's Own",
+            created_by_id=auth_setup["operator_user"]["id"],
+        )
+        response = await async_client.post(
+            f"/api/v1/archives/{archive.id}/favorite",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+        assert response.status_code == 200
+        assert response.json()["is_favorite"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_admin_can_favorite_any_archive(
+        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
+    ):
+        """Positive control for the *_ALL path: admin can act on a user's archive."""
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            print_name="Operator's Own",
+            created_by_id=auth_setup["operator_user"]["id"],
+        )
+        response = await async_client.post(
+            f"/api/v1/archives/{archive.id}/favorite",
+            headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
+        )
+        assert response.status_code == 200
+
+
+class TestSliceOwnershipPermissions(TestOwnershipPermissionsSetup):
+    """IDOR regression: slicing and slice-job polling must honour per-row ownership.
+
+    Before the fix, ``POST /library/files/{id}/slice`` and
+    ``POST /archives/{id}/slice`` gated only on ``LIBRARY_UPLOAD``, so a
+    READ_OWN operator could slice another user's model by raw id even though a
+    direct GET on that id returned 404 — the sliced output was then attributed
+    to and downloadable by the requester. ``GET /slice-jobs/{id}`` had no owner
+    scoping at all. ``POST /slicer-pipelines/{id}/run`` (and check-eligibility)
+    resolved the source by raw id with the same gap.
+
+    The slice route enforces the gate before touching the source bytes, so the
+    owner/READ_ALL "control" cases reach the later on-disk check (a distinct 404
+    detail) rather than a real slice — enough to prove the gate lets them past.
+    """
+
+    # Any preset triplet: the ownership 404 fires before preset resolution.
+    _SLICE_BODY = {"printer_preset_id": 1, "process_preset_id": 2, "filament_preset_id": 3}
+
+    @pytest.fixture
+    async def library_file_factory(self, db_session):
+        _counter = [0]
+
+        async def _create_file(**kwargs):
+            from backend.app.models.library import LibraryFile
+
+            _counter[0] += 1
+            defaults = {
+                "filename": f"slice_src_{_counter[0]}.3mf",
+                "file_path": f"library/slice_src_{_counter[0]}.3mf",
+                "file_type": "3mf",
+                "file_size": 1024,
+            }
+            defaults.update(kwargs)
+            row = LibraryFile(**defaults)
+            db_session.add(row)
+            await db_session.commit()
+            await db_session.refresh(row)
+            return row
+
+        return _create_file
+
+    # --- library file slice ------------------------------------------------
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_slice_others_library_file(self, async_client, auth_setup, library_file_factory):
+        file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
+        resp = await async_client.post(
+            f"/api/v1/library/files/{file.id}/slice",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            json=self._SLICE_BODY,
+        )
+        assert resp.status_code == 404
+        # 404 (not 403) so a probing operator can't tell the id exists.
+        assert resp.json()["detail"] == "File not found"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_can_slice_own_library_file(self, async_client, auth_setup, library_file_factory):
+        file = await library_file_factory(created_by_id=auth_setup["operator_user"]["id"])
+        resp = await async_client.post(
+            f"/api/v1/library/files/{file.id}/slice",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            json=self._SLICE_BODY,
+        )
+        # Past the ownership gate — only the on-disk source is missing in tests.
+        assert resp.status_code == 404
+        assert resp.json()["detail"] == "Source file missing on disk"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_admin_can_slice_any_library_file(self, async_client, auth_setup, library_file_factory):
+        file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
+        resp = await async_client.post(
+            f"/api/v1/library/files/{file.id}/slice",
+            headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
+            json=self._SLICE_BODY,
+        )
+        # READ_ALL passes the gate even on another user's file.
+        assert resp.status_code == 404
+        assert resp.json()["detail"] == "Source file missing on disk"
+
+    # --- archive slice -----------------------------------------------------
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_slice_others_archive(
+        self, async_client, auth_setup, archive_factory, printer_factory
+    ):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, created_by_id=auth_setup["operator2_user"]["id"])
+        resp = await async_client.post(
+            f"/api/v1/archives/{archive.id}/slice",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            json=self._SLICE_BODY,
+        )
+        assert resp.status_code == 404
+        assert resp.json()["detail"] == "Archive not found"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_can_slice_own_archive(self, async_client, auth_setup, archive_factory, printer_factory):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, created_by_id=auth_setup["operator_user"]["id"])
+        resp = await async_client.post(
+            f"/api/v1/archives/{archive.id}/slice",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            json=self._SLICE_BODY,
+        )
+        # Past the gate — the archive's source file isn't on disk in tests.
+        assert resp.status_code == 404
+        assert resp.json()["detail"] == "Archive source file missing on disk"
+
+    # --- slice-job polling -------------------------------------------------
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slice_job_polling_is_owner_scoped(self, async_client, auth_setup):
+        from backend.app.services.slice_dispatch import slice_dispatch
+
+        async def _noop(_job_id):
+            return {}
+
+        job = await slice_dispatch.enqueue(
+            kind="library_file",
+            source_id=1,
+            source_name="secret_model.3mf",
+            owner_id=auth_setup["operator2_user"]["id"],
+            run=_noop,
+        )
+
+        # Non-owner without READ_ALL cannot see the job (404, not 403).
+        other = await async_client.get(
+            f"/api/v1/slice-jobs/{job.id}",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+        assert other.status_code == 404
+
+        # The owner and a READ_ALL admin can.
+        owner = await async_client.get(
+            f"/api/v1/slice-jobs/{job.id}",
+            headers={"Authorization": f"Bearer {auth_setup['operator2_token']}"},
+        )
+        assert owner.status_code == 200
+        admin = await async_client.get(
+            f"/api/v1/slice-jobs/{job.id}",
+            headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
+        )
+        assert admin.status_code == 200
+
+    # --- pipeline source resolution ----------------------------------------
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_pipeline_run_cannot_reference_others_library_file(
+        self, async_client, auth_setup, library_file_factory, db_session
+    ):
+        """A pipeline runner with READ_OWN cannot resolve another user's source.
+
+        The built-in Operators group has no pipeline permissions, so this uses a
+        custom group carrying PIPELINES_RUN + READ_OWN — the realistic shape of
+        the exposure. check-eligibility resolves the source before any
+        eligibility work, so the ownership gate is what returns 404.
+        """
+        from backend.app.models.slicer_pipeline import SlicerPipeline
+
+        admin_headers = {"Authorization": f"Bearer {auth_setup['admin_token']}"}
+        group_resp = await async_client.post(
+            "/api/v1/groups/",
+            headers=admin_headers,
+            json={
+                "name": "pipeline_runners",
+                "permissions": [
+                    "pipelines:read",
+                    "pipelines:run",
+                    "library:read_own",
+                    "archives:read_own",
+                ],
+            },
+        )
+        assert group_resp.status_code == 201, group_resp.text
+        group_id = group_resp.json()["id"]
+
+        await async_client.post(
+            "/api/v1/users/",
+            headers=admin_headers,
+            json={"username": "runner1", "password": "Runnerpass1!", "group_ids": [group_id]},
+        )
+        runner_login = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "runner1", "password": "Runnerpass1!"},
+        )
+        runner_token = runner_login.json()["access_token"]
+
+        pipeline = SlicerPipeline(
+            name="Cross-user pipeline",
+            printer_preset_source="local",
+            printer_preset_id="1",
+            process_preset_source="local",
+            process_preset_id="2",
+            filament_presets_json="[]",
+            target_kind="printer_class",
+            target_model_class="Bambu Lab X1 Carbon",
+        )
+        db_session.add(pipeline)
+        await db_session.commit()
+        await db_session.refresh(pipeline)
+
+        # Source owned by operator2, not the runner.
+        file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
+        resp = await async_client.post(
+            f"/api/v1/slicer-pipelines/{pipeline.id}/check-eligibility",
+            headers={"Authorization": f"Bearer {runner_token}"},
+            json={"source_library_file_id": file.id},
+        )
+        assert resp.status_code == 404
+        assert resp.json()["detail"] == "File not found"

+ 510 - 30
backend/tests/integration/test_print_queue_api.py

@@ -289,8 +289,8 @@ class TestPrintQueueAPI:
         data = {
             "printer_id": printer.id,
             "archive_id": archive.id,
-            "bed_levelling": False,
-            "flow_cali": True,
+            "bed_levelling": "off",
+            "flow_cali": "on",
             "vibration_cali": False,
             "layer_inspect": True,
             "timelapse": True,
@@ -299,8 +299,8 @@ class TestPrintQueueAPI:
         response = await async_client.post("/api/v1/queue/", json=data)
         assert response.status_code == 200
         result = response.json()
-        assert result["bed_levelling"] is False
-        assert result["flow_cali"] is True
+        assert result["bed_levelling"] == "off"
+        assert result["flow_cali"] == "on"
         assert result["vibration_cali"] is False
         assert result["layer_inspect"] is True
         assert result["timelapse"] is True
@@ -324,15 +324,66 @@ class TestPrintQueueAPI:
         response = await async_client.patch(
             f"/api/v1/queue/{item.id}",
             json={
-                "bed_levelling": False,
+                "bed_levelling": "off",
                 "timelapse": True,
             },
         )
         assert response.status_code == 200
         result = response.json()
-        assert result["bed_levelling"] is False
+        assert result["bed_levelling"] == "off"
         assert result["timelapse"] is True
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reassign_rejected_while_dispatching(
+        self, async_client: AsyncClient, queue_item_factory, printer_factory, db_session
+    ):
+        """#2615: a claimed (in-flight) row rejects edits with 409, so its printer
+        can't be reassigned out from under the running FTP upload."""
+        from datetime import datetime, timezone
+
+        item = await queue_item_factory(dispatching_at=datetime.now(timezone.utc))
+        other = await printer_factory()
+        original_printer_id = item.printer_id
+
+        response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"printer_id": other.id})
+        assert response.status_code == 409
+
+        await db_session.refresh(item)
+        assert item.printer_id == original_printer_id, "printer_id must not change on a dispatching row"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_update_skips_dispatching_item(
+        self, async_client: AsyncClient, queue_item_factory, printer_factory, db_session
+    ):
+        """#2615: bulk edits skip a claimed row rather than splitting it."""
+        from datetime import datetime, timezone
+
+        item = await queue_item_factory(dispatching_at=datetime.now(timezone.utc))
+        other = await printer_factory()
+        original_printer_id = item.printer_id
+
+        response = await async_client.patch("/api/v1/queue/bulk", json={"item_ids": [item.id], "printer_id": other.id})
+        assert response.status_code == 200
+        body = response.json()
+        assert body["skipped_count"] == 1
+        assert body["updated_count"] == 0
+
+        await db_session.refresh(item)
+        assert item.printer_id == original_printer_id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_allowed_on_unclaimed_pending_item(
+        self, async_client: AsyncClient, queue_item_factory, db_session
+    ):
+        """Regression guard: a normal pending row (no claim) still edits fine."""
+        item = await queue_item_factory()
+        response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"plate_id": 7})
+        assert response.status_code == 200
+        assert response.json()["plate_id"] == 7
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_get_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
@@ -848,6 +899,22 @@ class TestQueueLibraryFileSupport:
         assert result["library_file_name"] == "Library Print 1"
         assert result["print_time_seconds"] == 3600
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_library_file_rejects_cross_model_mismatch(
+        self, async_client: AsyncClient, printer_factory, library_file_factory, db_session
+    ):
+        """Cross-model gate (#2578) also reads sliced_for_model from library file metadata."""
+        await printer_factory(model="H2D")
+        lib_file = await library_file_factory(file_metadata={"print_name": "Mismatch", "sliced_for_model": "X1C"})
+
+        response = await async_client.post(
+            "/api/v1/queue/",
+            json={"target_model": "H2D", "library_file_id": lib_file.id},
+        )
+        assert response.status_code == 400
+        assert "sliced for X1C" in response.json()["detail"]
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_add_to_queue_library_file_with_options(
@@ -862,7 +929,7 @@ class TestQueueLibraryFileSupport:
             "library_file_id": lib_file.id,
             "ams_mapping": [1, 2, -1, -1],
             "plate_id": 2,
-            "bed_levelling": False,
+            "bed_levelling": "off",
             "timelapse": True,
             "manual_start": True,
         }
@@ -872,7 +939,7 @@ class TestQueueLibraryFileSupport:
         assert result["library_file_id"] == lib_file.id
         assert result["ams_mapping"] == [1, 2, -1, -1]
         assert result["plate_id"] == 2
-        assert result["bed_levelling"] is False
+        assert result["bed_levelling"] == "off"
         assert result["timelapse"] is True
         assert result["manual_start"] is True
 
@@ -1038,8 +1105,8 @@ class TestBulkUpdateEndpoint:
             defaults = {
                 "status": "pending",
                 "position": 1,
-                "bed_levelling": True,
-                "flow_cali": False,
+                "bed_levelling": "on",
+                "flow_cali": "off",
                 "vibration_cali": True,
             }
             defaults.update(kwargs)
@@ -1056,12 +1123,12 @@ class TestBulkUpdateEndpoint:
     @pytest.mark.integration
     async def test_bulk_update_single_field(self, async_client: AsyncClient, queue_item_factory, db_session):
         """Verify bulk update can change a single field on multiple items."""
-        item1 = await queue_item_factory(bed_levelling=True)
-        item2 = await queue_item_factory(bed_levelling=True)
+        item1 = await queue_item_factory(bed_levelling="on")
+        item2 = await queue_item_factory(bed_levelling="on")
 
         response = await async_client.patch(
             "/api/v1/queue/bulk",
-            json={"item_ids": [item1.id, item2.id], "bed_levelling": False},
+            json={"item_ids": [item1.id, item2.id], "bed_levelling": "off"},
         )
         assert response.status_code == 200
         result = response.json()
@@ -1071,22 +1138,22 @@ class TestBulkUpdateEndpoint:
         # Verify items were updated
         await db_session.refresh(item1)
         await db_session.refresh(item2)
-        assert item1.bed_levelling is False
-        assert item2.bed_levelling is False
+        assert item1.bed_levelling == "off"
+        assert item2.bed_levelling == "off"
 
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_bulk_update_multiple_fields(self, async_client: AsyncClient, queue_item_factory, db_session):
         """Verify bulk update can change multiple fields at once."""
-        item1 = await queue_item_factory(bed_levelling=True, flow_cali=False, manual_start=False)
-        item2 = await queue_item_factory(bed_levelling=True, flow_cali=False, manual_start=False)
+        item1 = await queue_item_factory(bed_levelling="on", flow_cali="off", manual_start=False)
+        item2 = await queue_item_factory(bed_levelling="on", flow_cali="off", manual_start=False)
 
         response = await async_client.patch(
             "/api/v1/queue/bulk",
             json={
                 "item_ids": [item1.id, item2.id],
-                "bed_levelling": False,
-                "flow_cali": True,
+                "bed_levelling": "off",
+                "flow_cali": "on",
                 "manual_start": True,
             },
         )
@@ -1095,23 +1162,23 @@ class TestBulkUpdateEndpoint:
         assert result["updated_count"] == 2
 
         await db_session.refresh(item1)
-        assert item1.bed_levelling is False
-        assert item1.flow_cali is True
+        assert item1.bed_levelling == "off"
+        assert item1.flow_cali == "on"
         assert item1.manual_start is True
 
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_bulk_update_skips_non_pending(self, async_client: AsyncClient, queue_item_factory, db_session):
         """Verify bulk update skips non-pending items."""
-        pending_item = await queue_item_factory(status="pending", bed_levelling=True)
-        printing_item = await queue_item_factory(status="printing", bed_levelling=True)
-        completed_item = await queue_item_factory(status="completed", bed_levelling=True)
+        pending_item = await queue_item_factory(status="pending", bed_levelling="on")
+        printing_item = await queue_item_factory(status="printing", bed_levelling="on")
+        completed_item = await queue_item_factory(status="completed", bed_levelling="on")
 
         response = await async_client.patch(
             "/api/v1/queue/bulk",
             json={
                 "item_ids": [pending_item.id, printing_item.id, completed_item.id],
-                "bed_levelling": False,
+                "bed_levelling": "off",
             },
         )
         assert response.status_code == 200
@@ -1123,9 +1190,9 @@ class TestBulkUpdateEndpoint:
         await db_session.refresh(pending_item)
         await db_session.refresh(printing_item)
         await db_session.refresh(completed_item)
-        assert pending_item.bed_levelling is False
-        assert printing_item.bed_levelling is True
-        assert completed_item.bed_levelling is True
+        assert pending_item.bed_levelling == "off"
+        assert printing_item.bed_levelling == "on"
+        assert completed_item.bed_levelling == "on"
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -1401,6 +1468,90 @@ class TestTargetLocationFeature:
         result = response.json()
         assert result["target_location"] is None
 
+    # ------------------------------------------------------------------
+    # Cross-model dispatch gate (#2578): a G-code 3MF sliced for one model
+    # must not be queued for model-based dispatch to an incompatible model.
+    # ------------------------------------------------------------------
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_rejects_cross_model_mismatch(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """X1C-sliced archive + target_model=H2D must be rejected (#2578)."""
+        await printer_factory(model="H2D")
+        archive = await archive_factory(sliced_for_model="X1C")
+
+        response = await async_client.post(
+            "/api/v1/queue/",
+            json={"target_model": "H2D", "archive_id": archive.id},
+        )
+        assert response.status_code == 400
+        assert "sliced for X1C" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_allows_gcode_family_target(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """X1C-sliced G-code on a P1S is an intentional mixed-farm workflow —
+        same kinematics/volume family, must stay allowed."""
+        await printer_factory(model="P1S")
+        archive = await archive_factory(sliced_for_model="X1C")
+
+        response = await async_client.post(
+            "/api/v1/queue/",
+            json={"target_model": "P1S", "archive_id": archive.id},
+        )
+        assert response.status_code == 200
+        assert response.json()["target_model"] == "P1S"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_without_sliced_metadata_not_blocked(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Legacy archives without sliced_for_model can't be validated — must keep working."""
+        await printer_factory(model="H2D")
+        archive = await archive_factory()  # no sliced_for_model
+
+        response = await async_client.post(
+            "/api/v1/queue/",
+            json={"target_model": "H2D", "archive_id": archive.id},
+        )
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_rejects_cross_model_mismatch(
+        self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory, db_session
+    ):
+        """Editing an item must not be able to introduce an incompatible target either."""
+        await printer_factory(model="X1C")
+        await printer_factory(model="H2D")
+        archive = await archive_factory(sliced_for_model="X1C")
+        item = await queue_item_factory(printer_id=None, target_model="X1C", archive_id=archive.id)
+
+        response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"target_model": "H2D"})
+        assert response.status_code == 400
+        assert "sliced for X1C" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_can_fix_stale_mismatched_target(
+        self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory, db_session
+    ):
+        """A pre-fix DB row with a wrong target (the reporter's rows 78-82) must be
+        repairable by editing the target back to the sliced-for model."""
+        await printer_factory(model="X1C")
+        archive = await archive_factory(sliced_for_model="X1C")
+        # Stale mismatched row written directly to the DB (bypasses the API gate)
+        item = await queue_item_factory(printer_id=None, target_model="H2D", archive_id=archive.id)
+
+        response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"target_model": "X1C"})
+        assert response.status_code == 200
+        assert response.json()["target_model"] == "X1C"
+
 
 class TestAbortedStatusNormalisation:
     """Tests for issue #558: 'aborted' queue status causes 500 error."""
@@ -2099,7 +2250,7 @@ class TestAbortedStatusNormalisation:
             "printer_id": printer.id,
             "archive_id": archive.id,
             "quantity": 2,
-            "bed_levelling": False,
+            "bed_levelling": "off",
             "timelapse": True,
         }
         response = await async_client.post("/api/v1/queue/", json=data)
@@ -2110,7 +2261,7 @@ class TestAbortedStatusNormalisation:
         batch_items = [i for i in list_response.json() if i["batch_id"] == batch_id]
         assert len(batch_items) == 2
         for item in batch_items:
-            assert item["bed_levelling"] is False
+            assert item["bed_levelling"] == "off"
             assert item["timelapse"] is True
 
     @pytest.mark.asyncio
@@ -2807,3 +2958,332 @@ class TestReorderEndpoint:
         await db_session.refresh(item2)
         assert item1.position == 2
         assert item2.position == 1
+
+
+class TestForceColorOverridesAreScopedToThePlate:
+    """Queueing several plates of one 3MF must not make each plate wait on the
+    colours of its siblings (#2551).
+
+    The print dialog builds one override list from every selected plate and posts
+    that same list with each plate's item, so the API is what has to keep only the
+    slots the plate prints -- a ``force_color_match`` entry blocks dispatch until
+    the printer has that exact colour loaded.
+    """
+
+    THREE_PLATES = """<?xml version="1.0" encoding="UTF-8"?>
+    <config>
+        <plate>
+            <metadata key="index" value="1"/>
+            <filament id="1" used_g="50.0" type="PLA" color="#0B2C7A"/>
+        </plate>
+        <plate>
+            <metadata key="index" value="2"/>
+            <filament id="2" used_g="40.0" type="PLA" color="#9B9EA0"/>
+        </plate>
+        <plate>
+            <metadata key="index" value="3"/>
+            <filament id="3" used_g="30.0" type="PLA" color="#F4EE2A"/>
+        </plate>
+    </config>
+    """
+
+    # What the dialog posts for every plate: the union of all three plates'
+    # filaments, each one force-matched.
+    ALL_THREE_COLORS = [
+        {"slot_id": 1, "type": "PLA", "color": "#0B2C7A", "color_name": "Army Blue", "force_color_match": True},
+        {"slot_id": 2, "type": "PLA", "color": "#9B9EA0", "color_name": "Ash Grey", "force_color_match": True},
+        {"slot_id": 3, "type": "PLA", "color": "#F4EE2A", "color_name": "Sunshine Yellow", "force_color_match": True},
+    ]
+
+    @pytest.fixture
+    async def multi_plate_archive(self, db_session, tmp_path):
+        """An archive whose 3MF really exists on disk, one colour per plate."""
+        import zipfile
+
+        from backend.app.models.archive import PrintArchive
+
+        file_path = tmp_path / "three_plates.gcode.3mf"
+        with zipfile.ZipFile(file_path, "w") as zf:
+            zf.writestr("Metadata/slice_info.config", self.THREE_PLATES)
+
+        archive = PrintArchive(
+            filename="three_plates.gcode.3mf",
+            print_name="Three Plates",
+            file_path=str(file_path),
+            file_size=file_path.stat().st_size,
+            content_hash="platehash0001",
+            status="completed",
+        )
+        db_session.add(archive)
+        await db_session.commit()
+        await db_session.refresh(archive)
+        return archive
+
+    @pytest.fixture
+    async def x1c(self, db_session):
+        from backend.app.models.printer import Printer
+
+        printer = Printer(
+            name="Force Color X1C",
+            ip_address="192.168.1.210",
+            serial_number="FORCECOLOR01",
+            access_code="12345678",
+            model="X1C",
+        )
+        db_session.add(printer)
+        await db_session.commit()
+        await db_session.refresh(printer)
+        return printer
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_each_plate_keeps_only_the_colour_it_prints(
+        self, async_client: AsyncClient, multi_plate_archive, x1c
+    ):
+        """The bug: plate 1 prints Army Blue only, but was stored demanding all three."""
+        stored = {}
+        for plate_id in (1, 2, 3):
+            response = await async_client.post(
+                "/api/v1/queue/",
+                json={
+                    "target_model": "X1C",
+                    "archive_id": multi_plate_archive.id,
+                    "plate_id": plate_id,
+                    "filament_overrides": self.ALL_THREE_COLORS,
+                },
+            )
+            assert response.status_code == 200
+            stored[plate_id] = response.json()["filament_overrides"]
+
+        assert [o["color_name"] for o in stored[1]] == ["Army Blue"]
+        assert [o["color_name"] for o in stored[2]] == ["Ash Grey"]
+        assert [o["color_name"] for o in stored[3]] == ["Sunshine Yellow"]
+        # The slot each entry maps to has to survive narrowing untouched, or the
+        # dispatch-time AMS mapping would key the override onto the wrong slot.
+        assert [o["slot_id"] for o in stored[2]] == [2]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_whole_file_queue_keeps_every_colour(self, async_client: AsyncClient, multi_plate_archive, x1c):
+        """No plate_id means the job prints the whole file, so every colour is needed."""
+        response = await async_client.post(
+            "/api/v1/queue/",
+            json={
+                "target_model": "X1C",
+                "archive_id": multi_plate_archive.id,
+                "filament_overrides": self.ALL_THREE_COLORS,
+            },
+        )
+        assert response.status_code == 200
+        assert len(response.json()["filament_overrides"]) == 3
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unreadable_3mf_keeps_every_colour(self, async_client: AsyncClient, db_session, tmp_path, x1c):
+        """When the plate's slots can't be read, keep the overrides rather than drop them.
+
+        An item waiting on a colour it doesn't need is visible and fixable; one that
+        silently lost its forced colour would dispatch in the wrong filament.
+        """
+        from backend.app.models.archive import PrintArchive
+
+        file_path = tmp_path / "not_a_zip.gcode.3mf"
+        file_path.write_text("this is not a 3mf")
+        archive = PrintArchive(
+            filename="not_a_zip.gcode.3mf",
+            print_name="Corrupt",
+            file_path=str(file_path),
+            file_size=file_path.stat().st_size,
+            content_hash="platehash0002",
+            status="completed",
+        )
+        db_session.add(archive)
+        await db_session.commit()
+        await db_session.refresh(archive)
+
+        response = await async_client.post(
+            "/api/v1/queue/",
+            json={
+                "target_model": "X1C",
+                "archive_id": archive.id,
+                "plate_id": 1,
+                "filament_overrides": self.ALL_THREE_COLORS,
+            },
+        )
+        assert response.status_code == 200
+        assert len(response.json()["filament_overrides"]) == 3
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_required_types_stay_scoped_to_the_plate(self, async_client: AsyncClient, db_session, tmp_path, x1c):
+        """Override types are merged into required_filament_types, so a shared list
+        also widened the type gate -- a PLA-only plate demanded PETG as well."""
+        import zipfile
+
+        from backend.app.models.archive import PrintArchive
+
+        xml = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+            <plate>
+                <metadata key="index" value="1"/>
+                <filament id="1" used_g="50.0" type="PLA" color="#0B2C7A"/>
+            </plate>
+            <plate>
+                <metadata key="index" value="2"/>
+                <filament id="2" used_g="40.0" type="PETG" color="#9B9EA0"/>
+            </plate>
+        </config>
+        """
+        file_path = tmp_path / "mixed_types.gcode.3mf"
+        with zipfile.ZipFile(file_path, "w") as zf:
+            zf.writestr("Metadata/slice_info.config", xml)
+        archive = PrintArchive(
+            filename="mixed_types.gcode.3mf",
+            print_name="Mixed",
+            file_path=str(file_path),
+            file_size=file_path.stat().st_size,
+            content_hash="platehash0003",
+            status="completed",
+        )
+        db_session.add(archive)
+        await db_session.commit()
+        await db_session.refresh(archive)
+
+        response = await async_client.post(
+            "/api/v1/queue/",
+            json={
+                "target_model": "X1C",
+                "archive_id": archive.id,
+                "plate_id": 1,
+                "filament_overrides": [
+                    {"slot_id": 1, "type": "PLA", "color": "#0B2C7A", "force_color_match": True},
+                    {"slot_id": 2, "type": "PETG", "color": "#9B9EA0", "force_color_match": True},
+                ],
+            },
+        )
+        assert response.status_code == 200
+        assert response.json()["required_filament_types"] == ["PLA"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_editing_an_item_narrows_the_overrides_too(
+        self, async_client: AsyncClient, db_session, multi_plate_archive, x1c
+    ):
+        """The edit dialog posts the same shared list, so PATCH narrows it as well."""
+        from backend.app.models.print_queue import PrintQueueItem
+
+        item = PrintQueueItem(
+            target_model="X1C",
+            archive_id=multi_plate_archive.id,
+            plate_id=2,
+            status="pending",
+            position=1,
+        )
+        db_session.add(item)
+        await db_session.commit()
+        await db_session.refresh(item)
+
+        response = await async_client.patch(
+            f"/api/v1/queue/{item.id}",
+            json={"filament_overrides": self.ALL_THREE_COLORS},
+        )
+        assert response.status_code == 200
+        assert [o["color_name"] for o in response.json()["filament_overrides"]] == ["Ash Grey"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_editing_the_plate_renarrows_against_the_new_plate(
+        self, async_client: AsyncClient, db_session, multi_plate_archive, x1c
+    ):
+        """Moving an item to another plate must re-scope its colours to that plate."""
+        from backend.app.models.print_queue import PrintQueueItem
+
+        item = PrintQueueItem(
+            target_model="X1C",
+            archive_id=multi_plate_archive.id,
+            plate_id=1,
+            status="pending",
+            position=1,
+        )
+        db_session.add(item)
+        await db_session.commit()
+        await db_session.refresh(item)
+
+        response = await async_client.patch(
+            f"/api/v1/queue/{item.id}",
+            json={"plate_id": 3, "filament_overrides": self.ALL_THREE_COLORS},
+        )
+        assert response.status_code == 200
+        assert [o["color_name"] for o in response.json()["filament_overrides"]] == ["Sunshine Yellow"]
+
+
+@pytest.mark.asyncio
+async def test_stop_offline_reconciles_linked_archive_status_2603(
+    async_client: AsyncClient, printer_factory, archive_factory, db_session
+):
+    """Stopping a printing item while the printer is offline must also close out its
+    archive (#2603).
+
+    When the stop command reaches the printer, the later MQTT completion event flips
+    the archive to cancelled. When the printer is offline no such event ever arrives,
+    so without this the archive stays "printing" forever while the queue row is
+    already cancelled — the reporter's archive 436. The offline branch reconciles the
+    archive directly.
+    """
+    from unittest.mock import MagicMock, patch
+
+    from backend.app.models.print_queue import PrintQueueItem
+
+    printer = await printer_factory(name="Offline printer")
+    archive = await archive_factory(
+        printer.id, status="printing", plate_id=22, filename="heart 3.gcode.3mf", with_run=False
+    )
+    item = PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="printing")
+    db_session.add(item)
+    await db_session.commit()
+    await db_session.refresh(item)
+
+    # stop_print returns False => printer offline / not connected.
+    with patch(
+        "backend.app.services.printer_manager.printer_manager.stop_print",
+        MagicMock(return_value=False),
+    ):
+        resp = await async_client.post(f"/api/v1/queue/{item.id}/stop")
+
+    assert resp.status_code == 200
+    await db_session.refresh(item)
+    await db_session.refresh(archive)
+    assert item.status == "cancelled"
+    assert archive.status == "cancelled", "an offline stop must reconcile the archive, not leave it 'printing'"
+    assert archive.completed_at is not None
+    assert archive.failure_reason == "Stopped by user (printer was offline)"
+
+
+@pytest.mark.asyncio
+async def test_stop_online_leaves_archive_for_mqtt_to_reconcile_2603(
+    async_client: AsyncClient, printer_factory, archive_factory, db_session
+):
+    """When the stop command reaches the printer, the archive is left to the MQTT
+    completion path — the offline reconcile must NOT fire and pre-empt it."""
+    from unittest.mock import MagicMock, patch
+
+    from backend.app.models.print_queue import PrintQueueItem
+
+    printer = await printer_factory(name="Online printer")
+    archive = await archive_factory(printer.id, status="printing", filename="heart 3.gcode.3mf", with_run=False)
+    item = PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="printing")
+    db_session.add(item)
+    await db_session.commit()
+    await db_session.refresh(item)
+
+    with patch(
+        "backend.app.services.printer_manager.printer_manager.stop_print",
+        MagicMock(return_value=True),
+    ):
+        resp = await async_client.post(f"/api/v1/queue/{item.id}/stop")
+
+    assert resp.status_code == 200
+    await db_session.refresh(item)
+    await db_session.refresh(archive)
+    assert item.status == "cancelled"
+    assert archive.status == "printing", "an online stop must leave the archive for the MQTT completion path"

+ 213 - 1
backend/tests/integration/test_printers_api.py

@@ -450,6 +450,114 @@ class TestPrintersAPI:
         assert response.status_code == 200
         assert response.content == b"PLATE_4_PNG"
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cover_pick_view_serves_active_plate_object_mask(
+        self, async_client: AsyncClient, printer_factory, db_session, tmp_path
+    ):
+        """The skip-items UI needs the slicer's exact object-ID mask, not an
+        inferred bounding box, so a plate click resolves to the firmware ID."""
+        import zipfile
+        from unittest.mock import MagicMock, patch
+
+        from backend.app.services.bambu_ftp import cache_3mf_download
+        from backend.app.services.bambu_mqtt import PrinterState
+
+        printer = await printer_factory()
+        threemf_path = tmp_path / "PickMask.3mf"
+        with zipfile.ZipFile(threemf_path, "w") as zf:
+            zf.writestr("Metadata/pick_1.png", b"PICK_ONE")
+            zf.writestr("Metadata/pick_3.png", b"PICK_THREE")
+            zf.writestr("Metadata/plate_3.gcode", "; active plate\n")
+
+        cache_3mf_download(printer.id, "PickMask.3mf", threemf_path)
+
+        state = PrinterState()
+        state.connected = True
+        state.state = "RUNNING"
+        state.subtask_name = "PickMask"
+        state.gcode_file = "PickMask.3mf"
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status = MagicMock(return_value=state)
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/cover?view=pick")
+
+        assert response.status_code == 200
+        assert response.content == b"PICK_THREE"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cover_pick_view_404s_rather_than_serving_a_render(
+        self, async_client: AsyncClient, printer_factory, db_session, tmp_path
+    ):
+        """A mask is coordinates, not decoration. Archives without pick_N.png
+        (Handy jobs, older slicers) must 404 so the UI drops to the checklist —
+        every other view's fallback to a rendered thumbnail would be decoded as
+        object IDs here, and a click would skip an arbitrary object."""
+        import zipfile
+        from unittest.mock import MagicMock, patch
+
+        from backend.app.services.bambu_ftp import cache_3mf_download
+        from backend.app.services.bambu_mqtt import PrinterState
+
+        printer = await printer_factory()
+        threemf_path = tmp_path / "NoMask.3mf"
+        with zipfile.ZipFile(threemf_path, "w") as zf:
+            zf.writestr("Metadata/top_1.png", b"TOP_RENDER")
+            zf.writestr("Metadata/plate_1.png", b"PLATE_RENDER")
+            zf.writestr("Metadata/plate_1.gcode", "; active plate\n")
+
+        cache_3mf_download(printer.id, "NoMask.3mf", threemf_path)
+
+        state = PrinterState()
+        state.connected = True
+        state.state = "RUNNING"
+        state.subtask_name = "NoMask"
+        state.gcode_file = "NoMask.3mf"
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status = MagicMock(return_value=state)
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/cover?view=pick")
+
+        assert response.status_code == 404
+        assert b"RENDER" not in response.content
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cover_pick_view_does_not_borrow_another_plates_mask(
+        self, async_client: AsyncClient, printer_factory, db_session, tmp_path
+    ):
+        """Plate 1's mask over plate 3's layout resolves clicks to whatever
+        occupied that pixel on a different plate, so the active plate's mask is
+        the only acceptable answer."""
+        import zipfile
+        from unittest.mock import MagicMock, patch
+
+        from backend.app.services.bambu_ftp import cache_3mf_download
+        from backend.app.services.bambu_mqtt import PrinterState
+
+        printer = await printer_factory()
+        threemf_path = tmp_path / "OtherPlate.3mf"
+        with zipfile.ZipFile(threemf_path, "w") as zf:
+            zf.writestr("Metadata/pick_1.png", b"PICK_ONE")
+            zf.writestr("Metadata/top_3.png", b"TOP_THREE")
+            zf.writestr("Metadata/plate_3.gcode", "; active plate\n")
+
+        cache_3mf_download(printer.id, "OtherPlate.3mf", threemf_path)
+
+        state = PrinterState()
+        state.connected = True
+        state.state = "RUNNING"
+        state.subtask_name = "OtherPlate"
+        state.gcode_file = "OtherPlate.3mf"
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status = MagicMock(return_value=state)
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/cover?view=pick")
+
+        assert response.status_code == 404
+        assert response.content != b"PICK_ONE"
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_cover_3mf_scan_fallback_for_per_plate_archive(
@@ -571,6 +679,32 @@ class TestPrintersAPI:
     # ========================================================================
 
 
+class TestCoverPoolHygiene:
+    """Regression guard for the /cover DB-connection leak (issue #2572)."""
+
+    def test_cover_does_not_hold_a_get_db_session(self):
+        """The cover endpoint must NOT take a ``Depends(get_db)`` session.
+
+        ``get_db`` is a ``yield`` dependency, so its session stays open for the
+        whole request — including the 3MF cover download (up to 8 remote paths ×
+        retries with backoff, minutes under FTP contention), pinning one pooled
+        DB connection ``idle in transaction`` the entire time. The endpoint
+        fetches the printer in a short-lived ``async with async_session()`` and
+        releases the connection before the FTP work. If someone re-adds a
+        ``Depends(get_db)`` param, this fails.
+        """
+        import inspect
+
+        from backend.app.api.routes.printers import get_db, get_printer_cover
+
+        for name, param in inspect.signature(get_printer_cover).parameters.items():
+            dependency = getattr(param.default, "dependency", None)
+            assert dependency is not get_db, (
+                f"get_printer_cover re-introduced a get_db-held session via parameter {name!r} — "
+                "it would stay open for the entire FTP cover download (issue #2572)"
+            )
+
+
 class TestPrinterDataIntegrity:
     """Tests for printer data integrity."""
 
@@ -1104,6 +1238,82 @@ class TestConfigureAMSSlotAPI:
             call_kwargs = mock_client.ams_set_filament_setting.call_args
             assert call_kwargs.kwargs["tray_info_idx"] == "GFL05"
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_configure_builtin_empty_setting_id_is_derived(self, async_client: AsyncClient, printer_factory):
+        """A built-in preset (GF* tray_info_idx, empty setting_id) gets a derived GFS* setting_id (#2604).
+
+        The Configure AMS Slot modal sends built-in / local / Orca-generic presets with an
+        empty setting_id. Publishing a filament-id-without-setting-id slot makes the printer
+        treat it as half configured and revert to its previous profile, so the route must
+        back-fill setting_id from the resolved tray_info_idx.
+        """
+        printer = await printer_factory(name="X1C")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+        mock_client.request_status_update.return_value = True
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = None
+
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/slots/0/1/configure",
+                params={
+                    "tray_info_idx": "GFB99",  # Generic ABS, built-in
+                    "tray_type": "ABS",
+                    "tray_sub_brands": "Generic ABS",
+                    "tray_color": "000000FF",
+                    "nozzle_temp_min": 240,
+                    "nozzle_temp_max": 280,
+                    # setting_id intentionally omitted (empty) — as the modal sends it
+                },
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFB99"
+            assert call_kwargs.kwargs["setting_id"] == "GFSB99"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_configure_generic_material_fallback_derives_setting_id(
+        self, async_client: AsyncClient, printer_factory
+    ):
+        """When only a material is given, the generic tray_info_idx fallback still yields a setting_id (#2604)."""
+        printer = await printer_factory(name="X1C")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+        mock_client.request_status_update.return_value = True
+
+        mock_status = MagicMock()
+        mock_status.raw_data = {"ams": {"ams": []}}  # No existing tray to reuse
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = mock_status
+
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/slots/0/1/configure",
+                params={
+                    "tray_info_idx": "",  # No preset id — route derives generic from material
+                    "tray_type": "ABS",
+                    "tray_sub_brands": "Generic ABS",
+                    "tray_color": "000000FF",
+                    "nozzle_temp_min": 240,
+                    "nozzle_temp_max": 280,
+                },
+            )
+
+            assert response.status_code == 200
+            call_kwargs = mock_client.ams_set_filament_setting.call_args
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFB99"
+            assert call_kwargs.kwargs["setting_id"] == "GFSB99"
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_configure_pfus_sent_directly(self, async_client: AsyncClient, printer_factory):
@@ -3780,7 +3990,7 @@ class TestXYJogAPI:
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_success_x_only_emits_relative_gcode(self, async_client: AsyncClient, printer_factory):
-        """X-only jog should emit G91/G90 wrapping and only include the X axis."""
+        """X-only jog is a bare relative move (no M211), wraps in G91/G90, X only."""
         printer = await printer_factory(name="P", model="X1C")
         mock_client = MagicMock()
         mock_client.send_gcode.return_value = True
@@ -3789,6 +3999,8 @@ class TestXYJogAPI:
             response = await async_client.post(f"/api/v1/printers/{printer.id}/xy-jog?x=10&y=0")
         assert response.status_code == 200
         sent = mock_client.send_gcode.call_args.args[0]
+        # Never touch M211 — bare move, exactly like the touchscreen (#2579).
+        assert "M211" not in sent
         assert sent.startswith("G91\n")
         assert sent.endswith("\nG90")
         assert "X10.00" in sent

+ 95 - 0
backend/tests/integration/test_projects_api.py

@@ -1298,3 +1298,98 @@ class TestProjectExportImport:
         assert response.status_code == 200, response.text
         data = response.json()
         assert data["name"] == "nested-ok"
+
+
+class TestProjectListEditableFields:
+    """Tests for #2536 — the project list payload must carry every field the
+    shared edit dialog renders. The dialog is opened from both the project list
+    and the project detail page and seeds itself from whichever project object it
+    is handed, so a field missing from the list payload shows up blank there and
+    is saved back over the stored value."""
+
+    @pytest.fixture
+    async def project_factory(self, db_session):
+        async def _create(**kwargs):
+            from backend.app.models.project import Project
+
+            defaults = {"name": "Editable Fields Project", "color": "#123456"}
+            defaults.update(kwargs)
+            project = Project(**defaults)
+            db_session.add(project)
+            await db_session.commit()
+            await db_session.refresh(project)
+            return project
+
+        return _create
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_carries_the_fields_the_edit_dialog_renders(self, async_client: AsyncClient, project_factory):
+        """The list view is where the reporter saw an empty tags field."""
+        from datetime import datetime
+
+        await project_factory(
+            name="Tagged Project",
+            tags="prototype,client-work",
+            due_date=datetime(2026, 8, 1, 12, 0, 0),
+            priority="high",
+            target_parts_count=7,
+        )
+
+        response = await async_client.get("/api/v1/projects/")
+        assert response.status_code == 200
+        item = next(p for p in response.json() if p["name"] == "Tagged Project")
+
+        assert item["tags"] == "prototype,client-work"
+        assert item["due_date"].startswith("2026-08-01")
+        assert item["priority"] == "high"
+        assert item["target_parts_count"] == 7
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_template_list_carries_them_too(self, async_client: AsyncClient, project_factory):
+        """Templates feed the same dialog, so they need the same payload."""
+        await project_factory(
+            name="Tagged Template",
+            is_template=True,
+            tags="reusable",
+            priority="urgent",
+            target_parts_count=3,
+        )
+
+        response = await async_client.get("/api/v1/projects/templates")
+        assert response.status_code == 200
+        item = next(p for p in response.json() if p["name"] == "Tagged Template")
+
+        assert item["tags"] == "reusable"
+        assert item["priority"] == "urgent"
+        assert item["target_parts_count"] == 3
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_priority_survives_an_edit_that_does_not_touch_it(self, async_client: AsyncClient, project_factory):
+        """A save from the list view used to submit the default priority over a
+        stored 'high' — the dialog never received the real one."""
+        project = await project_factory(name="Important", priority="high", tags="keep-me")
+
+        response = await async_client.patch(f"/api/v1/projects/{project.id}", json={"name": "Still Important"})
+        assert response.status_code == 200
+
+        result = response.json()
+        assert result["priority"] == "high"
+        assert result["tags"] == "keep-me"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_explicit_null_clears_tags_and_due_date(self, async_client: AsyncClient, project_factory):
+        """Emptying the field in the dialog has to actually remove the value."""
+        from datetime import datetime
+
+        project = await project_factory(name="Clearable", tags="obsolete", due_date=datetime(2026, 8, 1, 12, 0, 0))
+
+        response = await async_client.patch(f"/api/v1/projects/{project.id}", json={"tags": None, "due_date": None})
+        assert response.status_code == 200
+
+        result = response.json()
+        assert result["tags"] is None
+        assert result["due_date"] is None

+ 32 - 14
backend/tests/integration/test_settings_api.py

@@ -485,8 +485,9 @@ class TestSettingsAPI:
         response = await async_client.get("/api/v1/settings/")
         result = response.json()
 
-        assert result["default_bed_levelling"] is True
-        assert result["default_flow_cali"] is False
+        # bed_levelling / flow_cali are tri-state, defaulting to "auto".
+        assert result["default_bed_levelling"] == "auto"
+        assert result["default_flow_cali"] == "auto"
         assert result["default_vibration_cali"] is True
         assert result["default_layer_inspect"] is False
         assert result["default_timelapse"] is False
@@ -494,12 +495,12 @@ class TestSettingsAPI:
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_update_default_print_options(self, async_client: AsyncClient):
-        """Verify default print options can be updated."""
+        """Verify default print options can be updated (tri-state + booleans)."""
         response = await async_client.put(
             "/api/v1/settings/",
             json={
-                "default_bed_levelling": False,
-                "default_flow_cali": True,
+                "default_bed_levelling": "off",
+                "default_flow_cali": "on",
                 "default_vibration_cali": False,
                 "default_layer_inspect": True,
                 "default_timelapse": True,
@@ -508,12 +509,29 @@ class TestSettingsAPI:
 
         assert response.status_code == 200
         result = response.json()
-        assert result["default_bed_levelling"] is False
-        assert result["default_flow_cali"] is True
+        assert result["default_bed_levelling"] == "off"
+        assert result["default_flow_cali"] == "on"
         assert result["default_vibration_cali"] is False
         assert result["default_layer_inspect"] is True
         assert result["default_timelapse"] is True
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_default_print_options_legacy_bool_coerced(self, async_client: AsyncClient):
+        """Old clients sending booleans for the tri-state options still work.
+
+        The TriState validator maps true->"on", false->"off" on input so a
+        pre-upgrade frontend never writes an invalid value.
+        """
+        response = await async_client.put(
+            "/api/v1/settings/",
+            json={"default_bed_levelling": False, "default_flow_cali": True},
+        )
+        assert response.status_code == 200
+        result = response.json()
+        assert result["default_bed_levelling"] == "off"
+        assert result["default_flow_cali"] == "on"
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_default_print_options_persist(self, async_client: AsyncClient):
@@ -521,14 +539,14 @@ class TestSettingsAPI:
         await async_client.put(
             "/api/v1/settings/",
             json={
-                "default_bed_levelling": False,
+                "default_bed_levelling": "on",
                 "default_timelapse": True,
             },
         )
 
         response = await async_client.get("/api/v1/settings/")
         result = response.json()
-        assert result["default_bed_levelling"] is False
+        assert result["default_bed_levelling"] == "on"
         assert result["default_timelapse"] is True
 
     @pytest.mark.asyncio
@@ -539,21 +557,21 @@ class TestSettingsAPI:
         await async_client.put(
             "/api/v1/settings/",
             json={
-                "default_bed_levelling": False,
-                "default_flow_cali": True,
+                "default_bed_levelling": "off",
+                "default_flow_cali": "on",
             },
         )
 
         # Update only one
         response = await async_client.put(
             "/api/v1/settings/",
-            json={"default_bed_levelling": True},
+            json={"default_bed_levelling": "auto"},
         )
 
         assert response.status_code == 200
         result = response.json()
-        assert result["default_bed_levelling"] is True
-        assert result["default_flow_cali"] is True  # Should remain from previous update
+        assert result["default_bed_levelling"] == "auto"
+        assert result["default_flow_cali"] == "on"  # Should remain from previous update
 
     # ========================================================================
     # Home Assistant environment variable tests

+ 66 - 0
backend/tests/integration/test_stats_reconciled_duration_2592.py

@@ -0,0 +1,66 @@
+"""Regression test for reconnect-reconciliation inflating Total Print Time (#2592).
+
+On a farm, a connected-edge reconcile closes out every stale ``status="printing"``
+archive as an aborted run whose duration was computed ``completed_at - started_at``
+— i.e. the whole multi-day disconnect gap — and the Stats endpoint's fallback
+recomputed the same value even when the stored duration was 0/NULL. A single
+reconnect could add hundreds of fictitious print hours (reporter @Jostxxl saw
+Total Print Time jump from ~1,500h to 3,215h).
+
+The fix stores an explicit 0 for reconciled entries and makes the Stats total
+trust that 0 instead of recomputing from the stale timestamps. This test drives
+the ``/archives/stats`` endpoint with hand-crafted rows covering the reporter's
+scenarios.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.models.print_log import PrintLogEntry
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_stats_total_time_ignores_reconciled_but_keeps_real_runtime(async_client: AsyncClient, db_session):
+    base = datetime(2026, 7, 15, 10, 0, 0)
+    reconnect = base + timedelta(days=2, hours=4)  # multi-day gap → ~52h each if recomputed
+
+    rows = [
+        # Two reconciled aborts for the same printer: duration logged as 0 on
+        # purpose (unknown real end time). Their timestamps span days, so the
+        # bug would have banked ~52h each (~104h total) via the fallback.
+        PrintLogEntry(printer_id=1, status="aborted", started_at=base, completed_at=reconnect, duration_seconds=0),
+        PrintLogEntry(printer_id=1, status="aborted", started_at=base, completed_at=reconnect, duration_seconds=0),
+        # A genuine >24h print — must be retained in full (no cap, no zeroing).
+        PrintLogEntry(
+            printer_id=1,
+            status="completed",
+            started_at=base,
+            completed_at=base + timedelta(hours=30),
+            duration_seconds=30 * 3600,
+        ),
+        # A legacy row that never stored a duration — must still fall back to
+        # its own (short, legitimate) 2h span.
+        PrintLogEntry(
+            printer_id=1,
+            status="completed",
+            started_at=base,
+            completed_at=base + timedelta(hours=2),
+            duration_seconds=None,
+        ),
+    ]
+    for r in rows:
+        db_session.add(r)
+    await db_session.commit()
+
+    resp = await async_client.get("/api/v1/archives/stats")
+    assert resp.status_code == 200, resp.text
+    data = resp.json()
+
+    # 30h (genuine) + 2h (legacy fallback) + 0 + 0 (reconciled) = 32.0h.
+    # Pre-fix the two reconciled rows would have added ~104h from their stamps.
+    assert data["total_print_time_hours"] == 32.0

+ 137 - 0
backend/tests/integration/test_timelapse_scan_session.py

@@ -0,0 +1,137 @@
+"""Regression tests for the #2572 timelapse-scan session-boundary refactor.
+
+``POST /archives/{id}/timelapse/scan`` used to hold its ``Depends(get_db)``
+session open across the FTP directory listing *and* the multi-MB video
+download. It now (1) reads the archive + printer in a short session and
+releases the pooled connection *before* the FTP work, then (2) re-opens a
+fresh short session only to attach the downloaded file.
+
+Two things that refactor could have broken, one test each:
+
+* The matching logic reads ``archive.filename/started_at/completed_at/
+  created_at`` and ``printer.ip_address/...`` AFTER the read session has
+  closed. If any were a lazy-loaded relationship (or an expired column) that
+  would raise ``DetachedInstanceError``. The not-found test drives every
+  match strategy, exercising all of those detached reads.
+
+* The attach write runs in a *fresh* ``async_session()``, which — unlike
+  ``get_db`` — does NOT auto-commit on block exit. If ``attach_timelapse``
+  didn't commit internally the write would be silently dropped. The attach
+  test asserts the row is actually persisted.
+
+FTP is fully mocked, so no printer is contacted.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_scan_timelapse_no_match_reads_detached_archive_scalars(
+    async_client: AsyncClient, archive_factory, printer_factory, db_session
+):
+    """Two non-matching videos → 200 not_found, driving every match strategy.
+
+    Strategies 2-4 read archive.started_at/completed_at/created_at after the
+    read session closed; this fails with DetachedInstanceError if the refactor
+    left one of those as a lazy load.
+    """
+    printer = await printer_factory()
+    archive = await archive_factory(printer.id, filename="test_print.gcode.3mf")
+
+    # Two videos, neither matching by name, no mtime, and the archive has no
+    # started_at — so strategy 1 (name) misses, 2 (start time) and 3 (mtime)
+    # are skipped, and 4 (single-file fallback) is disqualified by len == 2.
+    listing = [
+        {"name": "clip_a.mp4", "path": "/timelapse/clip_a.mp4", "is_directory": False, "size": 10, "mtime": None},
+        {"name": "clip_b.mp4", "path": "/timelapse/clip_b.mp4", "is_directory": False, "size": 20, "mtime": None},
+    ]
+
+    with (
+        patch("backend.app.services.bambu_ftp.list_files_async", AsyncMock(return_value=listing)),
+        patch(
+            "backend.app.services.bambu_ftp.get_ftp_retry_settings",
+            AsyncMock(return_value=(False, 3, 2.0, 30.0)),
+        ),
+        patch(
+            "backend.app.services.bambu_ftp.download_file_bytes_async",
+            AsyncMock(return_value=b"should-not-be-called"),
+        ) as mock_download,
+    ):
+        response = await async_client.post(f"/api/v1/archives/{archive.id}/timelapse/scan")
+
+    assert response.status_code == 200, response.text
+    data = response.json()
+    assert data["status"] == "not_found"
+    assert {f["name"] for f in data["available_files"]} == {"clip_a.mp4", "clip_b.mp4"}
+    # No match → we never download.
+    mock_download.assert_not_called()
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_scan_timelapse_attaches_and_persists_via_fresh_session(
+    async_client: AsyncClient, archive_factory, printer_factory, db_session, tmp_path, monkeypatch
+):
+    """A name-matched video is downloaded and the attach PERSISTS.
+
+    Guards the fresh-session write boundary: attach_timelapse runs in a new
+    async_session that does not auto-commit on exit, so this only passes if
+    the service commits internally.
+    """
+    printer = await printer_factory()
+    archive = await archive_factory(printer.id, filename="test_print.gcode.3mf")
+
+    # attach_timelapse writes into settings.base_dir / archive.file_path's
+    # parent, then stores a base_dir-relative timelapse_path. Point base_dir at
+    # tmp and stage the archive dir so the real write succeeds (mirrors
+    # test_attach_timelapse_safe_path).
+    monkeypatch.setattr(
+        "backend.app.services.archive.settings",
+        MagicMock(base_dir=tmp_path),
+    )
+    archive_dir = tmp_path / "archives" / "test"
+    archive_dir.mkdir(parents=True)
+
+    # base_name = Path("test_print.gcode.3mf").stem = "test_print.gcode", so this
+    # video matches by name (strategy 1). .mp4 → no background conversion task.
+    matched = {
+        "name": "test_print.gcode.mp4",
+        "path": "/timelapse/test_print.gcode.mp4",
+        "is_directory": False,
+        "size": 4096,
+        "mtime": None,
+    }
+    video_bytes = b"fake-timelapse-video-bytes"
+
+    with (
+        patch("backend.app.services.bambu_ftp.list_files_async", AsyncMock(return_value=[matched])),
+        patch(
+            "backend.app.services.bambu_ftp.get_ftp_retry_settings",
+            AsyncMock(return_value=(False, 3, 2.0, 30.0)),
+        ),
+        patch(
+            "backend.app.services.bambu_ftp.download_file_bytes_async",
+            AsyncMock(return_value=video_bytes),
+        ) as mock_download,
+    ):
+        response = await async_client.post(f"/api/v1/archives/{archive.id}/timelapse/scan")
+
+    assert response.status_code == 200, response.text
+    data = response.json()
+    assert data["status"] == "attached"
+    assert data["filename"] == "test_print.gcode.mp4"
+    mock_download.assert_awaited_once()
+
+    # The write happened in the route's fresh session; confirm it was committed
+    # by re-reading the row on the separate test session.
+    await db_session.refresh(archive)
+    assert archive.timelapse_path is not None
+    assert archive.timelapse_path.endswith("test_print.gcode.mp4")
+    # And the bytes actually landed on disk under the staged archive dir.
+    assert (archive_dir / "test_print.gcode.mp4").read_bytes() == video_bytes

+ 3 - 3
backend/tests/integration/test_webhook_start_print.py

@@ -55,8 +55,8 @@ async def printer_with_queue(db_session):
         status="pending",
         manual_start=True,
         timelapse=True,
-        bed_levelling=True,
-        flow_cali=False,
+        bed_levelling="on",
+        flow_cali="off",
         vibration_cali=True,
         layer_inspect=False,
         use_ams=True,
@@ -94,7 +94,7 @@ class TestWebhookStartPrint:
         assert item.manual_start is False, "manual_start must be cleared so scheduler dispatches"
         # Stored options must be untouched so the scheduler picks the user's choice.
         assert item.timelapse is True
-        assert item.bed_levelling is True
+        assert item.bed_levelling == "on"
         assert item.vibration_cali is True
 
     @pytest.mark.asyncio

+ 153 - 0
backend/tests/unit/services/test_backup_path.py

@@ -0,0 +1,153 @@
+"""A backup directory the service cannot write to must say so, and say why (#2544).
+
+The reporting bug this guards against: our own systemd unit ships
+``ProtectSystem=strict``, so a NAS share the operator mounted and can write to
+from their shell is read-only *for the service*. The kernel calls that EROFS,
+the UI showed the raw ``[Errno 30] Read-only file system``, and the reporter
+spent a week checking folder permissions — which were fine, because EROFS is not
+a permission error.
+"""
+
+from __future__ import annotations
+
+import errno
+from pathlib import Path
+
+import pytest
+
+from backend.app.services import backup_path
+from backend.app.services.backup_path import (
+    classify_backup_dir_error,
+    probe_backup_dir,
+    systemd_unit_name,
+)
+
+NAS = Path("/mnt/nasbackup")
+
+
+class TestSystemdUnitName:
+    def test_none_when_not_started_by_systemd(self, monkeypatch):
+        monkeypatch.delenv("INVOCATION_ID", raising=False)
+        assert systemd_unit_name() is None
+
+    @pytest.mark.parametrize(
+        ("cgroup", "expected"),
+        [
+            ("0::/system.slice/bambuddy.service\n", "bambuddy.service"),
+            ("0::/system.slice/system-bambuddy.slice/bambuddy@1.service\n", "bambuddy@1.service"),
+            # No .service in the path (a user scope, say) — still name something usable.
+            ("0::/user.slice/user-1000.slice/session-3.scope\n", "bambuddy.service"),
+        ],
+    )
+    def test_reads_the_unit_name_from_the_cgroup(self, monkeypatch, cgroup, expected):
+        monkeypatch.setenv("INVOCATION_ID", "deadbeef")
+        monkeypatch.setattr(Path, "read_text", lambda _self, *a, **k: cgroup)
+
+        assert systemd_unit_name() == expected
+
+    def test_falls_back_to_bambuddy_when_the_cgroup_is_unreadable(self, monkeypatch):
+        monkeypatch.setenv("INVOCATION_ID", "deadbeef")
+
+        def boom(_path):
+            raise OSError("no /proc here")
+
+        monkeypatch.setattr(Path, "read_text", boom)
+        assert systemd_unit_name() == "bambuddy.service"
+
+
+class TestClassifyReadOnly:
+    def test_erofs_under_systemd_blames_the_sandbox_and_hands_over_the_fix(self, monkeypatch):
+        monkeypatch.setattr(backup_path, "systemd_unit_name", lambda: "bambuddy.service")
+
+        result = classify_backup_dir_error(OSError(errno.EROFS, "Read-only file system"), NAS)
+
+        assert result["writable"] is False
+        assert result["code"] == "sandboxed"
+        assert "ProtectSystem=strict" in result["message"]
+        # The remedy has to be copy-pasteable, with their path already in it.
+        assert "systemctl edit bambuddy.service" in result["remedy"]
+        assert "ReadWritePaths=/mnt/nasbackup" in result["remedy"]
+
+    def test_erofs_outside_systemd_does_not_blame_a_unit_that_does_not_exist(self, monkeypatch):
+        monkeypatch.setattr(backup_path, "systemd_unit_name", lambda: None)
+
+        result = classify_backup_dir_error(OSError(errno.EROFS, "Read-only file system"), NAS)
+
+        assert result["code"] == "read_only"
+        assert result["remedy"] is None
+        assert "systemd" not in result["message"]
+
+    def test_eacces_is_a_permission_problem_not_a_sandbox_one(self, monkeypatch):
+        monkeypatch.setattr(backup_path, "systemd_unit_name", lambda: "bambuddy.service")
+
+        result = classify_backup_dir_error(OSError(errno.EACCES, "Permission denied"), NAS)
+
+        assert result["code"] == "permission_denied"
+        assert result["remedy"] is None
+
+    @pytest.mark.parametrize(
+        ("errno_value", "expected"),
+        [
+            (errno.ENOSPC, "no_space"),
+            (errno.ENOTDIR, "not_a_directory"),
+            (errno.ENOENT, "missing"),
+            (errno.EIO, "error"),
+        ],
+    )
+    def test_other_errnos_keep_their_own_identity(self, errno_value, expected):
+        result = classify_backup_dir_error(OSError(errno_value, "boom"), NAS)
+        assert result["code"] == expected
+        assert result["writable"] is False
+
+
+class TestProbe:
+    def test_a_writable_directory_is_reported_writable_and_left_clean(self, tmp_path, monkeypatch):
+        monkeypatch.setattr(backup_path, "is_running_in_docker", lambda: False)
+        target = tmp_path / "backups"
+
+        result = probe_backup_dir(target)
+
+        assert result["writable"] is True
+        assert result["code"] == "ok"
+        assert result["warning"] is None
+        assert target.is_dir()
+        # The probe file must not survive — it would show up in the backup list.
+        assert list(target.iterdir()) == []
+
+    def test_a_read_only_directory_is_diagnosed_not_just_reported(self, tmp_path, monkeypatch):
+        monkeypatch.setattr(backup_path, "systemd_unit_name", lambda: "bambuddy.service")
+        target = tmp_path / "nasbackup"
+        target.mkdir()
+
+        def refuse(*_args, **_kwargs):
+            raise OSError(errno.EROFS, "Read-only file system")
+
+        monkeypatch.setattr(backup_path.tempfile, "NamedTemporaryFile", refuse)
+
+        result = probe_backup_dir(target)
+
+        assert result["writable"] is False
+        assert result["code"] == "sandboxed"
+        assert str(target) in result["remedy"]
+
+    def test_docker_path_on_the_container_layer_is_writable_but_flagged(self, tmp_path, monkeypatch):
+        """Writable is not the same as persistent: an un-mounted host path inside a
+        container accepts the write and then loses it on the next `up`.
+        """
+        monkeypatch.setattr(backup_path, "is_running_in_docker", lambda: True)
+        monkeypatch.setattr(backup_path, "_is_container_ephemeral", lambda _p: True)
+
+        result = probe_backup_dir(tmp_path / "backups")
+
+        assert result["writable"] is True
+        assert result["warning"] == "container_ephemeral"
+        assert "volumes:" in result["remedy"]
+
+    def test_docker_path_on_a_mounted_volume_is_not_flagged(self, tmp_path, monkeypatch):
+        monkeypatch.setattr(backup_path, "is_running_in_docker", lambda: True)
+        monkeypatch.setattr(backup_path, "_is_container_ephemeral", lambda _p: False)
+
+        result = probe_backup_dir(tmp_path / "backups")
+
+        assert result["writable"] is True
+        assert result["warning"] is None

+ 193 - 0
backend/tests/unit/services/test_bambu_ftp.py

@@ -13,11 +13,14 @@ Tests against a real mock implicit FTPS server, covering:
 - Failure injection scenarios (regressions for 0.1.8 bugs)
 """
 
+import asyncio
+import threading
 import time
 from pathlib import Path
 
 import pytest
 
+from backend.app.services import bambu_ftp
 from backend.app.services.bambu_ftp import (
     BambuFTPClient,
     FileNotOnPrinterError,
@@ -1422,3 +1425,193 @@ class TestThreeMFCache:
         assert archive_file.exists(), "archive 3mf must not be deleted by cache cleanup"
         assert library_file.exists(), "library 3mf must not be deleted by cache cleanup"
         assert not temp_file.exists(), "temp file should still be cleaned up"
+
+
+@pytest.fixture
+def slow_upload_client(monkeypatch):
+    """Replace BambuFTPClient with a fake whose upload streams slowly.
+
+    Mirrors the real client's contract for the bits that matter here: it fires
+    the progress callback once per chunk and treats a callback exception as
+    "stop now" — break out of the send loop, drop the partial file, re-raise.
+    The returned dict lets a test see what the worker thread actually did,
+    which is the whole point: the #2529 ghost transfer was invisible from the
+    event loop's side.
+    """
+    state = {
+        "attempts": 0,
+        "concurrent": 0,
+        "max_concurrent": 0,
+        "completed": False,
+        "cancelled": False,
+        "deleted": [],
+        "chunks": 20,
+        "chunk_delay": 0.05,
+    }
+    lock = threading.Lock()
+
+    class FakeClient:
+        def __init__(self, *args, **kwargs):
+            pass
+
+        def connect(self):
+            return True
+
+        def upload_file(self, local_path, remote_path, progress_callback=None):
+            with lock:
+                state["attempts"] += 1
+                state["concurrent"] += 1
+                state["max_concurrent"] = max(state["max_concurrent"], state["concurrent"])
+            try:
+                total = state["chunks"]
+                for sent in range(1, total + 1):
+                    time.sleep(state["chunk_delay"])
+                    if progress_callback:
+                        try:
+                            progress_callback(sent, total)
+                        except Exception:
+                            state["cancelled"] = True
+                            state["deleted"].append(remote_path)
+                            raise
+                state["completed"] = True
+                return True
+            finally:
+                with lock:
+                    state["concurrent"] -= 1
+
+        def disconnect(self):
+            pass
+
+    monkeypatch.setattr(bambu_ftp, "BambuFTPClient", FakeClient)
+    monkeypatch.setattr(FakeClient, "_mode_cache", {}, raising=False)
+    monkeypatch.setattr(FakeClient, "A1_MODELS", ("A1", "A1 Mini"), raising=False)
+    monkeypatch.setattr(FakeClient, "cache_mode", staticmethod(lambda ip, mode: None), raising=False)
+    return state
+
+
+# ---------------------------------------------------------------------------
+# TestUploadDeadline (#2529)
+# ---------------------------------------------------------------------------
+class TestUploadDeadline:
+    """The upload deadline must be size-aware, and must actually stop the transfer.
+
+    Regression for #2529: a 96 MB 3MF to an A1 over WiFi sustains ~75 KB/s and
+    needs ~20 minutes. The old flat 600 s wall-clock cap declared it dead at
+    ~70 MB, `asyncio.wait_for` cancelled the *future* but not the executor
+    thread — which kept streaming — and `with_ftp_retry` then started a second
+    STOR of the same file onto the same printer. The reporter's video shows two
+    transfers of the same job climbing in parallel (2% and 72%), and the print
+    never landed.
+    """
+
+    def test_deadline_scales_with_file_size(self, tmp_path):
+        """A big file gets proportionally longer, a small one gets the floor."""
+        small = tmp_path / "small.3mf"
+        small.write_bytes(b"x" * 1024)
+        assert bambu_ftp._upload_deadline(small) == bambu_ftp._UPLOAD_MIN_TIMEOUT
+
+        # The reporter's file. At the 25 KB/s floor rate, 96 MB is ~64 minutes —
+        # far above the 600 s that killed it at 72%.
+        big = tmp_path / "big.3mf"
+        big.write_bytes(b"x" * (96 * 1024 * 1024))
+        deadline = bambu_ftp._upload_deadline(big)
+        assert deadline > bambu_ftp._UPLOAD_MIN_TIMEOUT
+        assert deadline == pytest.approx((96 * 1024 * 1024) / bambu_ftp._UPLOAD_FLOOR_BYTES_PER_SEC)
+
+    def test_deadline_falls_back_to_floor_for_unstatable_file(self, tmp_path):
+        assert bambu_ftp._upload_deadline(tmp_path / "nope.3mf") == bambu_ftp._UPLOAD_MIN_TIMEOUT
+
+    @pytest.mark.asyncio
+    async def test_timeout_stops_the_worker_thread(self, tmp_path, monkeypatch, slow_upload_client):
+        """The transfer stops when the deadline expires, instead of streaming on.
+
+        Mutation check: drop the `cancel.set()` in upload_file_async and the
+        worker runs to completion, which is exactly the ghost transfer #2529
+        reported.
+        """
+        state = slow_upload_client
+        local = tmp_path / "slow.3mf"
+        local.write_bytes(b"x" * 4096)
+
+        with pytest.raises(bambu_ftp.UploadCancelled):
+            await upload_file_async("127.0.0.1", "12345678", local, "/cache/slow.3mf", timeout=0.2, printer_model="X1C")
+
+        # The worker noticed the cancel and unwound — it did not run to the end.
+        await asyncio.sleep(0.5)
+        assert state["cancelled"] is True
+        assert state["completed"] is False
+        # And it cleaned the partial file off the printer on its way out.
+        assert state["deleted"] == ["/cache/slow.3mf"]
+
+    @pytest.mark.asyncio
+    async def test_timeout_is_not_retried(self, tmp_path, monkeypatch, slow_upload_client):
+        """with_ftp_retry must not start a second transfer after a deadline expiry.
+
+        This is the bug the reporter filmed: attempt 2 began while attempt 1 was
+        still sending. One attempt, then a hard failure.
+        """
+        state = slow_upload_client
+        local = tmp_path / "slow.3mf"
+        local.write_bytes(b"x" * 4096)
+
+        with pytest.raises(bambu_ftp.UploadCancelled):
+            await with_ftp_retry(
+                upload_file_async,
+                "127.0.0.1",
+                "12345678",
+                local,
+                "/cache/slow.3mf",
+                timeout=0.2,
+                printer_model="X1C",
+                max_retries=3,
+                retry_delay=0,
+            )
+
+        assert state["attempts"] == 1, "a timed-out upload must not be retried"
+
+    @pytest.mark.asyncio
+    async def test_uploads_to_one_printer_are_serialized(self, tmp_path, monkeypatch, slow_upload_client):
+        """Two dispatches to the same printer queue up; they never overlap.
+
+        Concurrent STORs of the same remote path leave a corrupt file on the SD
+        card and make the printer look like it has a flaky network.
+        """
+        state = slow_upload_client
+        state["chunk_delay"] = 0.05
+        local = tmp_path / "slow.3mf"
+        local.write_bytes(b"x" * 4096)
+
+        async def _dispatch(name: str) -> bool:
+            return await upload_file_async(
+                "127.0.0.1", "12345678", local, f"/cache/{name}.3mf", timeout=30.0, printer_model="X1C"
+            )
+
+        results = await asyncio.gather(_dispatch("a"), _dispatch("b"))
+
+        assert results == [True, True]
+        assert state["attempts"] == 2
+        assert state["max_concurrent"] == 1, "two uploads ran against the same printer at once"
+
+    def test_progress_callback_raising_deletes_the_partial_file(self, ftp_client_factory, ftp_root, tmp_path):
+        """The cancel path in the real client removes what it already wrote.
+
+        This is the mechanism the deadline now hangs off, exercised end to end
+        against the mock FTPS server rather than a fake.
+        """
+        client = ftp_client_factory()
+        assert client.connect() is True
+        try:
+            local = tmp_path / "cancelme.3mf"
+            # Two chunks, so the callback fires while there is a partial file.
+            local.write_bytes(b"x" * (BambuFTPClient.CHUNK_SIZE * 2))
+
+            def _stop_after_first_chunk(uploaded: int, total: int) -> None:
+                raise bambu_ftp.UploadCancelled("stop")
+
+            with pytest.raises(bambu_ftp.UploadCancelled):
+                client.upload_file(local, "/cancelme.3mf", _stop_after_first_chunk)
+        finally:
+            client.disconnect()
+
+        time.sleep(_UPLOAD_FLUSH_DELAY)
+        assert not (Path(ftp_root) / "cancelme.3mf").exists(), "partial file left on the printer"

+ 317 - 31
backend/tests/unit/services/test_bambu_mqtt.py

@@ -1196,6 +1196,63 @@ class TestAMSTrayStateClearning:
         assert tray0["tray_color"] == "00FF00FF"
         assert tray0["remain"] == 75
 
+    def _seed_loaded_ht_tray(self, mqtt_client):
+        """Seed an AMS-HT unit (id 128, single tray) whose loaded tray reports
+        state=9 — the real HT resting state, unlike a 4-slot AMS's state=11."""
+        initial = {
+            "ams": [
+                {
+                    "id": 128,
+                    "tray": [
+                        {
+                            "id": 0,
+                            "tray_type": "PA",
+                            "tray_color": "161616FF",
+                            "tray_info_idx": "GFG99",
+                            "tag_uid": "AABBCCDD11223344",
+                            "tray_uuid": "AABBCCDD11223344AABBCCDD11223344",
+                            "remain": 60,
+                            "state": 9,
+                        }
+                    ],
+                }
+            ],
+            "power_on_flag": True,
+        }
+        mqtt_client._handle_ams_data(initial)
+
+    def test_ht_unit_state_9_preserves_tray_data(self, mqtt_client):
+        """#2594: an AMS-HT reports its loaded tray as state=9. A partial
+        {id, state=9} for the HT unit must NOT be read as 'empty' and wipe the
+        present spool — that made the HT-A spool vanish on every power-on."""
+        self._seed_loaded_ht_tray(mqtt_client)
+
+        # Printer sends a partial {id, state} for the HT tray on power-on.
+        update = {
+            "ams": [{"id": 128, "tray": [{"id": 0, "state": 9}]}],
+            "power_on_flag": True,
+        }
+        mqtt_client._handle_ams_data(update)
+
+        tray0 = mqtt_client.state.raw_data["ams"][0]["tray"][0]
+        assert tray0["tray_type"] == "PA", "HT state=9 must NOT clear a present spool (#2594)"
+        assert tray0["tray_uuid"] == "AABBCCDD11223344AABBCCDD11223344", "HT RFID must survive"
+        assert tray0["remain"] == 60
+
+    def test_ht_unit_explicit_empty_still_clears(self, mqtt_client):
+        """A genuine HT spool removal (explicit tray_type='') must still clear —
+        the #2594 fix only skips the state-heuristic, not the explicit path."""
+        self._seed_loaded_ht_tray(mqtt_client)
+
+        update = {
+            "ams": [{"id": 128, "tray": [{"id": 0, "tray_type": ""}]}],
+            "power_on_flag": False,
+        }
+        mqtt_client._handle_ams_data(update)
+
+        tray0 = mqtt_client.state.raw_data["ams"][0]["tray"][0]
+        assert tray0["tray_type"] == "", "explicit tray_type='' must still clear an HT tray"
+
 
 class TestApplyTrayExistBitsHelper:
     """Direct contract pinning for the shared ``apply_tray_exist_bits`` helper.
@@ -1330,6 +1387,40 @@ class TestApplyTrayExistBitsHelper:
         assert cleared == 0
         assert units[0]["tray"][0]["state"] == 9
 
+    def test_annotate_exists_marks_present_and_absent(self):
+        """#2527: annotate_exists writes the tray_exist_bits presence bit onto
+        every slot so a non-RFID spool (present, no tray_type) is distinguishable
+        from a truly-empty slot. 0x5 = slots 0,2 present; slots 1,3 absent."""
+        from backend.app.services.bambu_mqtt import apply_tray_exist_bits
+
+        units = [{"id": 0, "tray": [{"id": i} for i in range(4)]}]
+        apply_tray_exist_bits(units, "5", power_on_flag=True, annotate_exists=True)
+        exists = [t["exists"] for t in units[0]["tray"]]
+        assert exists == [True, False, True, False]
+
+    def test_annotate_exists_present_unknown_slot_not_cleared(self):
+        """A present slot with no tray_type (fresh non-RFID spool) keeps its
+        state and is marked exists=True — the UI then shows "?" not "Empty"."""
+        from backend.app.services.bambu_mqtt import apply_tray_exist_bits
+
+        # 0x1 = slot 0 present. Slot 0 has no tray_type (unidentified spool).
+        units = [{"id": 0, "tray": [{"id": 0, "state": 9}]}]
+        cleared = apply_tray_exist_bits(units, "1", power_on_flag=True, annotate_exists=True)
+        assert cleared == 0
+        assert units[0]["tray"][0]["exists"] is True
+        # Present slot is left untouched (only absent slots get state=9 forced).
+        assert units[0]["tray"][0]["state"] == 9
+
+    def test_annotate_exists_off_by_default_keeps_wire_clean(self):
+        """The VP bridge calls this without annotate_exists, so the slicer-facing
+        tray dict must NOT gain a non-standard `exists` key."""
+        from backend.app.services.bambu_mqtt import apply_tray_exist_bits
+
+        units = [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}, {"id": 1}]}]
+        apply_tray_exist_bits(units, "1", power_on_flag=True)
+        assert "exists" not in units[0]["tray"][0]
+        assert "exists" not in units[0]["tray"][1]
+
 
 class TestNozzleRackData:
     """Tests for nozzle rack data parsing from H2 series device.nozzle.info."""
@@ -3897,12 +3988,18 @@ class TestStartPrintAmsMapping:
         cmd = self._get_published_command(mqtt_client)
         assert cmd["use_ams"] is False
 
-    def test_all_unmapped_sets_use_ams_false(self, mqtt_client):
-        """All unmapped slots on non-H2D printer sets use_ams=False."""
+    def test_all_unresolved_keeps_use_ams_true(self, mqtt_client):
+        """All-unresolved (-1) is NOT external — must keep use_ams=True (#2589).
+
+        A stored [-1] comes from an unresolved mapping (e.g. a frontend
+        status-load race), not an explicit external-spool selection. Treating it
+        as external silently started the print against the empty external feed.
+        Only >=254 may downgrade to use_ams=False.
+        """
         mqtt_client.start_print("test.3mf", ams_mapping=[-1, -1], use_ams=True)
 
         cmd = self._get_published_command(mqtt_client)
-        assert cmd["use_ams"] is False
+        assert cmd["use_ams"] is True
 
     def test_mixed_ams_and_external_keeps_use_ams_true(self, mqtt_client):
         """AMS tray + external spool keeps use_ams=True."""
@@ -3963,15 +4060,18 @@ class TestStartPrintAmsMapping:
         mqtt_client.start_print(
             "test.3mf",
             timelapse=True,
-            bed_levelling=False,
-            flow_cali=True,
+            bed_levelling="off",
+            flow_cali="on",
             vibration_cali=False,
             layer_inspect=True,
         )
 
         cmd = self._get_published_command(mqtt_client)
         assert cmd["timelapse"] is True
+        # bed_leveling stays a bool (true only for "on"); the tri-state rides on
+        # the auto_bed_leveling int.
         assert cmd["bed_leveling"] is False
+        assert cmd["auto_bed_leveling"] == 0
         assert cmd["flow_cali"] is True
         assert cmd["vibration_cali"] is False
         assert cmd["layer_inspect"] is True
@@ -3981,16 +4081,13 @@ class TestStartPrintAmsMapping:
     def test_p2s_uses_boolean_format(self, mqtt_client):
         """P2S sends calibration fields as JSON booleans (single-nozzle, like X1C/A1/P1)."""
         mqtt_client.model = "P2S"
-        mqtt_client.start_print("test.3mf", timelapse=True, flow_cali=False)
+        mqtt_client.start_print("test.3mf", timelapse=True, flow_cali="off")
 
         cmd = self._get_published_command(mqtt_client)
         assert cmd["timelapse"] is True
         assert cmd["flow_cali"] is False
-        # flow_cali off → extrude_cali_flag=0 (firmware actually skips the
-        # pre-print calibration stage). #1721 test on H2D 01.x showed `2`
-        # didn't suppress stage 8 ("Calibrating dynamic flow") despite the
-        # earlier "skip and reuse stored PA" reading; `0` does — verified
-        # live against the stg queue.
+        # flow_cali "off" → extrude_cali_flag=0 (firmware skips the pre-print
+        # calibration stage entirely). "auto" would send 2 instead.
         assert cmd["extrude_cali_flag"] == 0
 
     def test_h2s_single_external_spool_uses_main_id(self, mqtt_client):
@@ -4035,8 +4132,8 @@ class TestStartPrintAmsMapping:
         mqtt_client.start_print(
             "test.3mf",
             timelapse=True,
-            bed_levelling=False,
-            flow_cali=True,
+            bed_levelling="off",
+            flow_cali="on",
             vibration_cali=False,
             layer_inspect=True,
         )
@@ -4051,13 +4148,48 @@ class TestStartPrintAmsMapping:
         # flow-dynamics calibration instead of reusing the stored PA value.
         assert cmd["extrude_cali_flag"] == 1
 
-    def test_nozzle_offset_cali_default_is_skip(self, mqtt_client):
-        """Default `nozzle_offset_cali=False` → wire value `0` (skip).
+    def test_bed_leveling_auto_sends_int_two(self, mqtt_client):
+        """`bed_levelling="auto"` → bool false + auto_bed_leveling=2.
+
+        Matches BambuStudio's ops_auto wire shape: the bool is true only for the
+        explicit "on" state; "auto" carries its intent in the int (2 = run only
+        if the bed wasn't levelled recently).
+        """
+        mqtt_client.model = "X1C"
+        mqtt_client.start_print("test.3mf", bed_levelling="auto")
+
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["bed_leveling"] is False
+        assert cmd["auto_bed_leveling"] == 2
+
+    def test_bed_leveling_on_sends_int_one(self, mqtt_client):
+        """`bed_levelling="on"` → bool true + auto_bed_leveling=1 (force)."""
+        mqtt_client.model = "X1C"
+        mqtt_client.start_print("test.3mf", bed_levelling="on")
+
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["bed_leveling"] is True
+        assert cmd["auto_bed_leveling"] == 1
+
+    def test_flow_cali_auto_sends_int_two(self, mqtt_client):
+        """`flow_cali="auto"` → bool false + extrude_cali_flag=2.
+
+        #1721 saw stage 8 stay queued on 2 — that is the auto contract (queued,
+        skipped at runtime if the filament was calibrated recently), which is
+        exactly what "auto" should do.
+        """
+        mqtt_client.model = "X1C"
+        mqtt_client.start_print("test.3mf", flow_cali="auto")
+
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["flow_cali"] is False
+        assert cmd["extrude_cali_flag"] == 2
+
+    def test_nozzle_offset_cali_default_auto_gated_on_single_nozzle(self, mqtt_client):
+        """Default (auto) on a single-nozzle printer → wire value `0`.
 
-        #1721 H2D 01.x test: `2` ("skip") didn't actually suppress stage 39
-        ("Nozzle offset calibration") — the stage stayed in the `stg` queue
-        and ran at print start. `0` does suppress it (verified live). Matches
-        what a BambuStudio Send-dialog echo on the same firmware shows.
+        The default is now "auto", but single-nozzle machines have no second
+        head to calibrate, so the MQTT layer gates any state to `0` there.
         """
         mqtt_client.model = "P1S"
         mqtt_client.start_print("test.3mf")
@@ -4066,44 +4198,50 @@ class TestStartPrintAmsMapping:
         assert cmd["nozzle_offset_cali"] == 0
 
     def test_nozzle_offset_cali_ignored_on_single_nozzle(self, mqtt_client):
-        """Single-nozzle printer: `nozzle_offset_cali=True` is silently dropped.
+        """Single-nozzle printer: `nozzle_offset_cali="on"` is silently dropped.
 
         H2S is in the H2 firmware family but single-nozzle. The toggle has
         no physical meaning on single-nozzle machines and the UI gates it
         behind `nozzle_count==2`. Even if a stale queue item from when the
         printer was misidentified as dual carries the flag, the MQTT layer
         must downgrade it so firmware never tries to calibrate a head it
-        doesn't have (#1682). `0` is the actually-honoured skip value
-        post-#1721; old `2` left the stage in the queue.
+        doesn't have (#1682).
         """
         mqtt_client.model = "P1S"
-        mqtt_client.start_print("test.3mf", nozzle_offset_cali=True)
+        mqtt_client.start_print("test.3mf", nozzle_offset_cali="on")
 
         cmd = self._get_published_command(mqtt_client)
         assert cmd["nozzle_offset_cali"] == 0
 
     def test_nozzle_offset_cali_honored_on_dual_nozzle(self, mqtt_client):
-        """Dual-nozzle printer (H2D): `nozzle_offset_cali=True` → wire value `1`.
+        """Dual-nozzle printer (H2D): `nozzle_offset_cali="on"` → wire value `1`.
 
         H2D is in `DUAL_NOZZLE_MODELS`. The toggle controls whether the
         printer runs the nozzle-offset calibration pass before the print
-        starts. `1`=run (#1682).
+        starts. "on"=1 (force), "auto"=2, "off"=0 (#1682).
         """
         mqtt_client.model = "H2D"
-        mqtt_client.start_print("test.3mf", nozzle_offset_cali=True)
+        mqtt_client.start_print("test.3mf", nozzle_offset_cali="on")
 
         cmd = self._get_published_command(mqtt_client)
         assert cmd["nozzle_offset_cali"] == 1
 
-    def test_nozzle_offset_cali_false_on_dual_nozzle(self, mqtt_client):
-        """Dual-nozzle printer (H2D Pro): `nozzle_offset_cali=False` → `0` (skip).
+    def test_nozzle_offset_cali_auto_on_dual_nozzle(self, mqtt_client):
+        """Dual-nozzle printer (H2D): `nozzle_offset_cali="auto"` → wire value `2`."""
+        mqtt_client.model = "H2D"
+        mqtt_client.start_print("test.3mf", nozzle_offset_cali="auto")
+
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["nozzle_offset_cali"] == 2
+
+    def test_nozzle_offset_cali_off_on_dual_nozzle(self, mqtt_client):
+        """Dual-nozzle printer (H2D Pro): `nozzle_offset_cali="off"` → `0` (skip).
 
         Critical for users like #1682 who run diamond nozzles and need to
-        keep the calibration off. The wire value flipped from `2` to `0` in
-        #1721 after the H2D test showed `2` didn't actually suppress.
+        keep the calibration off.
         """
         mqtt_client.model = "H2D Pro"
-        mqtt_client.start_print("test.3mf", nozzle_offset_cali=False)
+        mqtt_client.start_print("test.3mf", nozzle_offset_cali="off")
 
         cmd = self._get_published_command(mqtt_client)
         assert cmd["nozzle_offset_cali"] == 0
@@ -6182,3 +6320,151 @@ class TestLastLayerFinishPhotoTrigger:
 
         assert len(events) == 1
         assert len(completion_events) == 1
+
+
+class TestPresumedPowerOffRecovery:
+    """#2629: a smart-plug turn-off marks the printer offline optimistically.
+
+    When the plug does not actually feed the printer, the printer keeps
+    publishing — and the forced 'unknown' state must be undone, or it sticks
+    until the next full pushall and the queue scheduler stalls forever.
+    """
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST123",
+            access_code="12345678",
+        )
+        client.state.connected = True
+        client.state.state = "FINISH"
+        return client
+
+    @staticmethod
+    def _report(client, payload):
+        """Feed a report-topic message through the real _on_message path."""
+
+        class _Msg:
+            def __init__(self, topic, data):
+                self.topic = topic
+                self.payload = json.dumps(data).encode()
+
+        client._on_message(None, None, _Msg(client.topic_subscribe, payload))
+
+    def test_mark_power_off_blanks_state_and_remembers_it(self, mqtt_client):
+        assert mqtt_client.mark_power_off() is True
+
+        assert mqtt_client.state.connected is False
+        assert mqtt_client.state.state == "unknown"
+        assert mqtt_client._state_before_power_off == "FINISH"
+
+    def test_mark_power_off_noop_when_already_disconnected(self, mqtt_client):
+        mqtt_client.state.connected = False
+
+        assert mqtt_client.mark_power_off() is False
+        assert mqtt_client._state_before_power_off is None
+
+    def test_second_mark_does_not_overwrite_saved_state(self, mqtt_client):
+        mqtt_client.mark_power_off()
+        # Something flips connected back (a partial message) before the second mark
+        mqtt_client.state.connected = True
+        mqtt_client.mark_power_off()
+
+        assert mqtt_client._state_before_power_off == "FINISH"
+
+    def test_partial_report_restores_state(self, mqtt_client):
+        """The steady-state push_status carries no gcode_state — the pre-off
+        state must come back anyway, otherwise 'unknown' is permanent."""
+        mqtt_client.mark_power_off()
+
+        self._report(mqtt_client, {"print": {"wifi_signal": "-30dBm"}})
+
+        assert mqtt_client.state.connected is True
+        assert mqtt_client.state.state == "FINISH"
+        assert mqtt_client._state_before_power_off is None
+
+    def test_restore_broadcasts_state_change(self, mqtt_client):
+        broadcasts = []
+        mqtt_client.on_state_change = lambda state: broadcasts.append(state.state)
+        mqtt_client.mark_power_off()
+
+        self._report(mqtt_client, {"print": {"wifi_signal": "-30dBm"}})
+
+        assert "FINISH" in broadcasts
+
+    def test_fresh_gcode_state_wins_over_restored_state(self, mqtt_client):
+        """A report that does carry gcode_state is authoritative."""
+        mqtt_client.mark_power_off()
+
+        self._report(mqtt_client, {"print": {"gcode_state": "IDLE"}})
+
+        assert mqtt_client.state.state == "IDLE"
+
+    def test_restore_happens_only_once(self, mqtt_client):
+        """After recovery a later genuine blank must not be undone by a stale
+        saved state."""
+        mqtt_client.mark_power_off()
+        self._report(mqtt_client, {"print": {"wifi_signal": "-30dBm"}})
+
+        # Printer really loses power now: state blanked, nothing to restore from
+        mqtt_client.state.state = "unknown"
+        assert mqtt_client._restore_state_after_false_power_off() is False
+        assert mqtt_client.state.state == "unknown"
+
+    def test_request_topic_traffic_does_not_restore(self, mqtt_client):
+        """Only the printer's own report topic proves it is alive; the request
+        topic also carries slicer/Bambuddy commands."""
+        mqtt_client.mark_power_off()
+
+        class _Msg:
+            topic = mqtt_client.topic_publish
+            payload = json.dumps({"print": {"command": "project_file"}}).encode()
+
+        mqtt_client._on_message(None, None, _Msg())
+
+        assert mqtt_client.state.state == "unknown"
+        assert mqtt_client._state_before_power_off == "FINISH"
+
+    def test_reconnect_discards_saved_state(self, mqtt_client):
+        """A real power cut drops the MQTT session; on reconnect the saved state
+        is stale and must not be broadcast ahead of the printer's first report."""
+        from unittest.mock import MagicMock
+
+        mqtt_client.mark_power_off()
+
+        paho = MagicMock()
+        paho.subscribe.return_value = (0, 1)  # (MQTT_ERR_SUCCESS, mid)
+        mqtt_client._on_connect(paho, None, {}, 0)
+
+        assert mqtt_client._state_before_power_off is None
+
+        self._report(mqtt_client, {"print": {"wifi_signal": "-30dBm"}})
+
+        assert mqtt_client.state.state == "unknown"
+
+    def test_already_unknown_state_is_not_saved(self, mqtt_client):
+        """A printer that never reported has nothing to restore — saving
+        'unknown' would make the recovery broadcast a no-op state change."""
+        mqtt_client.state.state = "unknown"
+
+        assert mqtt_client.mark_power_off() is True
+        assert mqtt_client._state_before_power_off is None
+
+    def test_message_interleaved_with_mark_does_not_strand_unknown(self, mqtt_client):
+        """mark_power_off runs on the event loop, _on_message on the paho
+        thread. A message landing mid-mark must not consume the saved state and
+        leave the printer stuck on 'unknown' — the next message must recover."""
+        # Simulate the worst interleaving: a report is processed after the state
+        # was blanked but before the previous state was recorded.
+        mqtt_client.state.connected = False
+        mqtt_client.state.state = "unknown"
+        self._report(mqtt_client, {"print": {"wifi_signal": "-30dBm"}})
+        # ...now the rest of the mark completes.
+        mqtt_client._state_before_power_off = "FINISH"
+
+        self._report(mqtt_client, {"print": {"wifi_signal": "-30dBm"}})
+
+        assert mqtt_client.state.state == "FINISH"

+ 245 - 0
backend/tests/unit/services/test_camera_fanout.py

@@ -111,6 +111,50 @@ async def test_multiple_subscribers_share_single_upstream():
     await bc.force_shutdown()
 
 
+# ---------------------------------------------------------------------------
+# Late subscribers are primed with the last frame (#2521)
+# ---------------------------------------------------------------------------
+
+
+async def test_late_subscriber_primed_with_last_frame():
+    """A viewer that joins after the stream is running must receive the most
+    recent frame immediately, not wait for the next upstream frame. On slow
+    chamber-image cams that wait looked like a permanent black screen (#2521).
+    """
+
+    async def factory(disconnect: asyncio.Event) -> AsyncGenerator[bytes, None]:
+        yield b"first"
+        await disconnect.wait()  # then hold the stream open, no further frames
+
+    bc = MjpegBroadcaster("p1", factory)
+    q1 = await bc.subscribe()
+    # First subscriber consumes the frame; this also guarantees the pump has
+    # recorded it as the last chunk.
+    assert await asyncio.wait_for(q1.get(), timeout=1.0) == b"first"
+
+    # Late joiner is handed that frame at once, even though no new frame is coming.
+    q2 = await bc.subscribe()
+    assert await asyncio.wait_for(q2.get(), timeout=0.2) == b"first"
+
+    await bc.force_shutdown()
+
+
+async def test_first_subscriber_not_primed():
+    """The very first subscriber has no prior frame to be primed with — its
+    queue starts empty and it triggers the upstream connect.
+    """
+
+    async def factory(disconnect: asyncio.Event) -> AsyncGenerator[bytes, None]:
+        await disconnect.wait()  # never produces a frame
+        yield b"never"  # pragma: no cover
+
+    bc = MjpegBroadcaster("p1", factory)
+    q1 = await bc.subscribe()
+    await asyncio.sleep(0)  # let the pump start
+    assert q1.empty()
+    await bc.force_shutdown()
+
+
 # ---------------------------------------------------------------------------
 # Slow subscriber should not block fast subscribers
 # ---------------------------------------------------------------------------
@@ -339,3 +383,204 @@ async def test_force_shutdown_then_subscribe_via_registry_works():
     chunk = await asyncio.wait_for(queue.get(), timeout=1.0)
     assert chunk == b"hello"
     await shutdown_broadcaster("p1")
+
+
+# ---------------------------------------------------------------------------
+# Teardown barrier: replacement waits for the prior upstream socket to close
+# ---------------------------------------------------------------------------
+
+
+async def test_wait_until_torn_down_completes_after_force_shutdown():
+    bc = MjpegBroadcaster("p1", _make_factory([b"x"] * 1000, delay=0.05))
+    await bc.subscribe()
+    await bc.force_shutdown()
+    # Fully torn down → the barrier returns promptly.
+    await asyncio.wait_for(bc.wait_until_torn_down(), timeout=1.0)
+
+
+async def test_successor_pump_waits_for_predecessor_socket_close():
+    """A replacement broadcaster's pump must not dial the printer until the
+    displaced (stopped) one's socket has finished closing — otherwise a
+    single-connection printer briefly sees two sockets and strands frames on
+    the orphaned one (#2521). Guarding at the pump (not at get_or_create) keeps
+    it correct even when concurrent viewers race to replace the same corpse.
+    Drive the mid-teardown state directly so the test is deterministic.
+    """
+    factory = _make_factory([b"x"] * 1000, delay=0.02)
+    bc1 = MjpegBroadcaster("p1", factory)
+    # Register it and simulate "grace fired: stopped, but socket not yet closed".
+    camera_fanout._broadcasters["p1"] = bc1
+    bc1._stopped = True  # noqa: SLF001 — white-box: mid-teardown snapshot
+    assert not bc1._teardown_complete.is_set()  # noqa: SLF001
+
+    # get_or_create returns immediately with the successor chained to bc1.
+    bc2 = await get_or_create_broadcaster("p1", factory)
+    assert bc2 is not bc1
+    # Subscribing starts bc2's pump, but it must block on bc1's teardown before
+    # producing any frame.
+    queue = await bc2.subscribe()
+    await asyncio.sleep(0.03)
+    assert queue.empty(), "successor produced a frame before the prior upstream closed"
+
+    # Predecessor teardown completes → bc2's pump dials and frames flow.
+    bc1._teardown_complete.set()  # noqa: SLF001
+    assert await asyncio.wait_for(queue.get(), timeout=1.0) == b"x"
+    await shutdown_broadcaster("p1")
+
+
+async def test_successor_pump_times_out_if_predecessor_wedges(monkeypatch):
+    """If a displaced broadcaster's teardown never completes, the successor's
+    pump must dial anyway (bounded wait) rather than never producing a frame.
+    """
+    monkeypatch.setattr(camera_fanout, "_TEARDOWN_WAIT_SECONDS", 0.05)
+    factory = _make_factory([b"x"] * 1000, delay=0.02)
+    bc1 = MjpegBroadcaster("p1", factory)
+    camera_fanout._broadcasters["p1"] = bc1
+    bc1._stopped = True  # noqa: SLF001 — wedged mid-teardown, event never set
+    # teardown_complete intentionally never set.
+
+    bc2 = await get_or_create_broadcaster("p1", factory)
+    assert bc2 is not bc1
+    queue = await bc2.subscribe()
+    # After the bounded wait elapses the pump dials and delivers a frame.
+    assert await asyncio.wait_for(queue.get(), timeout=1.0) == b"x"
+    await shutdown_broadcaster("p1")
+
+
+# ---------------------------------------------------------------------------
+# The printer only has ONE camera socket (#2521)
+# ---------------------------------------------------------------------------
+
+
+def _socket_counting_factory(state: dict, *, close_delay: float = 0.05):
+    """Upstream factory that models a real TCP socket to the printer.
+
+    Records the peak number of simultaneously-open sockets. A chamber-image cam
+    (P1/A1, port 6000) accepts exactly one connection: when a second overlaps,
+    the printer keeps feeding the first and the newcomer never sees a frame —
+    until the printer's TCP keepalive reaps the orphan, ~20 minutes later.
+    """
+
+    async def factory(disconnect: asyncio.Event) -> AsyncGenerator[bytes, None]:
+        await asyncio.sleep(0.01)  # dial + TLS handshake
+        state["open"] += 1
+        state["peak"] = max(state["peak"], state["open"])
+        try:
+            while not disconnect.is_set():
+                await asyncio.sleep(0.01)
+                yield b"frame"
+        finally:
+            await asyncio.sleep(close_delay)  # TCP close is not instantaneous
+            state["open"] -= 1
+
+    return factory
+
+
+async def test_stop_then_restream_never_opens_two_sockets():
+    """A page reload fires POST /camera/stop and GET /camera/stream at the same
+    time. ``shutdown_broadcaster`` used to *pop* the broadcaster out of the
+    registry and only then await its teardown, so a stream request landing in
+    that window found an empty slot, minted a broadcaster with no predecessor,
+    and dialled the printer while the old socket was still closing (#2521).
+    """
+    state = {"open": 0, "peak": 0}
+    factory = _socket_counting_factory(state)
+
+    bc1 = await get_or_create_broadcaster("p1", factory)
+    queue = await bc1.subscribe()
+    assert await asyncio.wait_for(queue.get(), timeout=1.0) == b"frame"
+    await bc1.unsubscribe(queue)
+
+    async def viewer_unmount_stop():
+        await shutdown_broadcaster("p1")
+
+    async def reloaded_page_streams():
+        await asyncio.sleep(0.005)  # lands a hair after the stop
+        bc = await get_or_create_broadcaster("p1", factory)
+        q = await bc.subscribe()
+        return await asyncio.wait_for(q.get(), timeout=2.0)
+
+    _stop_result, frame = await asyncio.gather(viewer_unmount_stop(), reloaded_page_streams())
+
+    assert frame == b"frame", "the reloaded page's viewer never received a frame"
+    assert state["peak"] == 1, (
+        f"opened {state['peak']} concurrent sockets to a printer that allows one — "
+        "the new stream dialled before the old socket closed"
+    )
+    await shutdown_broadcaster("p1")
+
+
+async def test_shutdown_broadcaster_leaves_a_chainable_predecessor():
+    """The stopped broadcaster must stay findable in the registry: that is what
+    lets the next viewer's pump chain behind its socket close."""
+    state = {"open": 0, "peak": 0}
+    factory = _socket_counting_factory(state)
+
+    bc1 = await get_or_create_broadcaster("p1", factory)
+    await bc1.subscribe()
+    await shutdown_broadcaster("p1")
+
+    assert camera_fanout._broadcasters.get("p1") is bc1, (  # noqa: SLF001
+        "the stopped broadcaster was removed from the registry — a successor "
+        "created now would have predecessor=None and dial immediately"
+    )
+    bc2 = await get_or_create_broadcaster("p1", factory)
+    assert bc2._predecessor is bc1  # noqa: SLF001 — white-box: the chain is the fix
+    await shutdown_broadcaster("p1")
+
+
+async def test_shutdown_broadcaster_is_idempotent():
+    """/camera/stop can fire twice (unmount + beforeunload). The second call
+    must report nothing was running rather than tearing down a live successor."""
+    factory = _make_factory([b"x"] * 1000, delay=0.02)
+    bc = await get_or_create_broadcaster("p1", factory)
+    await bc.subscribe()
+
+    assert await shutdown_broadcaster("p1") is True
+    assert await shutdown_broadcaster("p1") is False
+    assert await shutdown_broadcaster("never-existed") is False
+
+
+async def test_stopped_broadcaster_reports_no_subscribers():
+    """/camera/stop's reference-count guard must not see the corpse's leftovers."""
+    from backend.app.services.camera_fanout import get_subscriber_count
+
+    factory = _make_factory([b"x"] * 1000, delay=0.02)
+    bc = await get_or_create_broadcaster("p1", factory)
+    await bc.subscribe()
+    assert get_subscriber_count("p1") == 1
+
+    await shutdown_broadcaster("p1")
+    assert get_subscriber_count("p1") == 0, "a stopped broadcaster still reported subscribers"
+
+
+async def test_subscriber_with_no_frames_detaches_promptly():
+    """A viewer that goes away while the stream is black must stop being counted.
+
+    The disconnect check only ran after a chunk was yielded, or on a 30 s idle
+    timeout — so a client that left during a black stream stayed *counted* as a
+    subscriber for up to half a minute. /camera/stop trusts that count to decide
+    whether to tear the upstream down, so a phantom subscriber could make it
+    skip teardown entirely (#2521).
+    """
+
+    async def silent_factory(disconnect: asyncio.Event) -> AsyncGenerator[bytes, None]:
+        await disconnect.wait()  # connected, but the printer sends nothing
+        return
+        yield  # pragma: no cover — makes this an async generator
+
+    bc = MjpegBroadcaster("p1", silent_factory)
+    queue = await bc.subscribe()
+    assert bc.subscriber_count == 1
+
+    async def is_disconnected() -> bool:
+        return True  # the browser aborted the request
+
+    async def drain():
+        async for _chunk in iter_subscriber(bc, queue, is_disconnected=is_disconnected):
+            pass
+
+    # Must notice well inside the old 30 s idle timeout.
+    await asyncio.wait_for(drain(), timeout=3.0)
+    assert bc.subscriber_count == 0
+    await bc.force_shutdown()

+ 92 - 0
backend/tests/unit/services/test_external_spool_change.py

@@ -0,0 +1,92 @@
+"""External-spool (vt_tray) change detection (#2575).
+
+The AMS change-hash in ``_handle_ams_data`` is built only from AMS units, so a
+filament swap on the external spool alone (e.g. generic TPU -> generic ABS on
+the printer) used to never re-trigger ``on_ams_change``. That left a stale
+inventory assignment on the ``ams_id=255`` slot: Bambuddy kept showing the old
+filament after the physical type had changed.
+
+These tests drive full MQTT messages through ``_process_message`` and assert the
+callback fires exactly when the external spool's *identity* changes — and not on
+every push (e.g. a steadily-dropping ``remain`` percentage during a print).
+"""
+
+import pytest
+
+from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+
+def _ext_spool_msg(tray_type: str, remain: int = 100, color: str = "000000FF"):
+    """A realistic print message carrying only external-spool (vt_tray) data."""
+    return {
+        "print": {
+            "vt_tray": {
+                "id": "254",
+                "tray_type": tray_type,
+                "tray_color": color,
+                "tray_info_idx": "",
+                "tag_uid": "0000000000000000",
+                "tray_uuid": "00000000000000000000000000000000",
+                "remain": remain,
+            }
+        }
+    }
+
+
+class TestExternalSpoolChangeDetection:
+    @pytest.fixture
+    def mqtt_client(self):
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST123",
+            access_code="12345678",
+        )
+
+    def test_type_swap_fires_callback(self, mqtt_client):
+        """Swapping the external filament type re-triggers the sync callback."""
+        calls: list = []
+        mqtt_client.on_ams_change = lambda ams_data: calls.append(ams_data)
+
+        # First observation of the external spool (TPU) — fires once.
+        mqtt_client._process_message(_ext_spool_msg("TPU"))
+        assert len(calls) == 1
+
+        # Physical filament changed to ABS — must fire again so the stale
+        # ams_id=255 assignment gets reconciled.
+        mqtt_client._process_message(_ext_spool_msg("ABS"))
+        assert len(calls) == 2
+
+        # The callback receives the merged AMS list (never None).
+        assert all(isinstance(c, list) for c in calls)
+
+    def test_identical_push_does_not_refire(self, mqtt_client):
+        """Repeated identical vt_tray pushes fire the callback only once."""
+        calls: list = []
+        mqtt_client.on_ams_change = lambda ams_data: calls.append(ams_data)
+
+        mqtt_client._process_message(_ext_spool_msg("ABS"))
+        mqtt_client._process_message(_ext_spool_msg("ABS"))
+        mqtt_client._process_message(_ext_spool_msg("ABS"))
+        assert len(calls) == 1
+
+    def test_remain_only_change_does_not_refire(self, mqtt_client):
+        """A dropping fill percentage must not spam the reconciliation callback."""
+        calls: list = []
+        mqtt_client.on_ams_change = lambda ams_data: calls.append(ams_data)
+
+        mqtt_client._process_message(_ext_spool_msg("PLA", remain=100))
+        assert len(calls) == 1
+        # remain drops during a print — identity unchanged, no refire.
+        mqtt_client._process_message(_ext_spool_msg("PLA", remain=87))
+        mqtt_client._process_message(_ext_spool_msg("PLA", remain=42))
+        assert len(calls) == 1
+
+    def test_reset_to_empty_fires_callback(self, mqtt_client):
+        """Resetting the external spool (empty tray_type) is an identity change."""
+        calls: list = []
+        mqtt_client.on_ams_change = lambda ams_data: calls.append(ams_data)
+
+        mqtt_client._process_message(_ext_spool_msg("TPU"))
+        assert len(calls) == 1
+        mqtt_client._process_message(_ext_spool_msg(""))  # reset / unloaded
+        assert len(calls) == 2

Некоторые файлы не были показаны из-за большого количества измененных файлов