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

Merge pull request #2478 from maziggy/0.2.4.9

**Bambuddy 0.2.4.9**

**⚠ Upgrade Notes — Read Before Updating**

0.2.4.9 is a fix-and-polish release on the same 0.2.4 code base — no schema breaks beyond auto-migrated column additions (dialect-branched for SQLite and Postgres), no Docker entrypoint changes. The in-app Apply Update button in Settings → System → Updates works for Docker and for any native install already on 0.2.4.x.

**Four behaviour-change callouts to know about before you upgrade:**

- Plate-clear confirmation now correctly defaults to OFF (#1865, reported by @PurseChicken). On installs that never explicitly saved the "Require plate-clear confirmation" setting, the scheduler was still enforcing the plate-clear gate even though the toggle and the printer card showed it disabled — so a queued print could sit "Waiting" forever on a FINISH-state printer with no UI control to release it. The scheduler now reads the setting with the same default the schema and UI use, so a finished printer dispatches the next queued job automatically. If you were relying on that gate to hold the queue until you physically clear the bed, explicitly turn on Require plate-clear confirmation after upgrading — the "Mark plate as cleared" button and plate badge reappear on the card when it's on.
- File Manager "All Files" now scopes to your own uploads. The sidebar's All Files entry now shows the files you uploaded; a new "External" entry holds the combined linked-folder view that used to be mixed in. If you were used to seeing linked-folder content under All Files, it's now one entry down under External — no data moved, just the sidebar grouping.
- Slicer sidecar now ships as pre-built images (#1657). The slicer-API sidecar is now published on GHCR and Docker Hub, so it runs on QNAP / Synology / Container Station out of the box without a local build. If you run the sidecar, pull the published image instead of building; the wiki sidecar instructions are updated.
- Light theme contrast overhaul (#1909, reported by @AntonPalmqvist). If you run the light theme, a large number of previously washed-out status banners, badges, and form fields are now readable. Dark theme is pixel-identical.

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

docker-compose.yml doesn't need refreshing for 0.2.4.9.

Native install — recommended path

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

Snapshots the database first and rolls back on failure.

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-0.2.4.9-windows-x64-setup.exe from this release page (or the unversioned bambuddy-windows-x64-setup.exe alias for an always-latest link). This release fixes a fresh-install failure where the service reported "running" but nothing listened on port 8000 (#2474) — a missing C++ runtime DLL that greenlet needs is now bundled. Existing Windows installs upgrade in place via the in-app "Install Update" flow.

---
**Highlights**

0.2.4.9 is a stabilisation release — the headline is install robustness on Windows and macOS, a light-theme contrast pass, and a scheduler fix that could silently freeze queues.

The plate-clear scheduler fix (#1865, reported by @PurseChicken) is the one to know about: on installs that never explicitly saved the setting, a FINISH-state printer would never dispatch the next queued job even though the "Require plate-clear confirmation" toggle showed as off — see Upgrade Notes.

Install paths got hardened on both new platforms: the Windows .exe no longer starts a dead service on a clean Win10 box (#2474, @fangme), and the macOS native install is now fully rootless (no more Homebrew/venv permission failures) with a fixed Virtual Printer bind-interface list.

Smaller-but-useful: native CSV import / export on the inventory page (#1576, PR by @samedyuksel), custom-subnet printer scan for printers behind a different L3 segment (#1564, @MartinNYHC), thermal-printer-friendly spool-label QR codes with a black-and-white monochrome option (#1870, @fireboyff / @Geoff-S), and a finish photo that no longer catches the bed already dropping away (#1397).

---
**New Features**

- Inventory page now supports native CSV import / export (#1576, PR #1659 by @samedyuksel).
- Spool labels: scannable QR on 203 dpi thermal printers + monochrome mode for black-and-white printers (#1870, reported by @fireboyff, monochrome requested by @Geoff-S).
- Add Printer: scan a custom subnet for printers behind a router on a different L3 segment (#1564, reported by @MartinNYHC).
- Orca Cloud profile sync — end-to-end integration with the slicer + SpoolBuddy surfaces.
- Finish photo no longer shows the bed already dropped (#1397, reported by @rtadams89, @Jeff-GebhartCA, @MA2ZAK).
- Connection diagnostic now verifies the printer is actually publishing on its report topic (#1622).
- Virtual Printer MQTT bridge now surfaces why a net.info[].ip rewrite didn't arm (#1429, defensive diagnostics).

---
**Changes**

- Slicer sidecar now ships as pre-built images on GHCR + Docker Hub — install works on QNAP / Synology / Container Station without a local build (#1657). See Upgrade Notes.
- File Manager sidebar: "All Files" now scopes to your own uploaded files; a new "External" entry holds the combined linked-folder view. See Upgrade Notes.
- Empty AMS units no longer trigger hourly humidity / temperature notifications (#1619).
- AMS drying now enabled for H2C starting at firmware 01.02.00.00.
- Virtual Printer access code is now auto-derived from the target printer in non-proxy modes.
- Inventory: /reset-usage renamed to /reset-consumed-counter; the UI label is now "Reset counter".
- docker-compose.yml: added a bridge-mode warning about the 1001-port FTP passive range + docker-proxy RAM footprint (#1646).

---
**Fixed**

Print queue + scheduler

- Queued prints never dispatched to FINISH-state printers when "Require plate-clear confirmation" was disabled (#1865, reported by @PurseChicken). See Upgrade Notes.

Connection / camera

- External camera "connection lost" when the snapshot URL serves a non-JPEG image — snapshots are now transcoded to JPEG (#1902).
- Virtual Printer "bind interface" dropdown was empty on macOS — interface enumeration now routes non-Linux platforms through psutil.

Install / backup

- Windows installer: fresh install failed to start ("connection refused", nothing on port 8000) — the C++ runtime DLL greenlet needs is now bundled (#2474, reported by @fangme).
- Native install script failed on macOS with Homebrew/venv permission errors — the macOS path is now fully rootless.
- PostgreSQL restore from a SQLite backup no longer deadlocks against the print scheduler.

UI / rendering

- Light theme: low-contrast washed-out text on status/warning banners, badges, and form fields, app-wide (#1909, reported by @AntonPalmqvist).
- Sponsor toast ignored its 14-day cooldown and re-fired on every fresh browser session (#2477, reported by @pchulpjoost).

---
**Security**

- idna: bumped to >=3.15 to clear CVE-2026-45409 (ReDoS in idna.encode() with crafted Unicode input).

---
**Contributors**

External code contributors with merged PRs in this release: @samedyuksel (#1659 — native CSV import / export on the inventory page). Thank you!

The reporters who drove the fixes in this release are credited inline next to each entry above.

---
**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 2 месяцев назад
Родитель
Сommit
b27601db54
100 измененных файлов с 10252 добавлено и 588 удалено
  1. 3 1
      .pre-commit-config.yaml
  2. 0 62
      CHANGELOG.md
  3. 3 2
      CONTRIBUTING.md
  4. 3 2
      README.md
  5. 12 0
      backend/app/api/routes/api_keys.py
  6. 5 2
      backend/app/api/routes/labels.py
  7. 95 1
      backend/app/api/routes/library.py
  8. 955 0
      backend/app/api/routes/pipeline_runs.py
  9. 16 27
      backend/app/api/routes/print_queue.py
  10. 15 12
      backend/app/api/routes/printers.py
  11. 4 0
      backend/app/api/routes/settings.py
  12. 199 0
      backend/app/api/routes/slicer_pipelines.py
  13. 16 0
      backend/app/api/routes/websocket.py
  14. 8 0
      backend/app/cli.py
  15. 69 20
      backend/app/core/auth.py
  16. 1 1
      backend/app/core/config.py
  17. 133 49
      backend/app/core/database.py
  18. 16 0
      backend/app/core/permissions.py
  19. 112 0
      backend/app/core/websocket.py
  20. 34 35
      backend/app/main.py
  21. 5 0
      backend/app/models/__init__.py
  22. 9 0
      backend/app/models/api_key.py
  23. 111 0
      backend/app/models/pipeline_run.py
  24. 9 0
      backend/app/models/print_queue.py
  25. 61 0
      backend/app/models/slicer_pipeline.py
  26. 11 0
      backend/app/schemas/api_key.py
  27. 173 0
      backend/app/schemas/pipeline_run.py
  28. 12 1
      backend/app/schemas/print_queue.py
  29. 51 0
      backend/app/schemas/settings.py
  30. 83 0
      backend/app/schemas/slicer_pipeline.py
  31. 26 29
      backend/app/services/archive.py
  32. 18 11
      backend/app/services/bambu_cloud.py
  33. 108 37
      backend/app/services/bambu_mqtt.py
  34. 61 8
      backend/app/services/external_camera.py
  35. 58 22
      backend/app/services/label_renderer.py
  36. 17 11
      backend/app/services/network_utils.py
  37. 360 0
      backend/app/services/pipeline_eligibility.py
  38. 575 55
      backend/app/services/print_scheduler.py
  39. 63 0
      backend/app/services/printer_manager.py
  40. 9 1
      backend/app/services/slicer_3mf_convert.py
  41. 98 7
      backend/app/services/smart_plug_manager.py
  42. 228 17
      backend/app/services/spoolman_tracking.py
  43. 19 41
      backend/app/services/usage_tracker.py
  44. 38 0
      backend/app/services/virtual_printer/ftp_server.py
  45. 22 11
      backend/app/services/virtual_printer/manager.py
  46. 55 3
      backend/app/services/virtual_printer/mqtt_server.py
  47. 71 7
      backend/app/utils/threemf_tools.py
  48. 94 0
      backend/app/utils/tray_split.py
  49. 56 1
      backend/tests/integration/test_auth_apikey_rbac.py
  50. 2 2
      backend/tests/integration/test_labels.py
  51. 44 0
      backend/tests/integration/test_library_slice_api.py
  52. 927 0
      backend/tests/integration/test_pipeline_runs_api.py
  53. 61 14
      backend/tests/integration/test_printers_api.py
  54. 57 0
      backend/tests/integration/test_read_permission_backfill_migration.py
  55. 158 0
      backend/tests/integration/test_slicer_pipelines_api.py
  56. 73 0
      backend/tests/unit/services/test_archive_service.py
  57. 88 0
      backend/tests/unit/services/test_bambu_cloud.py
  58. 112 0
      backend/tests/unit/services/test_bambu_mqtt.py
  59. 114 0
      backend/tests/unit/services/test_external_camera.py
  60. 64 33
      backend/tests/unit/services/test_hms_actions.py
  61. 70 2
      backend/tests/unit/services/test_label_renderer.py
  62. 83 0
      backend/tests/unit/services/test_network_utils.py
  63. 40 0
      backend/tests/unit/services/test_printer_manager.py
  64. 45 0
      backend/tests/unit/services/test_slicer_3mf_convert.py
  65. 221 1
      backend/tests/unit/services/test_smart_plug_manager.py
  66. 350 0
      backend/tests/unit/services/test_spoolman_tray_split.py
  67. 140 0
      backend/tests/unit/services/test_virtual_printer.py
  68. 102 0
      backend/tests/unit/test_scheduler_ams_mapping.py
  69. 27 18
      backend/tests/unit/test_scheduler_auto_drying.py
  70. 1 1
      backend/tests/unit/test_scheduler_backup_gate.py
  71. 237 0
      backend/tests/unit/test_scheduler_cancel_race.py
  72. 152 0
      backend/tests/unit/test_scheduler_clear_plate.py
  73. 568 0
      backend/tests/unit/test_scheduler_preheat.py
  74. 153 0
      backend/tests/unit/test_slice_process_support_patch.py
  75. 93 0
      backend/tests/unit/test_threemf_tools.py
  76. 113 0
      backend/tests/unit/test_upload_progress_bridge.py
  77. 68 1
      backend/tests/unit/test_vp_ftp_stor.py
  78. 93 0
      backend/tests/unit/test_vp_mqtt_server.py
  79. 138 0
      backend/tests/unit/test_ws_broadcast_to_user.py
  80. 179 0
      backend/tests/unit/utils/test_tray_split.py
  81. 3 1
      deploy/bambuddy.service
  82. 14 0
      frontend/scripts/check-i18n-parity.mjs
  83. 4 0
      frontend/src/App.tsx
  84. 28 0
      frontend/src/__tests__/components/LabelTemplatePickerModal.test.tsx
  85. 46 0
      frontend/src/__tests__/components/PrintModal.test.tsx
  86. 210 0
      frontend/src/__tests__/components/RunWithPipelineModal.test.tsx
  87. 265 9
      frontend/src/__tests__/components/SliceModal.test.tsx
  88. 74 2
      frontend/src/__tests__/contexts/AuthContext.test.tsx
  89. 123 0
      frontend/src/__tests__/contexts/DispatchToastContext.test.tsx
  90. 114 0
      frontend/src/__tests__/hooks/useSponsorPrompt.test.tsx
  91. 98 0
      frontend/src/__tests__/hooks/useWebSocket.test.ts
  92. 156 0
      frontend/src/__tests__/pages/PipelineRunsPage.test.tsx
  93. 17 2
      frontend/src/__tests__/pages/PrintersPageBucketing.test.ts
  94. 55 2
      frontend/src/__tests__/pages/SettingsPage.test.tsx
  95. 6 2
      frontend/src/__tests__/utils/currency.test.ts
  96. 247 3
      frontend/src/api/client.ts
  97. 2 2
      frontend/src/components/AMSHistoryModal.tsx
  98. 13 13
      frontend/src/components/APIBrowser.tsx
  99. 2 2
      frontend/src/components/AddExternalLinkModal.tsx
  100. 2 2
      frontend/src/components/AddNotificationModal.tsx

+ 3 - 1
.pre-commit-config.yaml

@@ -30,7 +30,9 @@ repos:
         exclude: ^(static/|frontend/tsconfig\.)
       - id: check-added-large-files
         args: ['--maxkb=1000']
-        exclude: ^static/assets/
+        # CHANGELOG.md is intentionally large (detailed per-release entries over
+        # many versions); exempt it while keeping the 1 MB guard for everything else.
+        exclude: ^(static/assets/|CHANGELOG\.md$)
       - id: check-merge-conflict
       - id: debug-statements
       - id: detect-private-key

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


+ 3 - 2
CONTRIBUTING.md

@@ -117,8 +117,9 @@ pip install -r requirements-dev.txt  # Dev/test dependencies (pytest, ruff, band
 pip install pre-commit
 pre-commit install
 
-# Run backend
-DEBUG=true uvicorn backend.app.main:app --reload --host 0.0.0.0 --port 8000
+# Run backend (--loop asyncio matches production; avoids a uvloop TLS bug
+# that can truncate Virtual Printer FTP uploads on slow storage — see #1896)
+DEBUG=true uvicorn backend.app.main:app --reload --host 0.0.0.0 --port 8000 --loop asyncio
 ```
 
 ### Frontend Setup

+ 3 - 2
README.md

@@ -209,6 +209,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 - Queue Only mode (stage without auto-start)
 - Clear plate confirmation between queued prints (can be disabled in settings for farm workflows)
 - Auto-print G-code injection (per-model start/end snippets for Farmloop, SwapMod, AutoClear, Printflow 3D — toggle per queue item)
+- **Preheat & Heat Soak before queued prints** — Heat the bed (and the chamber, on supported printers) and hold at temperature between FTP upload and print start. Per-print Inherit / On / Off override in the Print Options panel; per-filament chamber-target map under Settings → Workflow so PA wants 50°C, ABS 45°C, PETG-CF 40°C, PLA 0°C (skips chamber phase automatically). Hardware-aware: H-series / X2D / X1E actively heat the chamber via M141; X1C / P2S rely on bed radiation with a chamber-sensor wait; P1S / P1P / A1 family have no chamber sensor so only the soak timer applies. The cooling/heating airduct flap on H-series / X2D / P2S auto-switches to match the resolved chamber target — preheat for ABS opens nothing and recirculates warm air; preheat for PLA opens the exhaust and vents — so engineering filaments actually reach target instead of fighting the open flap, and PLA prints don't inherit a previously-hot recirculation. M191 (wait-for-chamber-temp) isn't honoured by Bambu firmware, so doing this at the orchestration layer is the only place it works
 - Smart plug integration (Tasmota, Home Assistant, MQTT, REST/Webhook)
 - REST smart plugs: Control any device with an HTTP API (openHAB, ioBroker, FHEM, Node-RED) with separate power/energy URLs and unit multipliers
 - MQTT smart plugs: Subscribe to Zigbee2MQTT, Shelly, or any MQTT topic for energy monitoring
@@ -664,8 +665,8 @@ python3 -m venv venv
 source venv/bin/activate
 pip install -r requirements.txt
 
-# Run
-uvicorn backend.app.main:app --host 0.0.0.0 --port 8000
+# Run (--loop asyncio avoids a uvloop TLS bug that can truncate VP FTP uploads)
+uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 --loop asyncio
 ```
 
 Open **http://localhost:8000** and add your printer!

+ 12 - 0
backend/app/api/routes/api_keys.py

@@ -65,6 +65,9 @@ async def create_api_key(
         can_read_status=data.can_read_status,
         can_manage_library=data.can_manage_library,
         can_manage_inventory=data.can_manage_inventory,
+        can_manage_maintenance=data.can_manage_maintenance,
+        can_manage_archives=data.can_manage_archives,
+        can_manage_projects=data.can_manage_projects,
         can_access_cloud=data.can_access_cloud,
         can_update_energy_cost=data.can_update_energy_cost,
         printer_ids=data.printer_ids,
@@ -86,6 +89,9 @@ async def create_api_key(
         can_read_status=api_key.can_read_status,
         can_manage_library=api_key.can_manage_library,
         can_manage_inventory=api_key.can_manage_inventory,
+        can_manage_maintenance=api_key.can_manage_maintenance,
+        can_manage_archives=api_key.can_manage_archives,
+        can_manage_projects=api_key.can_manage_projects,
         can_access_cloud=api_key.can_access_cloud,
         can_update_energy_cost=api_key.can_update_energy_cost,
         printer_ids=api_key.printer_ids,
@@ -139,6 +145,12 @@ async def update_api_key(
         api_key.can_manage_library = data.can_manage_library
     if data.can_manage_inventory is not None:
         api_key.can_manage_inventory = data.can_manage_inventory
+    if data.can_manage_maintenance is not None:
+        api_key.can_manage_maintenance = data.can_manage_maintenance
+    if data.can_manage_archives is not None:
+        api_key.can_manage_archives = data.can_manage_archives
+    if data.can_manage_projects is not None:
+        api_key.can_manage_projects = data.can_manage_projects
     if data.can_access_cloud is not None:
         # Same constraint as create — flipping cloud access on a legacy key
         # without an owner would be silently broken; reject at the route layer.

+ 5 - 2
backend/app/api/routes/labels.py

@@ -60,6 +60,9 @@ class LabelRequest(BaseModel):
         "avery_5160",
         "avery_l7160",
     ]
+    # Black-and-white thermal printers: drop the colour swatch (prints as a
+    # muddy grey block) and widen the text column instead (#1870).
+    monochrome: bool = False
 
 
 def _split_extra_colors(raw: str | None) -> list[str] | None:
@@ -170,7 +173,7 @@ async def render_local_inventory_labels(
     deeplink_base = await _resolve_deeplink_base(request, db)
     data_list = [_spool_to_label_data(s, deeplink_base) for s in ordered]
 
-    pdf = render_labels(body.template, data_list)
+    pdf = render_labels(body.template, data_list, monochrome=body.monochrome)
     filename = f"bambuddy-labels-{body.template}.pdf"
     return _stream_pdf(pdf, filename)
 
@@ -214,6 +217,6 @@ async def render_spoolman_labels(
     deeplink_base = await _resolve_deeplink_base(request, db)
     data_list = [_spoolman_dict_to_label_data(by_id[sid], deeplink_base) for sid in body.spool_ids]
 
-    pdf = render_labels(body.template, data_list)
+    pdf = render_labels(body.template, data_list, monochrome=body.monochrome)
     filename = f"bambuddy-labels-spoolman-{body.template}.pdf"
     return _stream_pdf(pdf, filename)

+ 95 - 1
backend/app/api/routes/library.py

@@ -3285,12 +3285,79 @@ def _patch_process_bed_type(process_json: str, bed_type: str) -> str:
     return json.dumps(profile)
 
 
+# Support-related keys we lift from the source 3MF's project_settings.config
+# into the picked process preset before `--load-settings` sees it (#1881).
+# BambuStudio's shipped process presets ("0.20mm Standard @BBL H2D" etc.)
+# define `enable_support: 0` as their default — supports are a per-print
+# decision, not a per-quality one. `--load-settings` is authoritative, so
+# without preserving these fields the source's per-project support intent
+# (supports on, PVA in the interface slot, tree vs normal) gets discarded
+# and the slicer produces a single-material output with no supports at all.
+_SOURCE_PROCESS_SUPPORT_KEYS_TO_PRESERVE = (
+    "enable_support",
+    "support_filament",
+    "support_interface_filament",
+    "support_type",
+)
+
+
+def _patch_process_support_settings(process_json: str, source_3mf_bytes: bytes) -> str:
+    """Overlay the source 3MF's support configuration onto the process JSON.
+
+    Only fires on 3MF sources — STL / STEP don't carry `project_settings.
+    config`. Silently no-ops when the source doesn't have the config, has
+    a malformed one, or when the process JSON isn't parseable — the slice
+    then runs with the process preset's own defaults, which is the safe
+    fall-back for both this bug and the pre-fix behaviour.
+    """
+    from io import BytesIO
+
+    try:
+        with zipfile.ZipFile(BytesIO(source_3mf_bytes), "r") as zf:
+            if "Metadata/project_settings.config" not in zf.namelist():
+                return process_json
+            src_cfg = json.loads(zf.read("Metadata/project_settings.config").decode("utf-8"))
+    except (zipfile.BadZipFile, json.JSONDecodeError, UnicodeDecodeError, OSError, KeyError):
+        return process_json
+    if not isinstance(src_cfg, dict):
+        return process_json
+
+    try:
+        process_cfg = json.loads(process_json)
+    except json.JSONDecodeError:
+        return process_json
+    if not isinstance(process_cfg, dict):
+        return process_json
+
+    for key in _SOURCE_PROCESS_SUPPORT_KEYS_TO_PRESERVE:
+        if key in src_cfg:
+            process_cfg[key] = src_cfg[key]
+
+    return json.dumps(process_cfg)
+
+
 # The sidecar prefixes the slicer CLI's own error_string with this when the
 # slicer ran and rejected the job (model off the bed, incompatible filament
 # temps, range validation) — as opposed to the CLI crashing before it could
 # evaluate the job at all.
 _SLICER_REJECTION_MARKER = "Slicing failed with error from slicer:"
 
+# The CLI writes its real diagnostic to stdout/stderr on the `[error]` level.
+# Format is `[<timestamp>] [error] run <NNNN>: <message>` (or sometimes without
+# the `run NNNN:` prefix). The bracketed timestamp is optional; the `[error]`
+# tag is what we anchor on. Used to recover the actual rejection reason for
+# the `error_string: "The input preset file is invalid and can not be parsed."`
+# case (#1851) — the CLI emits that generic placeholder for every -5 exit
+# including real preset-compat rejections, and the per-incident specifics
+# only live in the stdout dump.
+_CLI_ERROR_LINE_RE = re.compile(r"\[error\]\s*(?:run\s+\d+:\s*)?(.+?)\s*$", re.MULTILINE)
+
+# The placeholder error_string Bambu Studio writes to result.json for any
+# `--load-settings` parse / compat rejection (-5 exit). When the sidecar
+# surfaces this, the real reason lives in the stdout `[error]` line that we
+# mine via _CLI_ERROR_LINE_RE.
+_INPUT_PRESET_INVALID_PLACEHOLDER = "The input preset file is invalid and can not be parsed."
+
 
 def _slicer_rejection_message(error_text: str) -> str | None:
     """Extract the slicer's own rejection reason from a sidecar error string,
@@ -3301,16 +3368,34 @@ def _slicer_rejection_message(error_text: str) -> str | None:
     no. Retrying with the 3MF's embedded settings would then only "succeed"
     by silently reverting to the source file's original printer, masking the
     real problem; such failures must reach the user instead.
+
+    When the sidecar's `error_string` is Bambu Studio's generic
+    "The input preset file is invalid and can not be parsed." placeholder
+    (#1851) — emitted for every -5 exit, including the actual preset-compat
+    rejections whose real reason is logged to stdout as
+    `[error] run NNNN: <diagnostic>` — prefer the stdout `[error]` line so
+    the user sees which preset clashed with which printer.
     """
     if _SLICER_REJECTION_MARKER not in error_text:
         return None
     reason = error_text.split(_SLICER_REJECTION_MARKER, 1)[1]
+    # Mine the stdout/stderr dump for a more specific CLI diagnostic before
+    # we trim it off below. Done first so the lookup window covers the full
+    # response, not just the headline.
+    cli_diagnostic_match = _CLI_ERROR_LINE_RE.search(reason)
+    cli_diagnostic = cli_diagnostic_match.group(1).strip() if cli_diagnostic_match else None
     # Trim the sidecar's trailing exit-code note and any stderr/stdout dump.
     for cut in (": Slicer process failed", "\nstderr:", "\nstdout:"):
         idx = reason.find(cut)
         if idx != -1:
             reason = reason[:idx]
-    return reason.strip() or None
+    reason = reason.strip() or None
+    # When the headline is Bambu Studio's catch-all placeholder, the real
+    # reason is in the stdout `[error]` line. Substitute it. The placeholder
+    # by itself tells the user nothing about why their slice was rejected.
+    if cli_diagnostic and (reason is None or reason == _INPUT_PRESET_INVALID_PLACEHOLDER):
+        return cli_diagnostic
+    return reason
 
 
 async def _run_slicer_with_fallback(
@@ -3424,6 +3509,15 @@ async def _run_slicer_with_fallback(
         # didn't touch) still drive the slice.
         primary_bytes = _sanitize_project_settings_sentinels(primary_bytes)
 
+        # #1881: preserve the source 3MF's support configuration on top of
+        # the picked process preset. Bambu's shipped process presets set
+        # `enable_support: 0` by default (supports are a per-print, not
+        # per-quality, decision); `--load-settings` is authoritative so
+        # without patching, the source's `enable_support: 1` + support-slot
+        # assignments get discarded and the slice comes out single-material
+        # with a PVA slot loaded but never used.
+        presets["process"] = _patch_process_support_settings(presets["process"], primary_bytes)
+
     used_embedded_settings = False
     service = SlicerApiService(api_url)
 

+ 955 - 0
backend/app/api/routes/pipeline_runs.py

@@ -0,0 +1,955 @@
+"""API routes for Slicer Pipeline runs (#1425 PR B + PR C).
+
+PR B implemented single-target dispatch: one Run-pipeline click =
+  slice the source once → enqueue ONE print on ``target_printer_id``.
+
+PR C extends this with:
+  * ``copies > 1`` — slice once, enqueue N copies.
+  * ``target_kind='printer_class'`` — pipeline targets a Bambu model code
+    (X1C / P1S / H2D / …); orchestrator distributes copies across matching
+    printers using the pipeline's ``fanout_strategy``.
+  * Retry-failed runs that re-attempt only the failed/cancelled copies of
+    a partial-failure run.
+  * Dashboard list endpoint (``GET /pipeline-runs``) with status + pipeline
+    filters and pagination.
+  * WebSocket ``pipeline_run_updated`` events on state transitions so the
+    dashboard refreshes live without polling.
+
+The slice itself runs through ``slice_dispatch`` (same path as the manual
+SliceModal), so the ``Slicing X — Generating G-code 75%`` toast renders
+end-to-end. The slice job's id rides on the run response so the frontend
+can call ``trackJob`` directly.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Literal
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import delete, desc, func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.config import settings as app_settings
+from backend.app.core.database import async_session, get_db
+from backend.app.core.permissions import Permission
+from backend.app.core.websocket import ws_manager
+from backend.app.models.archive import PrintArchive
+from backend.app.models.library import LibraryFile
+from backend.app.models.pipeline_run import PipelineJob, PipelineRun
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+from backend.app.models.slicer_pipeline import SlicerPipeline
+from backend.app.models.user import User
+from backend.app.schemas.pipeline_run import (
+    CheckEligibilityRequest,
+    EligibilityIssueResponse,
+    EligibilityReportResponse,
+    PerPrinterReport as PerPrinterReportResponse,
+    PipelineJobResponse,
+    PipelineRunCreateRequest,
+    PipelineRunListResponse,
+    PipelineRunResponse,
+)
+from backend.app.schemas.slicer import PresetRef, SliceRequest
+from backend.app.services.pipeline_eligibility import (
+    EligibilityReport,
+    check_pipeline_eligibility,
+)
+
+logger = logging.getLogger(__name__)
+
+
+pipeline_run_create_router = APIRouter(prefix="/slicer-pipelines", tags=["Slicer Pipelines"])
+pipeline_run_router = APIRouter(prefix="/pipeline-runs", tags=["Slicer Pipelines"])
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _serialise_status(report: EligibilityReport) -> EligibilityReportResponse:
+    return EligibilityReportResponse(
+        ok=report.ok,
+        target_kind=report.target_kind,
+        target_printer_id=report.target_printer_id,
+        target_printer_name=report.target_printer_name,
+        target_model_class=report.target_model_class,
+        issues=[
+            EligibilityIssueResponse(
+                kind=issue.kind,
+                slot_index=issue.slot_index,
+                expected=issue.expected,
+                actual=issue.actual,
+            )
+            for issue in report.issues
+        ],
+        printer_reports=[
+            PerPrinterReportResponse(
+                printer_id=r.printer_id,
+                printer_name=r.printer_name,
+                ok=r.ok,
+                issues=[
+                    EligibilityIssueResponse(
+                        kind=i.kind,
+                        slot_index=i.slot_index,
+                        expected=i.expected,
+                        actual=i.actual,
+                    )
+                    for i in r.issues
+                ],
+            )
+            for r in report.printer_reports
+        ],
+    )
+
+
+async def _load_pipeline(db: AsyncSession, pipeline_id: int) -> SlicerPipeline:
+    pipeline = (
+        await db.execute(
+            select(SlicerPipeline).where(
+                SlicerPipeline.id == pipeline_id,
+                SlicerPipeline.is_deleted.is_(False),
+            )
+        )
+    ).scalar_one_or_none()
+    if pipeline is None:
+        raise HTTPException(404, "Pipeline not found")
+    return pipeline
+
+
+async def _load_printer_status(printer_id: int | None) -> dict | None:
+    """Snapshot the printer_manager's live PrinterState for the eligibility
+    matcher. Returns ``None`` when the printer has no MQTT client."""
+    if printer_id is None:
+        return None
+    from backend.app.services.printer_manager import printer_manager
+
+    state = printer_manager.get_status(printer_id)
+    if state is None:
+        return None
+    return {"connected": state.connected, "raw_data": state.raw_data}
+
+
+def _make_status_lookup():
+    """Closure that snapshots the printer_manager once per printer_id call.
+    Passed to the matcher's class-targeting branch so it can read live state
+    for every candidate printer."""
+
+    def _lookup(printer_id: int) -> dict | None:
+        from backend.app.services.printer_manager import printer_manager
+
+        state = printer_manager.get_status(printer_id)
+        if state is None:
+            return None
+        return {"connected": state.connected, "raw_data": state.raw_data}
+
+    return _lookup
+
+
+def _slice_request_from_pipeline(pipeline: SlicerPipeline) -> SliceRequest:
+    try:
+        raw_filaments = json.loads(pipeline.filament_presets_json or "[]")
+    except (json.JSONDecodeError, TypeError):
+        raw_filaments = []
+    filament_presets = [
+        PresetRef(source=r["source"], id=r["id"])
+        for r in raw_filaments
+        if isinstance(r, dict) and "source" in r and "id" in r
+    ]
+    return SliceRequest(
+        printer_preset=PresetRef(source=pipeline.printer_preset_source, id=pipeline.printer_preset_id),
+        process_preset=PresetRef(source=pipeline.process_preset_source, id=pipeline.process_preset_id),
+        filament_presets=filament_presets,
+        bed_type=pipeline.bed_type,
+        export_3mf=True,
+    )
+
+
+def _compute_job_status(
+    persisted: str,
+    queue_entry: PrintQueueItem | None,
+) -> str:
+    if persisted in ("failed", "cancelled", "completed"):
+        return persisted
+    if queue_entry is None:
+        return persisted
+    qs = queue_entry.status
+    if qs == "completed":
+        return "completed"
+    if qs in ("failed", "aborted"):
+        return "failed"
+    if qs == "cancelled":
+        return "cancelled"
+    if qs == "printing":
+        return "printing"
+    return "queued"
+
+
+def _roll_up_run_status(
+    persisted: str,
+    job_statuses: list[str],
+) -> str:
+    """Compute the run-level status from the per-job statuses.
+
+    Terminal-persisted always wins for explicit cancels / hard failures so
+    the dashboard doesn't flicker when one job's queue entry hasn't caught
+    up. Otherwise:
+      - all completed → completed
+      - any in_progress / printing / queued / dispatching → in_progress
+      - any failed alongside any completed → partial_failure
+      - all failed/cancelled → failed
+    """
+    if persisted in ("cancelled",):
+        return persisted
+    if not job_statuses:
+        return persisted
+
+    completed = sum(1 for s in job_statuses if s == "completed")
+    failed = sum(1 for s in job_statuses if s == "failed")
+    cancelled = sum(1 for s in job_statuses if s == "cancelled")
+    in_flight = sum(1 for s in job_statuses if s in ("printing", "queued", "awaiting_printer", "pending"))
+    total = len(job_statuses)
+
+    if completed == total:
+        return "completed"
+    if in_flight > 0:
+        return "in_progress" if persisted not in ("queued", "slicing", "dispatching") else persisted
+    # All copies are in terminal states.
+    if failed == 0 and cancelled == total:
+        return "cancelled"
+    if completed > 0 and (failed > 0 or cancelled > 0):
+        return "partial_failure"
+    if failed > 0:
+        return "failed"
+    return persisted
+
+
+async def _materialise_run(db: AsyncSession, run: PipelineRun) -> PipelineRunResponse:
+    pipeline_name: str | None = None
+    target_kind = None
+    target_printer_id = None
+    target_model_class = None
+    fanout_strategy = None
+    if run.pipeline_id:
+        pipeline = (
+            await db.execute(select(SlicerPipeline).where(SlicerPipeline.id == run.pipeline_id))
+        ).scalar_one_or_none()
+        if pipeline:
+            pipeline_name = pipeline.name
+            target_kind = pipeline.target_kind  # type: ignore[assignment]
+            target_printer_id = pipeline.target_printer_id
+            target_model_class = pipeline.target_model_class
+            fanout_strategy = pipeline.fanout_strategy  # type: ignore[assignment]
+
+    source_filename: str | None = None
+    if run.source_library_file_id:
+        src = (
+            await db.execute(select(LibraryFile).where(LibraryFile.id == run.source_library_file_id))
+        ).scalar_one_or_none()
+        source_filename = src.filename if src else None
+    elif run.source_archive_id:
+        arc = (
+            await db.execute(select(PrintArchive).where(PrintArchive.id == run.source_archive_id))
+        ).scalar_one_or_none()
+        source_filename = (arc.print_name or arc.filename) if arc else None
+
+    job_rows = (
+        (
+            await db.execute(
+                select(PipelineJob).where(PipelineJob.pipeline_run_id == run.id).order_by(PipelineJob.copy_index)
+            )
+        )
+        .scalars()
+        .all()
+    )
+
+    job_responses: list[PipelineJobResponse] = []
+    job_live_statuses: list[str] = []
+    for job in job_rows:
+        queue_entry = None
+        if job.queue_entry_id:
+            queue_entry = (
+                await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == job.queue_entry_id))
+            ).scalar_one_or_none()
+
+        printer_name: str | None = None
+        if job.assigned_printer_id:
+            p = (await db.execute(select(Printer).where(Printer.id == job.assigned_printer_id))).scalar_one_or_none()
+            printer_name = p.name if p else None
+
+        live_job_status = _compute_job_status(job.status, queue_entry)
+        # If the job WAS dispatched (had a queue_entry_id) but the entry has
+        # since been deleted from the queue page, the user's intent was
+        # cancellation. Otherwise the run would stay forever showing as
+        # ``queued`` because the persisted job.status hasn't been updated.
+        if (
+            job.queue_entry_id is not None
+            and queue_entry is None
+            and live_job_status not in ("completed", "failed", "cancelled")
+        ):
+            live_job_status = "cancelled"
+        job_live_statuses.append(live_job_status)
+        job_responses.append(
+            PipelineJobResponse(
+                id=job.id,
+                pipeline_run_id=job.pipeline_run_id,
+                copy_index=job.copy_index,
+                assigned_printer_id=job.assigned_printer_id,
+                assigned_printer_name=printer_name,
+                queue_entry_id=job.queue_entry_id,
+                status=live_job_status,  # type: ignore[arg-type]
+                error_message=job.error_message,
+                dispatched_at=job.dispatched_at,
+                completed_at=job.completed_at,
+            )
+        )
+
+    rolled_up = _roll_up_run_status(run.status, job_live_statuses)
+
+    return PipelineRunResponse(
+        id=run.id,
+        pipeline_id=run.pipeline_id,
+        pipeline_name=pipeline_name,
+        source_library_file_id=run.source_library_file_id,
+        source_archive_id=run.source_archive_id,
+        source_filename=source_filename,
+        parent_run_id=run.parent_run_id,
+        copies=run.copies,
+        copies_completed=sum(1 for s in job_live_statuses if s == "completed"),
+        copies_failed=sum(1 for s in job_live_statuses if s == "failed"),
+        copies_cancelled=sum(1 for s in job_live_statuses if s == "cancelled"),
+        copies_in_progress=sum(
+            1 for s in job_live_statuses if s in ("printing", "queued", "awaiting_printer", "pending")
+        ),
+        status=rolled_up,  # type: ignore[arg-type]
+        slice_job_id=run.slice_job_id,
+        sliced_library_file_id=run.sliced_library_file_id,
+        eligibility_overridden=run.eligibility_overridden,
+        error_message=run.error_message,
+        created_by=run.created_by,
+        created_at=run.created_at,
+        started_at=run.started_at,
+        completed_at=run.completed_at,
+        jobs=job_responses,
+        target_kind=target_kind,
+        target_printer_id=target_printer_id,
+        target_model_class=target_model_class,
+        fanout_strategy=fanout_strategy,
+    )
+
+
+async def _publish_run_event(db: AsyncSession, run: PipelineRun) -> None:
+    """Broadcast a ``pipeline_run_updated`` event with the full materialised
+    run. Per-user routing via ``broadcast_to_user`` falls back to a global
+    broadcast when ``created_by`` is None (auth-disabled installs)."""
+    try:
+        payload = await _materialise_run(db, run)
+        await ws_manager.broadcast_to_user(
+            run.created_by,
+            {
+                "type": "pipeline_run_updated",
+                "run": payload.model_dump(mode="json"),
+            },
+        )
+    except Exception:
+        logger.exception("Failed to broadcast pipeline_run_updated for run %d", run.id)
+
+
+# ---------------------------------------------------------------------------
+# Source resolution + orchestration
+# ---------------------------------------------------------------------------
+
+
+SourceKind = Literal["library_file", "archive"]
+
+
+async def _resolve_source(
+    db: AsyncSession,
+    *,
+    library_file_id: int | None,
+    archive_id: int | None,
+) -> tuple[SourceKind, int, str, Path]:
+    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")
+        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/.
+        if not src_path.exists():
+            raise HTTPException(404, "Source library file missing on disk")
+        return ("library_file", lib.id, lib.filename, src_path)
+
+    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")
+    rel = arc.source_3mf_path or arc.file_path
+    if not rel:
+        raise HTTPException(400, "Archive has no source file to slice")
+    src_path = (
+        Path(app_settings.base_dir) / rel
+    )  # SEC-PATH-OK: rel is archive.source_3mf_path / archive.file_path, both set by upload-time validators that already do resolve+relative_to containment.
+    if not src_path.exists():
+        raise HTTPException(404, "Archive source file missing on disk")
+    name = arc.filename or arc.print_name or src_path.name
+    return ("archive", arc.id, name, src_path)
+
+
+async def _pick_assignments(
+    db: AsyncSession,
+    pipeline: SlicerPipeline,
+    copies: int,
+) -> list[tuple[int | None, str | None]]:
+    """Return ``[(printer_id_or_None, target_model_or_None), ...]`` of length
+    ``copies`` per the pipeline's fanout strategy. ``target_model_class``
+    items leave ``printer_id`` None so the scheduler picks any free matching
+    printer; specific assignments fill ``printer_id``."""
+    target_kind = pipeline.target_kind or "specific_printer"
+    if target_kind == "specific_printer" or pipeline.target_printer_id is not None:
+        assert pipeline.target_printer_id is not None
+        return [(pipeline.target_printer_id, None)] * copies
+
+    # Class-targeting. Enumerate matching printers + apply the strategy.
+    matching = (
+        (
+            await db.execute(
+                select(Printer)
+                .where(Printer.model == pipeline.target_model_class)
+                .where(Printer.is_active.is_(True))
+                .order_by(Printer.id)
+            )
+        )
+        .scalars()
+        .all()
+    )
+    if not matching:
+        # Shouldn't reach here when eligibility passes, but failing gracefully
+        # is better than a TypeError on next-slot pick.
+        return [(None, pipeline.target_model_class)] * copies
+
+    strategy = pipeline.fanout_strategy or "max_parallel"
+    if strategy == "fill_one_first":
+        # Pin every copy to the first match. Scheduler dispatches them serially
+        # to that printer. If the printer breaks, copies wait; that's the
+        # documented trade-off.
+        return [(matching[0].id, None)] * copies
+    if strategy == "round_robin":
+        # Cycle through eligible printers — copy ``i`` lands on
+        # ``matching[i % len(matching)]``. Each item gets a fixed printer_id.
+        return [(matching[i % len(matching)].id, None) for i in range(copies)]
+    # max_parallel — leave printer_id=None, set target_model so the scheduler
+    # picks any free X1C / P1S / … for each item independently.
+    return [(None, pipeline.target_model_class)] * copies
+
+
+def _make_orchestration_callable(
+    *,
+    run_id: int,
+    pipeline_id: int,
+    src_kind: SourceKind,
+    src_id: int,
+    src_filename: str,
+    src_path: Path,
+    creator_user_id: int | None,
+    copies: int,
+):
+    """Returns the async callable that ``slice_dispatch.enqueue`` runs as the
+    background slice job. Wraps slice + multi-copy enqueue + state update."""
+
+    async def _orchestrate(slice_job_id: int) -> dict:
+        from backend.app.api.routes.library import slice_and_persist
+
+        async with async_session() as session:
+            run = (await session.execute(select(PipelineRun).where(PipelineRun.id == run_id))).scalar_one_or_none()
+            pipeline = (
+                await session.execute(select(SlicerPipeline).where(SlicerPipeline.id == pipeline_id))
+            ).scalar_one_or_none()
+            if run is None or pipeline is None:
+                logger.warning("pipeline_run %d or pipeline %d disappeared mid-orchestration", run_id, pipeline_id)
+                return {}
+
+            # Honour a cancel that landed between ``POST /run`` returning and
+            # this background task starting. If the run was cancelled while
+            # still in ``queued`` we must NOT flip it back to ``slicing`` —
+            # the operator's intent was to stop, and overwriting status here
+            # was the bug that left runs stuck at ``dispatching`` after a
+            # user-side cancel (#1425 PR C bug report).
+            if run.status == "cancelled":
+                logger.info("pipeline_run %d was cancelled before slicing started", run_id)
+                return {}
+
+            run.status = "slicing"
+            run.started_at = datetime.now(timezone.utc)
+            await session.commit()
+            await _publish_run_event(session, run)
+
+            slice_request = _slice_request_from_pipeline(pipeline)
+            model_bytes = src_path.read_bytes()
+
+            folder_id: int | None = None
+            if src_kind == "library_file":
+                lib = (await session.execute(select(LibraryFile).where(LibraryFile.id == src_id))).scalar_one_or_none()
+                if lib is not None:
+                    folder_id = lib.folder_id
+
+            try:
+                slice_response = await slice_and_persist(
+                    session,
+                    model_bytes=model_bytes,
+                    model_filename=src_filename,
+                    folder_id=folder_id,
+                    extra_metadata={
+                        f"sliced_from_{src_kind}_id": src_id,
+                        "sliced_via_pipeline_id": pipeline.id,
+                        "sliced_via_pipeline_run_id": run.id,
+                    },
+                    request=slice_request,
+                    current_user_id=creator_user_id,
+                    job_id=slice_job_id,
+                )
+            except HTTPException as exc:
+                run.status = "failed"
+                run.error_message = f"Slice failed: {exc.detail}"
+                run.completed_at = datetime.now(timezone.utc)
+                await session.commit()
+                await _publish_run_event(session, run)
+                raise
+            except Exception as exc:
+                logger.exception("Pipeline run %d slice raised unexpectedly", run_id)
+                run.status = "failed"
+                run.error_message = f"Slice failed: {exc}"
+                run.completed_at = datetime.now(timezone.utc)
+                await session.commit()
+                await _publish_run_event(session, run)
+                raise
+
+            run.sliced_library_file_id = slice_response.library_file_id
+
+            # Re-check cancellation: the slice can take minutes, and the
+            # operator may have hit Cancel during that window. Refresh from
+            # the DB rather than trusting our in-memory `run` (the cancel
+            # route writes via a separate session). When cancelled, don't
+            # enqueue print queue items — that's the whole point of cancel.
+            await session.refresh(run)
+            if run.status == "cancelled":
+                logger.info("pipeline_run %d cancelled mid-slice; skipping queue enqueue", run_id)
+                await session.commit()
+                return slice_response.model_dump()
+
+            # PR C: enqueue N copies per the picked assignment strategy.
+            assignments = await _pick_assignments(session, pipeline, copies)
+
+            jobs = (
+                (
+                    await session.execute(
+                        select(PipelineJob)
+                        .where(PipelineJob.pipeline_run_id == run_id)
+                        .order_by(PipelineJob.copy_index)
+                    )
+                )
+                .scalars()
+                .all()
+            )
+            if len(jobs) != copies:
+                logger.warning("pipeline_run %d expected %d jobs, found %d", run_id, copies, len(jobs))
+
+            for job, (printer_id, target_model) in zip(jobs, assignments, strict=False):
+                queue_item = PrintQueueItem(
+                    printer_id=printer_id,
+                    target_model=target_model,
+                    library_file_id=slice_response.library_file_id,
+                    created_by_id=creator_user_id,
+                    status="pending",
+                )
+                session.add(queue_item)
+                await session.flush()
+
+                job.queue_entry_id = queue_item.id
+                job.assigned_printer_id = printer_id  # may be None for max_parallel
+                # Don't write job.status yet — final cancellation check below
+                # may flip it to 'cancelled' instead. dispatched_at is fine to
+                # set unconditionally since the orchestration actually got here.
+                job.dispatched_at = datetime.now(timezone.utc)
+
+            # Final cancellation check before committing 'dispatching'. The
+            # cancel route writes via a separate session so we have to refresh
+            # to see the latest. If the cancel landed in this narrow window —
+            # AFTER the post-slice refresh but BEFORE this commit — the queue
+            # entries we just created would otherwise pick up and print. Mark
+            # them + the per-copy jobs cancelled so the user's intent sticks.
+            await session.refresh(run)
+            if run.status == "cancelled":
+                logger.info(
+                    "pipeline_run %d cancelled in the dispatch window; cancelling its %d queue entries",
+                    run_id,
+                    len(jobs),
+                )
+                for job in jobs:
+                    if job.queue_entry_id:
+                        qe = (
+                            await session.execute(select(PrintQueueItem).where(PrintQueueItem.id == job.queue_entry_id))
+                        ).scalar_one_or_none()
+                        if qe is not None and qe.status in ("pending", "queued"):
+                            qe.status = "cancelled"
+                    if job.status not in ("completed", "failed", "cancelled"):
+                        job.status = "cancelled"
+                        job.completed_at = datetime.now(timezone.utc)
+                await session.commit()
+                await _publish_run_event(session, run)
+                return slice_response.model_dump()
+
+            for job in jobs:
+                job.status = "queued"
+            run.status = "dispatching"
+            await session.commit()
+            await _publish_run_event(session, run)
+
+            return slice_response.model_dump()
+
+    return _orchestrate
+
+
+# ---------------------------------------------------------------------------
+# /slicer-pipelines/{id}/check-eligibility
+# ---------------------------------------------------------------------------
+
+
+@pipeline_run_create_router.post("/{pipeline_id}/check-eligibility", response_model=EligibilityReportResponse)
+async def check_eligibility(
+    pipeline_id: int,
+    body: CheckEligibilityRequest,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    pipeline = await _load_pipeline(db, pipeline_id)
+    await _resolve_source(
+        db,
+        library_file_id=body.source_library_file_id,
+        archive_id=body.source_archive_id,
+    )
+    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())
+    else:
+        status = await _load_printer_status(pipeline.target_printer_id)
+        report = await check_pipeline_eligibility(db, pipeline, status)
+    return _serialise_status(report)
+
+
+# ---------------------------------------------------------------------------
+# /slicer-pipelines/{id}/run
+# ---------------------------------------------------------------------------
+
+
+@pipeline_run_create_router.post("/{pipeline_id}/run", response_model=PipelineRunResponse, status_code=202)
+async def run_pipeline(
+    pipeline_id: int,
+    body: PipelineRunCreateRequest,
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
+    db: AsyncSession = Depends(get_db),
+):
+    from backend.app.api.routes.settings import get_setting
+    from backend.app.services.slice_dispatch import slice_dispatch
+
+    pipeline = await _load_pipeline(db, pipeline_id)
+    src_kind, src_id, src_filename, src_path = await _resolve_source(
+        db,
+        library_file_id=body.source_library_file_id,
+        archive_id=body.source_archive_id,
+    )
+
+    # Cap copies against the configured ceiling.
+    raw_cap = await get_setting(db, "pipeline_max_copies")
+    try:
+        cap = int(raw_cap) if raw_cap else 50
+    except (TypeError, ValueError):
+        cap = 50
+    if body.copies > cap:
+        raise HTTPException(
+            422,
+            f"copies={body.copies} exceeds pipeline_max_copies setting ({cap})",
+        )
+
+    # Eligibility pre-flight.
+    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())
+    else:
+        status = await _load_printer_status(pipeline.target_printer_id)
+        report = await check_pipeline_eligibility(db, pipeline, status)
+
+    if not report.ok and not body.force:
+        raise HTTPException(status_code=409, detail=_serialise_status(report).model_dump())
+
+    # Need a target — specific or class — to dispatch.
+    if pipeline.target_printer_id is None and not pipeline.target_model_class:
+        raise HTTPException(
+            400,
+            "Pipeline has no target. Open the pipeline in Settings → Workflow → Pipelines and choose a target printer or printer class.",
+        )
+
+    run = PipelineRun(
+        pipeline_id=pipeline.id,
+        source_library_file_id=src_id if src_kind == "library_file" else None,
+        source_archive_id=src_id if src_kind == "archive" else None,
+        copies=body.copies,
+        status="queued",
+        eligibility_overridden=(not report.ok and body.force),
+        created_by=current_user.id if current_user else None,
+    )
+    db.add(run)
+    await db.flush()
+
+    # One PipelineJob per copy. PR B was copies=1, PR C generalises.
+    for i in range(body.copies):
+        db.add(
+            PipelineJob(
+                pipeline_run_id=run.id,
+                copy_index=i,
+                status="pending",
+            )
+        )
+    await db.commit()
+    await db.refresh(run)
+    await _publish_run_event(db, run)
+
+    orchestrate = _make_orchestration_callable(
+        run_id=run.id,
+        pipeline_id=pipeline.id,
+        src_kind=src_kind,
+        src_id=src_id,
+        src_filename=src_filename,
+        src_path=src_path,
+        creator_user_id=current_user.id if current_user else None,
+        copies=body.copies,
+    )
+    slice_job = await slice_dispatch.enqueue(
+        kind="library_file" if src_kind == "library_file" else "archive",
+        source_id=src_id,
+        source_name=src_filename,
+        run=orchestrate,
+    )
+
+    run.slice_job_id = slice_job.id
+    await db.commit()
+    await db.refresh(run)
+
+    return await _materialise_run(db, run)
+
+
+# ---------------------------------------------------------------------------
+# Lists, reads, cancel, retry-failed
+# ---------------------------------------------------------------------------
+
+
+@pipeline_run_create_router.get("/{pipeline_id}/runs", response_model=PipelineRunListResponse)
+async def list_runs_for_pipeline(
+    pipeline_id: int,
+    limit: int = 10,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    limit = max(1, min(limit, 100))
+    rows = (
+        (
+            await db.execute(
+                select(PipelineRun)
+                .where(PipelineRun.pipeline_id == pipeline_id)
+                .order_by(PipelineRun.id.desc())
+                .limit(limit)
+            )
+        )
+        .scalars()
+        .all()
+    )
+    total = (
+        await db.execute(select(func.count()).select_from(PipelineRun).where(PipelineRun.pipeline_id == pipeline_id))
+    ).scalar() or 0
+    return PipelineRunListResponse(
+        runs=[await _materialise_run(db, r) for r in rows],
+        total=total,
+    )
+
+
+@pipeline_run_router.get("", response_model=PipelineRunListResponse)
+async def list_all_runs(
+    limit: int = 25,
+    offset: int = 0,
+    pipeline_id: int | None = None,
+    status: str | None = None,
+    target_printer_id: int | None = None,
+    target_model_class: str | None = None,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """Dashboard list. Newest first; filters on pipeline_id + status +
+    target_printer_id + target_model_class. The ``status`` filter matches
+    the persisted snapshot, not the live roll-up — in-progress runs may
+    appear under ``dispatching`` until the next state transition writes
+    through. ``target_*`` filters JOIN to the pipeline so runs whose
+    pipeline currently points at the printer / class are returned."""
+    limit = max(1, min(limit, 100))
+    offset = max(0, offset)
+
+    stmt = select(PipelineRun)
+    count_stmt = select(func.count()).select_from(PipelineRun)
+    if pipeline_id is not None:
+        stmt = stmt.where(PipelineRun.pipeline_id == pipeline_id)
+        count_stmt = count_stmt.where(PipelineRun.pipeline_id == pipeline_id)
+    if status:
+        stmt = stmt.where(PipelineRun.status == status)
+        count_stmt = count_stmt.where(PipelineRun.status == status)
+    if target_printer_id is not None or target_model_class is not None:
+        stmt = stmt.join(SlicerPipeline, SlicerPipeline.id == PipelineRun.pipeline_id)
+        count_stmt = count_stmt.join(SlicerPipeline, SlicerPipeline.id == PipelineRun.pipeline_id)
+        if target_printer_id is not None:
+            stmt = stmt.where(SlicerPipeline.target_printer_id == target_printer_id)
+            count_stmt = count_stmt.where(SlicerPipeline.target_printer_id == target_printer_id)
+        if target_model_class is not None:
+            stmt = stmt.where(SlicerPipeline.target_model_class == target_model_class)
+            count_stmt = count_stmt.where(SlicerPipeline.target_model_class == target_model_class)
+
+    rows = (await db.execute(stmt.order_by(desc(PipelineRun.id)).offset(offset).limit(limit))).scalars().all()
+    total = (await db.execute(count_stmt)).scalar() or 0
+
+    return PipelineRunListResponse(
+        runs=[await _materialise_run(db, r) for r in rows],
+        total=total,
+    )
+
+
+_TERMINAL_RUN_STATUSES = ("completed", "failed", "cancelled", "partial_failure")
+
+
+@pipeline_run_router.post("/clear")
+async def clear_terminal_runs(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete every terminal pipeline run (completed / failed / cancelled /
+    partial_failure). In-flight runs (queued / slicing / dispatching /
+    in_progress) are preserved — clearing those mid-flight would lose the
+    operator's intent. Cascades to PipelineJob via the ondelete='CASCADE'
+    relationship; the linked PrintQueueItem rows stay (they have their own
+    lifecycle on the queue page)."""
+    # Count first so the response can report how many got cleared. Done
+    # under the same session/transaction as the delete so the numbers can't
+    # drift if another caller races in.
+    count_stmt = select(func.count()).select_from(PipelineRun).where(PipelineRun.status.in_(_TERMINAL_RUN_STATUSES))
+    n = (await db.execute(count_stmt)).scalar() or 0
+    if n > 0:
+        await db.execute(delete(PipelineRun).where(PipelineRun.status.in_(_TERMINAL_RUN_STATUSES)))
+        await db.commit()
+    return {"deleted": n}
+
+
+@pipeline_run_router.get("/{run_id}", response_model=PipelineRunResponse)
+async def get_run(
+    run_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    run = (await db.execute(select(PipelineRun).where(PipelineRun.id == run_id))).scalar_one_or_none()
+    if run is None:
+        raise HTTPException(404, "Pipeline run not found")
+    return await _materialise_run(db, run)
+
+
+@pipeline_run_router.post("/{run_id}/cancel", response_model=PipelineRunResponse)
+async def cancel_run(
+    run_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
+    db: AsyncSession = Depends(get_db),
+):
+    """Cancel a queued / in-flight run. Cascades to all non-terminal queue
+    entries; in-flight prints continue on the printer (operator must Stop)."""
+    run = (await db.execute(select(PipelineRun).where(PipelineRun.id == run_id))).scalar_one_or_none()
+    if run is None:
+        raise HTTPException(404, "Pipeline run not found")
+
+    if run.status in ("completed", "failed", "cancelled", "partial_failure"):
+        return await _materialise_run(db, run)
+
+    run.status = "cancelled"
+    run.completed_at = datetime.now(timezone.utc)
+    if not run.error_message:
+        run.error_message = "Cancelled by user"
+
+    job_rows = (await db.execute(select(PipelineJob).where(PipelineJob.pipeline_run_id == run.id))).scalars().all()
+    for job in job_rows:
+        if job.queue_entry_id:
+            queue_entry = (
+                await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == job.queue_entry_id))
+            ).scalar_one_or_none()
+            if queue_entry is not None and queue_entry.status in ("pending", "queued"):
+                queue_entry.status = "cancelled"
+        if job.status not in ("completed", "failed", "cancelled"):
+            job.status = "cancelled"
+            job.completed_at = datetime.now(timezone.utc)
+
+    await db.commit()
+    await db.refresh(run)
+    await _publish_run_event(db, run)
+    return await _materialise_run(db, run)
+
+
+@pipeline_run_router.post("/{run_id}/retry-failed", response_model=PipelineRunResponse, status_code=202)
+async def retry_failed(
+    run_id: int,
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
+    db: AsyncSession = Depends(get_db),
+):
+    """Create a new run with copies = (failed + cancelled count) from the
+    parent. Same pipeline, same source. Eligibility re-checked at run time
+    (it might pass this time — operator may have fixed the issue)."""
+    parent = (await db.execute(select(PipelineRun).where(PipelineRun.id == run_id))).scalar_one_or_none()
+    if parent is None:
+        raise HTTPException(404, "Pipeline run not found")
+    if parent.pipeline_id is None:
+        raise HTTPException(400, "Original pipeline was deleted; cannot retry")
+    if parent.source_library_file_id is None and parent.source_archive_id is None:
+        raise HTTPException(400, "Original source was deleted; cannot retry")
+
+    # Count the parent's failed + cancelled jobs.
+    parent_jobs = (
+        (await db.execute(select(PipelineJob).where(PipelineJob.pipeline_run_id == parent.id))).scalars().all()
+    )
+    fail_count = 0
+    for j in parent_jobs:
+        queue_entry = None
+        if j.queue_entry_id:
+            queue_entry = (
+                await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == j.queue_entry_id))
+            ).scalar_one_or_none()
+        live = _compute_job_status(j.status, queue_entry)
+        if live in ("failed", "cancelled"):
+            fail_count += 1
+
+    if fail_count == 0:
+        raise HTTPException(400, "No failed copies to retry")
+
+    # Build the request payload the same way the user would have via /run.
+    body = PipelineRunCreateRequest(
+        source_library_file_id=parent.source_library_file_id,
+        source_archive_id=parent.source_archive_id,
+        copies=fail_count,
+        force=True,  # operator already accepted eligibility on the parent
+    )
+
+    # Reuse the run_pipeline route logic via a direct call — keeps the
+    # orchestration single-sourced. The result inherits parent_run_id.
+    new_run_response = await run_pipeline(parent.pipeline_id, body, current_user=current_user, db=db)
+
+    # Stamp parent_run_id on the freshly-created run.
+    new_row = (await db.execute(select(PipelineRun).where(PipelineRun.id == new_run_response.id))).scalar_one_or_none()
+    if new_row is not None:
+        new_row.parent_run_id = parent.id
+        await db.commit()
+        await db.refresh(new_row)
+        return await _materialise_run(db, new_row)
+    return new_run_response

+ 16 - 27
backend/app/api/routes/print_queue.py

@@ -16,7 +16,6 @@ from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_owners
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
-from backend.app.core.tasks import spawn_background_task
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile
 from backend.app.models.print_batch import PrintBatch
@@ -187,6 +186,8 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         "timelapse": item.timelapse,
         "use_ams": item.use_ams,
         "nozzle_offset_cali": item.nozzle_offset_cali,
+        "preheat_override": item.preheat_override,
+        "preheat_chamber_target_override": item.preheat_chamber_target_override,
         "status": item.status,
         "started_at": item.started_at,
         "completed_at": item.completed_at,
@@ -634,6 +635,8 @@ async def add_to_queue(
             timelapse=data.timelapse,
             use_ams=data.use_ams,
             nozzle_offset_cali=data.nozzle_offset_cali,
+            preheat_override=data.preheat_override,
+            preheat_chamber_target_override=data.preheat_chamber_target_override,
             gcode_injection=data.gcode_injection,
             cleanup_library_after_dispatch=data.cleanup_library_after_dispatch,
             project_id=data.project_id,
@@ -1268,9 +1271,7 @@ async def stop_queue_item(
     holding only _OWN saw the Stop button in the queue UI but got 403 on click.
     """
 
-    from backend.app.models.smart_plug import SmartPlug
     from backend.app.services.printer_manager import printer_manager
-    from backend.app.services.tasmota import tasmota_service
 
     user, can_modify_all = auth_result
 
@@ -1319,33 +1320,21 @@ async def stop_queue_item(
     item.error_message = "Stopped by user" if stop_sent else "Stopped by user (printer was offline)"
     await db.commit()
 
-    # Get smart plug info if auto-off is enabled
-    plug_ip = None
-    if auto_off_after:
-        result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
-        plug = result.scalar_one_or_none()
-        if plug and plug.enabled:
-            plug_ip = plug.ip_address
-
     logger.info("Stopped printing queue item %s (stop command sent: %s)", item_id, stop_sent)
 
-    # Schedule background task for cooldown + power off
-    if plug_ip:
-
-        async def cooldown_and_poweroff():
-            logger.info("Auto-off: Waiting for printer %s to cool down before power off...", printer_id)
-            await printer_manager.wait_for_cooldown(printer_id, target_temp=50.0, timeout=600)
-            # Re-fetch plug since we're in a new async context
-            from backend.app.core.database import async_session
-
-            async with async_session() as new_db:
-                result = await new_db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
-                plug = result.scalar_one_or_none()
-                if plug and plug.enabled:
-                    logger.info("Auto-off: Powering off printer %s", printer_id)
-                    await tasmota_service.turn_off(plug)
+    # Schedule power-off if the queue item opted in. Delegates to the smart-plug
+    # manager so the off honours each plug's configured strategy (time delay or
+    # temperature threshold), is cancelled if the printer starts printing again,
+    # and never cuts power on a loaded print (#1890). Previously an inline block
+    # hardcoded a 50°C / 600s cooldown wait and powered off on the timeout
+    # regardless of print state.
+    if auto_off_after:
+        from backend.app.services.smart_plug_manager import smart_plug_manager
 
-        spawn_background_task(cooldown_and_poweroff(), name=f"queue-cooldown-poweroff-{printer_id}")
+        try:
+            await smart_plug_manager.schedule_off_after_queue_job(printer_id, db)
+        except Exception as e:
+            logger.warning("Auto-off: Failed to schedule power-off for printer %s: %s", printer_id, e)
 
     return {"message": "Print stopped" if stop_sent else "Queue item cancelled (printer was offline)"}
 

+ 15 - 12
backend/app/api/routes/printers.py

@@ -3824,14 +3824,17 @@ async def execute_hms_action(
     # command. publish() success is NOT the same as printer-ack: Bambu's
     # firmware silently rejects malformed HMS commands at QoS 1 (the broker
     # ACKs the publish, but the printer drops it). Verified end-to-end against
-    # a live H2D — see #1830 §(3). We sample (gcode_state, hms_errors length)
-    # because every accepted HMS action mutates at least one of them.
+    # a live H2D — see #1830 §(3).
     #
-    # PrinterState.state carries the MQTT `gcode_state` value verbatim (see
-    # bambu_mqtt.py line 2144); the raw `print_error` int isn't preserved on
-    # state, only the derived HMSError entries are.
-    pre_gcode = client.state.state
-    pre_hms_count = len(client.state.hms_errors)
+    # We probe `_last_message_time` (bumped on every MQTT push) rather than a
+    # (gcode_state, hms_errors-length) diff. The old diff missed the
+    # wrong-plate IGNORE_RESUME case where the printer briefly resumes and
+    # re-pauses with the same fault inside the 2.5s window: both fields
+    # round-trip to their pre-publish values → false 502 even though the
+    # firmware fully ack'd the resume. Every accepted command triggers a
+    # pushall response within ~100-500ms, so a fresh inbound message after
+    # the publish is the robust ack signal.
+    pre_last_message = client._last_message_time
 
     success = client.execute_hms_action(body.print_error, body.action, body.job_id)
     if not success:
@@ -3845,12 +3848,12 @@ async def execute_hms_action(
     # coroutine is awaiting.
     await asyncio.sleep(HMS_ACTION_ACK_WAIT_SECONDS)
 
-    acked = client.state.state != pre_gcode or len(client.state.hms_errors) != pre_hms_count
+    acked = client._last_message_time > pre_last_message
     if not acked:
-        # Publish succeeded but the printer's state didn't move. Almost always
-        # firmware-side silent rejection (err mismatch, command/state mismatch).
-        # 502 makes it visible at the UI instead of the 200-but-broken loop
-        # #1830 reported.
+        # Publish succeeded but the printer sent nothing back. Almost always
+        # firmware-side silent rejection (err mismatch, command/state mismatch)
+        # or a dropped MQTT route. 502 makes it visible at the UI instead of
+        # the 200-but-broken loop #1830 reported.
         raise HTTPException(502, "Printer did not acknowledge HMS action within 2.5s")
 
     return {"success": True, "message": "HMS action executed"}

+ 4 - 0
backend/app/api/routes/settings.py

@@ -140,6 +140,7 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
             "ldap_enabled",
             "ldap_auto_provision",
             "local_login_enabled",
+            "preheat_enabled",
         ]:
             settings_dict[setting.key] = setting.value.lower() == "true"
         elif setting.key in [
@@ -164,6 +165,9 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
             "stagger_interval_minutes",
             "forecast_global_lead_time_days",
             "session_max_hours",
+            "pipeline_max_copies",
+            "preheat_max_wait_seconds",
+            "preheat_soak_seconds",
         ]:
             settings_dict[setting.key] = int(setting.value)
         elif setting.key == "default_printer_id":

+ 199 - 0
backend/app/api/routes/slicer_pipelines.py

@@ -0,0 +1,199 @@
+"""API routes for Slicer Pipelines (#1425, PR A — definitions only).
+
+A pipeline bundles printer / process / filament(s) / bed-type picks so the
+SliceModal can apply them in one click. PR A surfaces only CRUD + an
+``apply`` helper that returns the pipeline as the four ``PresetRef`` slots a
+``SliceRequest`` expects. PR B adds single-target dispatch; PR C adds
+multi-copy fanout and the run dashboard.
+"""
+
+import json
+import logging
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.slicer_pipeline import SlicerPipeline
+from backend.app.models.user import User
+from backend.app.schemas.slicer import PresetRef
+from backend.app.schemas.slicer_pipeline import (
+    SlicerPipelineCreate,
+    SlicerPipelineListResponse,
+    SlicerPipelineResponse,
+    SlicerPipelineUpdate,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/slicer-pipelines", tags=["Slicer Pipelines"])
+
+
+def _to_response(row: SlicerPipeline) -> SlicerPipelineResponse:
+    """Materialise the JSON filament list back into PresetRef objects so the
+    response shape matches the create/update input shape exactly."""
+    try:
+        raw = json.loads(row.filament_presets_json) if row.filament_presets_json else []
+    except (json.JSONDecodeError, TypeError):
+        # Row was hand-edited or corrupted — return an empty list rather than
+        # 500ing on a list endpoint. Edit/run paths will surface the problem.
+        logger.warning("slicer_pipeline %d has invalid filament_presets_json", row.id)
+        raw = []
+    filament_presets = [PresetRef(**f) for f in raw if isinstance(f, dict)]
+
+    return SlicerPipelineResponse(
+        id=row.id,
+        name=row.name,
+        description=row.description,
+        printer_preset=PresetRef(source=row.printer_preset_source, id=row.printer_preset_id),
+        process_preset=PresetRef(source=row.process_preset_source, id=row.process_preset_id),
+        filament_presets=filament_presets,
+        bed_type=row.bed_type,
+        target_kind=row.target_kind,  # type: ignore[arg-type]
+        target_printer_id=row.target_printer_id,
+        target_model_class=row.target_model_class,
+        fanout_strategy=row.fanout_strategy,  # type: ignore[arg-type]
+        created_by=row.created_by,
+        created_at=row.created_at,
+        updated_at=row.updated_at,
+    )
+
+
+@router.get("/", response_model=SlicerPipelineListResponse)
+async def list_pipelines(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """List all pipelines, newest first. Soft-deleted rows are hidden."""
+    result = await db.execute(
+        select(SlicerPipeline).where(SlicerPipeline.is_deleted.is_(False)).order_by(SlicerPipeline.id.desc())
+    )
+    rows = result.scalars().all()
+    return SlicerPipelineListResponse(pipelines=[_to_response(r) for r in rows])
+
+
+@router.post("/", response_model=SlicerPipelineResponse, status_code=201)
+async def create_pipeline(
+    data: SlicerPipelineCreate,
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Create a new pipeline."""
+    row = SlicerPipeline(
+        name=data.name.strip(),
+        description=data.description,
+        printer_preset_source=data.printer_preset.source,
+        printer_preset_id=data.printer_preset.id,
+        process_preset_source=data.process_preset.source,
+        process_preset_id=data.process_preset.id,
+        filament_presets_json=json.dumps([f.model_dump() for f in data.filament_presets]),
+        bed_type=data.bed_type,
+        created_by=current_user.id if current_user else None,
+    )
+    db.add(row)
+    await db.commit()
+    await db.refresh(row)
+    return _to_response(row)
+
+
+@router.get("/{pipeline_id}", response_model=SlicerPipelineResponse)
+async def get_pipeline(
+    pipeline_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """Read one pipeline by id."""
+    result = await db.execute(
+        select(SlicerPipeline).where(
+            SlicerPipeline.id == pipeline_id,
+            SlicerPipeline.is_deleted.is_(False),
+        )
+    )
+    row = result.scalar_one_or_none()
+    if not row:
+        raise HTTPException(404, "Pipeline not found")
+    return _to_response(row)
+
+
+@router.put("/{pipeline_id}", response_model=SlicerPipelineResponse)
+async def update_pipeline(
+    pipeline_id: int,
+    data: SlicerPipelineUpdate,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Update a pipeline. Only fields present in the payload are written."""
+    result = await db.execute(
+        select(SlicerPipeline).where(
+            SlicerPipeline.id == pipeline_id,
+            SlicerPipeline.is_deleted.is_(False),
+        )
+    )
+    row = result.scalar_one_or_none()
+    if not row:
+        raise HTTPException(404, "Pipeline not found")
+
+    if data.name is not None:
+        row.name = data.name.strip()
+    if data.description is not None:
+        row.description = data.description
+    if data.printer_preset is not None:
+        row.printer_preset_source = data.printer_preset.source
+        row.printer_preset_id = data.printer_preset.id
+    if data.process_preset is not None:
+        row.process_preset_source = data.process_preset.source
+        row.process_preset_id = data.process_preset.id
+    if data.filament_presets is not None:
+        row.filament_presets_json = json.dumps([f.model_dump() for f in data.filament_presets])
+    if data.bed_type is not None:
+        row.bed_type = data.bed_type
+
+    # PR B target binding. The schema accepts ``target_kind=specific_printer``
+    # without ``target_printer_id`` (operator may be saving the kind first),
+    # but a 'specific_printer' kind with a printer_id of 0 is rejected since
+    # printer ids are always positive — guard against the JSON-coerced
+    # empty-string case from the frontend.
+    if data.target_kind is not None:
+        row.target_kind = data.target_kind
+    if data.target_printer_id is not None:
+        # ``target_printer_id=0`` from the frontend means "clear the target"
+        # (the <option value=""> case). Anything positive must reference an
+        # actual printer row.
+        if data.target_printer_id == 0:
+            row.target_printer_id = None
+        else:
+            row.target_printer_id = data.target_printer_id
+    # PR C — class targeting + fanout strategy. Empty string from the frontend
+    # also clears the class (radio toggled away).
+    if data.target_model_class is not None:
+        row.target_model_class = data.target_model_class or None
+    if data.fanout_strategy is not None:
+        row.fanout_strategy = data.fanout_strategy
+
+    await db.commit()
+    await db.refresh(row)
+    return _to_response(row)
+
+
+@router.delete("/{pipeline_id}", status_code=204)
+async def delete_pipeline(
+    pipeline_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Soft-delete a pipeline (sets is_deleted=True so PR B+ run history can
+    still resolve pipeline metadata)."""
+    result = await db.execute(
+        select(SlicerPipeline).where(
+            SlicerPipeline.id == pipeline_id,
+            SlicerPipeline.is_deleted.is_(False),
+        )
+    )
+    row = result.scalar_one_or_none()
+    if not row:
+        raise HTTPException(404, "Pipeline not found")
+    row.is_deleted = True
+    await db.commit()

+ 16 - 0
backend/app/api/routes/websocket.py

@@ -19,10 +19,12 @@ from __future__ import annotations
 import logging
 
 from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
+from sqlalchemy import select
 
 from backend.app.core.auth import is_auth_enabled, verify_websocket_token
 from backend.app.core.database import async_session
 from backend.app.core.websocket import ws_manager
+from backend.app.models.user import User
 from backend.app.services.printer_manager import printer_manager, printer_state_to_dict
 
 logger = logging.getLogger(__name__)
@@ -86,6 +88,20 @@ async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(def
     # ``broadcast_to_principal()`` helper can filter on it without
     # touching every call site.
     websocket.state.bambuddy_principal = principal
+    # Resolve principal username → User.id once at connect so
+    # ``ws_manager.broadcast_to_user()`` can filter without re-querying
+    # per message. Auth-disabled path keeps None (broadcast_to_user fans
+    # out to all when target is None — matches the legacy single-user
+    # toast behaviour). API-keyed principal is empty string → None.
+    principal_user_id: int | None = None
+    if principal:
+        try:
+            async with async_session() as db:
+                row = await db.execute(select(User.id).where(User.username == principal))
+                principal_user_id = row.scalar_one_or_none()
+        except Exception:  # SEC-AUTH-EXC: resolution failure is non-fatal — degrades to no per-user routing
+            logger.warning("WebSocket principal resolve failed for %s", principal, exc_info=True)
+    websocket.state.bambuddy_principal_user_id = principal_user_id
     logger.info("WebSocket client connected")
 
     try:

+ 8 - 0
backend/app/cli.py

@@ -70,6 +70,14 @@ async def kiosk_bootstrap(
             # commands via the /spoolbuddy/* routes — all gated by
             # can_manage_inventory now, so the bundled key must opt in.
             can_manage_inventory=True,
+            # Kiosk doesn't need maintenance writes; keep it False so the
+            # bundled key stays minimally scoped (#1832 follow-up).
+            can_manage_maintenance=False,
+            # Kiosk doesn't manage print archives either — keep it minimally
+            # scoped (#1888).
+            can_manage_archives=False,
+            # Kiosk doesn't manage projects — keep it minimally scoped (#1893).
+            can_manage_projects=False,
             printer_ids=None,
             enabled=True,
             expires_at=None,

+ 69 - 20
backend/app/core/auth.py

@@ -49,15 +49,17 @@ logger = logging.getLogger(__name__)
 # entries also satisfy "not in the allowlist", so they fail closed regardless.
 #
 # Mapping rationale (see wiki/features/api-keys.md):
-#   can_read_status     → every ``*_READ`` + camera + stats + system + websocket
-#   can_queue           → queue write ops + archive reprint
-#   can_control_printer → physical printer + smart-plug control
-#   can_manage_library  → library upload/own + MakerWorld import (separate
-#                         trust level from queue management, hence its own flag)
-#   admin-only          → unmapped (default-deny); covers all create/update/
-#                         delete of admin resources, settings writes, user/
-#                         group/api-key/backup admin ops, discovery scan,
-#                         cloud auth, library ALL-ownership perms, purges
+#   can_read_status       → every ``*_READ`` + camera + stats + system + websocket
+#   can_queue             → queue write ops + archive reprint
+#   can_control_printer   → physical printer + smart-plug control
+#   can_manage_library    → library upload/own + MakerWorld import (separate
+#                           trust level from queue management, hence its own flag)
+#   can_manage_inventory  → spool/catalog/forecast writes + SpoolBuddy kiosk writes
+#   can_manage_maintenance→ per-printer maintenance log/reset + type-catalog CRUD
+#   admin-only            → unmapped (default-deny); covers all create/update/
+#                           delete of admin resources, settings writes, user/
+#                           group/api-key/backup admin ops, discovery scan,
+#                           cloud auth, library ALL-ownership perms, purges
 _APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str] = {
     # can_read_status — read-only access to status, history, and configuration
     Permission.PRINTERS_READ: "can_read_status",
@@ -138,6 +140,44 @@ _APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str] = {
     Permission.INVENTORY_UPDATE: "can_manage_inventory",
     Permission.INVENTORY_DELETE: "can_manage_inventory",
     Permission.INVENTORY_FORECAST_WRITE: "can_manage_inventory",
+    # can_manage_maintenance — carved out of the admin denylist so HA-style
+    # automations can log "cleaned nozzle" / reset a maintenance counter via
+    # `POST /maintenance/items/{item_id}/perform` without granting broader
+    # printer control or settings write (#1832 follow-up). Also covers the
+    # per-printer maintenance CRUD (assign/remove items, edit intervals) and
+    # the type-catalog CRUD — the type catalog is a config surface (system
+    # types are auto-seeded, custom types are user-defined), so grouping it
+    # with the item writes matches the operator mental model of "keys that
+    # log maintenance can also manage what gets tracked." MAINTENANCE_READ
+    # stays under can_read_status.
+    Permission.MAINTENANCE_CREATE: "can_manage_maintenance",
+    Permission.MAINTENANCE_UPDATE: "can_manage_maintenance",
+    Permission.MAINTENANCE_DELETE: "can_manage_maintenance",
+    # can_manage_archives — print-history curation. Carved out of the admin
+    # denylist so automations can prune old prints via API key (#1888): the
+    # archive delete/update routes gate on
+    # ``require_ownership_permission(ARCHIVES_*_ALL, ARCHIVES_*_OWN)``, which
+    # resolves the ALL permission for API keys (no per-row ownership identity,
+    # same as can_queue / can_manage_library), so OWN and ALL map to the same
+    # scope. ARCHIVES_PURGE stays admin-only (see denylist) as a genuinely
+    # destructive op that drops the stats contribution, mirroring LIBRARY_PURGE.
+    # ARCHIVES_REPRINT_* stays under can_queue (it enqueues a print).
+    Permission.ARCHIVES_CREATE: "can_manage_archives",
+    Permission.ARCHIVES_UPDATE_OWN: "can_manage_archives",
+    Permission.ARCHIVES_UPDATE_ALL: "can_manage_archives",
+    Permission.ARCHIVES_DELETE_OWN: "can_manage_archives",
+    Permission.ARCHIVES_DELETE_ALL: "can_manage_archives",
+    # can_manage_projects — project curation. Carved out of the admin denylist
+    # so automations can create projects and batch-add archives via API key
+    # (#1893). The project mutation routes gate on plain
+    # ``RequirePermissionIfAuthEnabled(Permission.PROJECTS_*)`` (no OWN/ALL
+    # ownership split — projects have no per-row ownership permission), so the
+    # three CRUD permissions map directly to the one scope. Membership edits
+    # (e.g. add-archives-to-project) gate on PROJECTS_UPDATE, so they're covered.
+    # PROJECTS_READ stays under can_read_status (unchanged).
+    Permission.PROJECTS_CREATE: "can_manage_projects",
+    Permission.PROJECTS_UPDATE: "can_manage_projects",
+    Permission.PROJECTS_DELETE: "can_manage_projects",
     # can_access_cloud — narrow opt-in scope, gated by the router-level
     # ``_cloud_api_key_gate`` and additionally enforced here so the route-
     # level ``cloud_caller(Permission.CLOUD_AUTH)`` dep also fails closed
@@ -185,11 +225,13 @@ _APIKEY_DENIED_PERMISSIONS: frozenset[Permission] = frozenset(
         Permission.PRINTERS_CREATE,
         Permission.PRINTERS_UPDATE,
         Permission.PRINTERS_DELETE,
-        Permission.ARCHIVES_CREATE,
-        Permission.ARCHIVES_UPDATE_OWN,
-        Permission.ARCHIVES_UPDATE_ALL,
-        Permission.ARCHIVES_DELETE_OWN,
-        Permission.ARCHIVES_DELETE_ALL,
+        # ARCHIVES_CREATE / _UPDATE_OWN / _UPDATE_ALL / _DELETE_OWN /
+        # _DELETE_ALL moved to the allowlist under `can_manage_archives`
+        # (#1888) — split between allow/deny made the whole archive-management
+        # surface unreachable for API keys via `require_ownership_permission`
+        # (same regression class as the library/maintenance carve-outs in
+        # #1832). ARCHIVES_PURGE stays denied as a genuinely destructive op
+        # that drops the print's stats contribution.
         Permission.ARCHIVES_PURGE,
         # LIBRARY_UPDATE_ALL / LIBRARY_DELETE_ALL moved to the allowlist
         # under `can_manage_library` (#1832) — split between allow/deny made
@@ -197,15 +239,15 @@ _APIKEY_DENIED_PERMISSIONS: frozenset[Permission] = frozenset(
         # `require_ownership_permission`. Purge stays denied as a genuinely
         # destructive op.
         Permission.LIBRARY_PURGE,
-        Permission.PROJECTS_CREATE,
-        Permission.PROJECTS_UPDATE,
-        Permission.PROJECTS_DELETE,
+        # PROJECTS_CREATE / _UPDATE / _DELETE moved to the allowlist under
+        # `can_manage_projects` (#1893) — they were denied for every API key,
+        # making the project-management surface (create, add-archives, delete)
+        # unreachable, same regression class as the archives/library carve-outs.
         Permission.FILAMENTS_CREATE,
         Permission.FILAMENTS_UPDATE,
         Permission.FILAMENTS_DELETE,
-        Permission.MAINTENANCE_CREATE,
-        Permission.MAINTENANCE_UPDATE,
-        Permission.MAINTENANCE_DELETE,
+        # MAINTENANCE_CREATE / MAINTENANCE_UPDATE / MAINTENANCE_DELETE moved
+        # to the allowlist under `can_manage_maintenance` (#1832 follow-up).
         Permission.KPROFILES_CREATE,
         Permission.KPROFILES_UPDATE,
         Permission.KPROFILES_DELETE,
@@ -222,6 +264,13 @@ _APIKEY_DENIED_PERMISSIONS: frozenset[Permission] = frozenset(
         Permission.SMART_PLUGS_DELETE,
         # Network scanning — operator only (no API-key scope for this).
         Permission.DISCOVERY_SCAN,
+        # Slicer Pipelines (#1425) — admin authoring + the print-spending Run
+        # action. PR A only ships CRUD; PR B / PR C may move PIPELINES_RUN onto
+        # `can_queue` (it queues prints) once the run dispatch lands. PR A keeps
+        # all three denied so they fail closed for any API-key surface.
+        Permission.PIPELINES_READ,
+        Permission.PIPELINES_WRITE,
+        Permission.PIPELINES_RUN,
     }
 )
 

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

@@ -6,7 +6,7 @@ from pathlib import Path
 from pydantic_settings import BaseSettings
 
 # Application version - single source of truth
-APP_VERSION = "0.2.4.8"
+APP_VERSION = "0.2.4.9"
 GITHUB_REPO = "maziggy/bambuddy"
 BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")
 

+ 133 - 49
backend/app/core/database.py

@@ -189,6 +189,7 @@ async def init_db():
         oidc_provider,
         orca_base_cache,
         pending_upload,
+        pipeline_run,
         print_batch,
         print_log,
         print_queue,
@@ -198,6 +199,7 @@ async def init_db():
         project_bom,
         settings,
         shopping_list,
+        slicer_pipeline,
         slot_preset,
         smart_plug,
         smart_plug_energy_snapshot,
@@ -668,6 +670,23 @@ async def run_migrations(conn):
     """
     from sqlalchemy import text
 
+    # Migration: Add parent_run_id column to pipeline_runs (#1425 PR C).
+    # Links a retry-failed run back to its parent so the dashboard can show
+    # "Retry of run #N" inline. Idempotent on both SQLite and Postgres.
+    await _safe_execute(
+        conn,
+        "ALTER TABLE pipeline_runs ADD COLUMN parent_run_id INTEGER REFERENCES pipeline_runs(id) ON DELETE SET NULL",
+    )
+
+    # Migration: Add source_archive_id column to pipeline_runs (#1425 PR B follow-up).
+    # Allows a pipeline run to source from an archive's source 3MF in addition
+    # to a library file. Idempotent — _safe_execute swallows the "already exists"
+    # case on both SQLite and Postgres.
+    await _safe_execute(
+        conn,
+        "ALTER TABLE pipeline_runs ADD COLUMN source_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL",
+    )
+
     # Migration: Add is_favorite column to print_archives
     await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN is_favorite BOOLEAN DEFAULT 0")
 
@@ -1159,6 +1178,21 @@ async def run_migrations(conn):
     else:
         await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzle_offset_cali BOOLEAN DEFAULT TRUE")
 
+    # 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
+    # target column overrides the filament-map derivation when not null.
+    # Existing rows default to 'inherit' + NULL so behaviour is unchanged for
+    # in-flight queues.
+    await _safe_execute(
+        conn,
+        "ALTER TABLE print_queue ADD COLUMN preheat_override VARCHAR(10) DEFAULT 'inherit'",
+    )
+    await _safe_execute(
+        conn,
+        "ALTER TABLE print_queue ADD COLUMN preheat_chamber_target_override INTEGER",
+    )
+
     # Migration: Add library_file_id column to print_queue and make archive_id nullable
     # This allows queue items to reference library files directly (archive created at print start)
     try:
@@ -2489,6 +2523,63 @@ async def run_migrations(conn):
         async with conn.begin_nested():
             await conn.execute(text("UPDATE api_keys SET can_manage_inventory = can_queue"))
 
+    # #1832 follow-up: carve maintenance CRUD out of the admin denylist so
+    # HA-style automations can log "cleaned nozzle" via API key. Distinct
+    # from the two backfills above: MAINTENANCE_CREATE / _UPDATE / _DELETE
+    # were EXPLICITLY denied for every API key under the pre-migration model
+    # (they were on ``_APIKEY_DENIED_PERMISSIONS``), so no existing
+    # integration relies on them. Column default TRUE matches the "safe,
+    # on-by-default" pattern for keys created via the UI going forward;
+    # existing rows backfill to FALSE so the upgrade path does not silently
+    # widen scope for keys created before this flag existed. Users opt in
+    # via Settings → API Keys per key.
+    column_existed = await _api_keys_column_exists(conn, "can_manage_maintenance")
+    await _safe_execute(
+        conn,
+        "ALTER TABLE api_keys ADD COLUMN can_manage_maintenance BOOLEAN DEFAULT TRUE",
+    )
+    if not column_existed:
+        async with conn.begin_nested():
+            await conn.execute(text("UPDATE api_keys SET can_manage_maintenance = FALSE"))
+
+    # #1888: carve archive CRUD (create/update/delete — NOT purge) out of the
+    # admin denylist so automations can prune old prints via API key. Same
+    # shape and reasoning as can_manage_maintenance above: ARCHIVES_CREATE /
+    # _UPDATE_* / _DELETE_* were EXPLICITLY denied for every API key under the
+    # pre-migration model (they were on ``_APIKEY_DENIED_PERMISSIONS``), so no
+    # existing integration relies on them. Column default TRUE for keys created
+    # via the UI going forward; existing rows backfill to FALSE so the upgrade
+    # path does not silently widen scope for keys created before this flag
+    # existed. Users opt in via Settings → API Keys per key. BOOLEAN is valid
+    # on both SQLite and Postgres, so no dialect branch is needed.
+    column_existed = await _api_keys_column_exists(conn, "can_manage_archives")
+    await _safe_execute(
+        conn,
+        "ALTER TABLE api_keys ADD COLUMN can_manage_archives BOOLEAN DEFAULT TRUE",
+    )
+    if not column_existed:
+        async with conn.begin_nested():
+            await conn.execute(text("UPDATE api_keys SET can_manage_archives = FALSE"))
+
+    # #1893: carve project CRUD + membership (create/update/delete, add-archives)
+    # out of the admin denylist so automations can manage projects via API key.
+    # Identical shape and reasoning to can_manage_archives above: PROJECTS_CREATE
+    # / _UPDATE / _DELETE were EXPLICITLY denied for every API key under the
+    # pre-migration model (they were on ``_APIKEY_DENIED_PERMISSIONS``), so no
+    # existing integration relies on them. Column default TRUE for keys created
+    # via the UI going forward; existing rows backfill to FALSE so the upgrade
+    # path does not silently widen scope for keys created before this flag
+    # existed. Users opt in via Settings → API Keys per key. BOOLEAN is valid on
+    # both SQLite and Postgres, so no dialect branch is needed.
+    column_existed = await _api_keys_column_exists(conn, "can_manage_projects")
+    await _safe_execute(
+        conn,
+        "ALTER TABLE api_keys ADD COLUMN can_manage_projects BOOLEAN DEFAULT TRUE",
+    )
+    if not column_existed:
+        async with conn.begin_nested():
+            await conn.execute(text("UPDATE api_keys SET can_manage_projects = FALSE"))
+
     # Migration: Soft-delete column for trash bin (Issue #1008). Indexed so the
     # sweeper's "SELECT ... WHERE deleted_at < cutoff" and the trash list's
     # "WHERE deleted_at IS NOT NULL" stay cheap as the table grows.
@@ -3326,7 +3417,7 @@ async def seed_default_groups():
 
     from sqlalchemy import select
 
-    from backend.app.core.permissions import DEFAULT_GROUPS
+    from backend.app.core.permissions import ALL_PERMISSIONS, DEFAULT_GROUPS
     from backend.app.models.group import Group
     from backend.app.models.user import User
 
@@ -3479,63 +3570,31 @@ async def seed_default_groups():
                 group.permissions = perms
         await session.commit()
 
-        # Backfill library:purge + archives:purge for the Administrators group
-        # on existing installs. Both permissions were added after Administrators
-        # was first seeded, so upgrading users miss them even though the default
-        # config (ALL_PERMISSIONS) includes them for fresh installs.
-        result = await session.execute(select(Group).where(Group.name == "Administrators"))
-        admin_group = result.scalar_one_or_none()
-        if admin_group and admin_group.permissions is not None:
-            perms = list(admin_group.permissions)
-            added = False
-            for new_perm in ("library:purge", "archives:purge"):
-                if new_perm not in perms:
-                    perms.append(new_perm)
-                    added = True
-                    logger.info("Added %s to Administrators group (backfill)", new_perm)
-            if added:
-                admin_group.permissions = perms
-        await session.commit()
-
-        # Backfill the read flag set for the Administrators group on existing
-        # installs (maziggy/bambuddy-security #2). Two layers:
-        #
-        # (a) New OWN/ALL splits — `archives:read_own` etc. Fresh installs get
-        #     these via ALL_PERMISSIONS; upgrades need the explicit backfill
-        #     so admin's permission set matches a fresh install's.
-        #
-        # (b) Legacy `archives:read` / `library:read` / `queue:read`. The
-        #     frontend still gates download / preview UI on these LEGACY
-        #     strings (see ArchivesPage / FileManagerPage), so admin needs
-        #     them retained even though the new API uses the OWN/ALL split.
-        #     The PERMISSION_MIGRATION_ALL map deliberately doesn't rename
-        #     read flags for admin — this backfill ensures they're present
-        #     even if they were stripped by hand or by an older migration.
+        # Backfill: sync the Administrators system group to ALL_PERMISSIONS.
+        # Administrators' contract is full access to every feature — fresh
+        # installs get that via DEFAULT_GROUPS["Administrators"]["permissions"]
+        # = ALL_PERMISSIONS. Upgrading installs would otherwise stay frozen at
+        # whatever permission set existed when they were first seeded, so a
+        # newly-added Permission enum member silently leaves admins gated out
+        # of the feature it controls.
         #
-        # Also includes orca_cloud:auth for parity with fresh-install
-        # behaviour (ALL_PERMISSIONS covers it; backfill makes sure an
-        # admin role that's been customised since seed still has it).
+        # Generalises the previous one-off admin backfills (library:purge,
+        # archives:purge, the OWN/ALL read-flag set + legacy read flags,
+        # orca_cloud:auth, printer_sensor_history:read, …): every current
+        # Permission enum value is appended to the admin group if missing.
+        # Additive only — never removes a permission an operator added by
+        # hand. Run AFTER the legacy-rename migration above so the renamed
+        # OWN/ALL variants land in the group before the sync sees them.
         result = await session.execute(select(Group).where(Group.name == "Administrators"))
         admin_group = result.scalar_one_or_none()
         if admin_group and admin_group.permissions is not None:
             perms = list(admin_group.permissions)
             added = False
-            for new_perm in (
-                "archives:read",
-                "archives:read_own",
-                "archives:read_all",
-                "library:read",
-                "library:read_own",
-                "library:read_all",
-                "queue:read",
-                "queue:read_own",
-                "queue:read_all",
-                "orca_cloud:auth",
-            ):
+            for new_perm in ALL_PERMISSIONS:
                 if new_perm not in perms:
                     perms.append(new_perm)
                     added = True
-                    logger.info("Added %s to Administrators group (backfill)", new_perm)
+                    logger.info("Added %s to Administrators group (ALL_PERMISSIONS sync)", new_perm)
             if added:
                 admin_group.permissions = perms
         await session.commit()
@@ -3594,6 +3653,31 @@ async def seed_default_groups():
                 group.permissions = perms
         await session.commit()
 
+        # Backfill pipeline permissions (#1425) for non-admin groups.
+        # Administrators is handled by the ALL_PERMISSIONS sync above.
+        #   - Operators: all three (matches fresh-install DEFAULT_GROUPS)
+        #   - Any other group with library:read_own or settings:read:
+        #     pipelines:read only
+        result = await session.execute(select(Group))
+        for group in result.scalars().all():
+            if not group.permissions or group.name == "Administrators":
+                continue
+            perms = list(group.permissions)
+            changed = False
+            if group.name == "Operators":
+                for new_perm in ("pipelines:read", "pipelines:write", "pipelines:run"):
+                    if new_perm not in perms:
+                        perms.append(new_perm)
+                        changed = True
+                        logger.info("Added %s to Operators group (backfill)", new_perm)
+            elif "pipelines:read" not in perms and ("library:read_own" in perms or "settings:read" in perms):
+                perms.append("pipelines:read")
+                changed = True
+                logger.info("Added pipelines:read to group '%s' (backfill)", group.name)
+            if changed:
+                group.permissions = perms
+        await session.commit()
+
         # Migrate existing users to groups if they're not already in any group
         if groups_created:
             # Refresh to get newly created groups

+ 16 - 0
backend/app/core/permissions.py

@@ -178,6 +178,11 @@ class Permission(StrEnum):
     GROUPS_UPDATE = "groups:update"
     GROUPS_DELETE = "groups:delete"
 
+    # Slicer Pipelines (#1425)
+    PIPELINES_READ = "pipelines:read"  # View pipeline definitions and run history
+    PIPELINES_WRITE = "pipelines:write"  # Create / edit / delete pipeline definitions
+    PIPELINES_RUN = "pipelines:run"  # Kick off a pipeline run (PR C); separate because spending filament is a different trust dimension than authoring the recipe
+
     # WebSocket connection
     WEBSOCKET_CONNECT = "websocket:connect"
 
@@ -337,6 +342,11 @@ PERMISSION_CATEGORIES = {
         Permission.GROUPS_UPDATE,
         Permission.GROUPS_DELETE,
     ],
+    "Slicer Pipelines": [
+        Permission.PIPELINES_READ,
+        Permission.PIPELINES_WRITE,
+        Permission.PIPELINES_RUN,
+    ],
     "WebSocket": [
         Permission.WEBSOCKET_CONNECT,
     ],
@@ -452,6 +462,10 @@ DEFAULT_GROUPS = {
             Permission.SYSTEM_READ.value,
             # Settings - read only
             Permission.SETTINGS_READ.value,
+            # Slicer Pipelines - full access
+            Permission.PIPELINES_READ.value,
+            Permission.PIPELINES_WRITE.value,
+            Permission.PIPELINES_RUN.value,
             # WebSocket
             Permission.WEBSOCKET_CONNECT.value,
         ],
@@ -483,6 +497,8 @@ DEFAULT_GROUPS = {
             Permission.STATS_READ.value,
             Permission.SYSTEM_READ.value,
             Permission.SETTINGS_READ.value,
+            # Slicer Pipelines - read only
+            Permission.PIPELINES_READ.value,
             Permission.WEBSOCKET_CONNECT.value,
             # MakerWorld browsing only (no import — that writes to library)
             Permission.MAKERWORLD_VIEW.value,

+ 112 - 0
backend/app/core/websocket.py

@@ -43,6 +43,42 @@ class ConnectionManager:
                 if conn in self.active_connections:
                     self.active_connections.remove(conn)
 
+    async def broadcast_to_user(self, user_id: int | None, message: dict[str, Any]):
+        """Send a message to every connection authenticated as the given user.
+
+        When ``user_id`` is None the message fans out to all connections —
+        this is the auth-disabled single-user path, where neither the queue
+        item's ``created_by_id`` nor the WS principal is set, and the
+        existing fan-out semantics are exactly what the user wants.
+
+        Per-user routing reads ``websocket.state.bambuddy_principal_user_id``
+        stamped at connect time (``routes/websocket.py``). Connections
+        without a stamped id are skipped on the targeted path so an
+        anonymous reader never receives another user's dispatch toast.
+        """
+        if user_id is None:
+            await self.broadcast(message)
+            return
+
+        if not self.active_connections:
+            return
+
+        data = json.dumps(message)
+        async with self._lock:
+            disconnected = []
+            for connection in self.active_connections:
+                conn_uid = getattr(connection.state, "bambuddy_principal_user_id", None)
+                if conn_uid != user_id:
+                    continue
+                try:
+                    await connection.send_text(data)
+                except Exception:
+                    disconnected.append(connection)
+
+            for conn in disconnected:
+                if conn in self.active_connections:
+                    self.active_connections.remove(conn)
+
     async def send_printer_status(self, printer_id: int, status: dict):
         """Send printer status update to all clients."""
         await self.broadcast(
@@ -91,6 +127,82 @@ class ConnectionManager:
             }
         )
 
+    async def send_queue_item_uploading(
+        self,
+        user_id: int | None,
+        queue_item_id: int,
+        printer_id: int,
+        printer_name: str | None,
+        file_name: str,
+        total_bytes: int,
+    ):
+        """Toast trigger: scheduler picked the item up, FTP upload starts."""
+        await self.broadcast_to_user(
+            user_id,
+            {
+                "type": "queue_item_uploading",
+                "queue_item_id": queue_item_id,
+                "printer_id": printer_id,
+                "printer_name": printer_name,
+                "file_name": file_name,
+                "total_bytes": total_bytes,
+            },
+        )
+
+    async def send_queue_item_upload_progress(
+        self,
+        user_id: int | None,
+        queue_item_id: int,
+        bytes_transferred: int,
+        total_bytes: int,
+    ):
+        """Toast update: throttled byte-level progress during the FTP upload."""
+        pct = int(round(100 * bytes_transferred / total_bytes)) if total_bytes else 0
+        await self.broadcast_to_user(
+            user_id,
+            {
+                "type": "queue_item_upload_progress",
+                "queue_item_id": queue_item_id,
+                "bytes_transferred": bytes_transferred,
+                "total_bytes": total_bytes,
+                "pct": pct,
+            },
+        )
+
+    async def send_queue_item_acked(
+        self,
+        user_id: int | None,
+        queue_item_id: int,
+        printer_id: int,
+    ):
+        """Toast trigger: watchdog confirmed the printer transitioned out of pre_state."""
+        await self.broadcast_to_user(
+            user_id,
+            {
+                "type": "queue_item_acked",
+                "queue_item_id": queue_item_id,
+                "printer_id": printer_id,
+            },
+        )
+
+    async def send_queue_item_failed(
+        self,
+        user_id: int | None,
+        queue_item_id: int,
+        printer_id: int | None,
+        reason: str,
+    ):
+        """Toast trigger: dispatch failed at any stage. Toast turns red, auto-dismisses."""
+        await self.broadcast_to_user(
+            user_id,
+            {
+                "type": "queue_item_failed",
+                "queue_item_id": queue_item_id,
+                "printer_id": printer_id,
+                "reason": reason,
+            },
+        )
+
     async def send_missing_spool_assignment(
         self,
         printer_id: int,

+ 34 - 35
backend/app/main.py

@@ -49,6 +49,7 @@ from backend.app.api.routes import (
     obico,
     orca_cloud,
     pending_uploads,
+    pipeline_runs,
     print_log,
     print_queue,
     printer_sensor_history,
@@ -56,6 +57,7 @@ from backend.app.api.routes import (
     projects,
     settings as settings_routes,
     slice_jobs,
+    slicer_pipelines,
     slicer_presets,
     smart_plugs,
     sponsor_prompt,
@@ -4331,38 +4333,19 @@ async def on_print_complete(printer_id: int, data: dict):
             except Exception:
                 pass  # Don't fail if notification fails
 
-            # Handle auto_off_after - power off printer if requested (after cooldown)
+            # Handle auto_off_after - power off printer if the queue item opted
+            # in. Delegates to the smart-plug manager so the off honours each
+            # plug's configured strategy (time delay or temperature threshold),
+            # is cancelled if the printer starts printing again, and never cuts
+            # power on a loaded print (#1890). Previously an inline block here
+            # hardcoded a 50°C / 600s cooldown wait and powered off on the
+            # timeout regardless of print state — cutting a touchscreen reprint.
             if queue_auto_off:
-                async with async_session() as db:
-                    result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
-                    plugs = list(result.scalars().all())
-                enabled_plugs = [p for p in plugs if p.enabled]
-                if enabled_plugs:
-                    logger.info("Auto-off requested for printer %s, waiting for cooldown...", printer_id)
-
-                    async def cooldown_and_poweroff(pid: int, plug_ids: list[int]):
-                        # Wait for nozzle to cool down
-                        await printer_manager.wait_for_cooldown(pid, target_temp=50.0, timeout=600)
-                        # Re-fetch plugs in new session and turn off each one
-                        async with async_session() as new_db:
-                            for plug_id in plug_ids:
-                                try:
-                                    result = await new_db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
-                                    p = result.scalar_one_or_none()
-                                    if p and p.enabled:
-                                        service = await smart_plug_manager.get_service_for_plug(p, new_db)
-                                        success = await service.turn_off(p)
-                                        if success:
-                                            logger.info("Powered off printer %s via smart plug '%s'", pid, p.name)
-                                        else:
-                                            logger.warning("Failed to power off plug '%s' for printer %s", p.name, pid)
-                                except Exception as e:
-                                    logger.warning("Failed to power off plug %s for printer %s: %s", plug_id, pid, e)
-
-                    spawn_background_task(
-                        cooldown_and_poweroff(printer_id, [p.id for p in enabled_plugs]),
-                        name=f"cooldown-poweroff-{printer_id}",
-                    )
+                try:
+                    async with async_session() as db:
+                        await smart_plug_manager.schedule_off_after_queue_job(printer_id, db)
+                except Exception as e:
+                    logger.warning("Failed to schedule queue auto-off for printer %s: %s", printer_id, e)
     except Exception as e:
         logging.getLogger(__name__).warning(f"Queue item update failed: {e}")
 
@@ -6038,8 +6021,23 @@ async def lifespan(app: FastAPI):
 
         await tl_layer_change(printer_id, layer_num)
 
-        # First layer complete notification (layer_num >= 2 means layer 1 is done)
-        if 2 <= layer_num <= 5 and not _first_layer_notified.get(printer_id, False):
+        # First layer complete notification (layer_num >= 2 means layer 1 is done).
+        # Gate on actual printing state — Bambu firmware ticks layer_num during
+        # the pre-print calibration sequence (homing / mesh-level / bed scan /
+        # nozzle clean), so a bare layer_num check can fire minutes before the
+        # first real extrusion. We require gcode_state == RUNNING and
+        # mc_print_sub_stage in (0 = "Printing", None) so calibration sub-stages
+        # (1, 9, 14, ...) are excluded. The window widens to [2, 10] because if
+        # the layer counter advanced past 2 during PREPARE, the next on_layer_change
+        # edge fires later; _first_layer_notified stays clear until we actually send
+        # so a deferred re-evaluation can win. See issue #1837.
+        if 2 <= layer_num <= 10 and not _first_layer_notified.get(printer_id, False):
+            client = printer_manager.get_client(printer_id)
+            state = client.state if client else None
+            if not state or state.state != "RUNNING":
+                return
+            if state.mc_print_sub_stage not in (None, 0):
+                return
             _first_layer_notified[printer_id] = True
             try:
                 async with async_session() as db:
@@ -6050,8 +6048,6 @@ async def lifespan(app: FastAPI):
                     if not printer:
                         return
                     printer_name = printer.name
-                    client = printer_manager.get_client(printer_id)
-                    state = client.state if client else None
                     filename = (state.subtask_name or state.gcode_file or "Unknown") if state else "Unknown"
                     total_layers = state.total_layers if state else 0
 
@@ -6750,6 +6746,9 @@ app.include_router(library.router, prefix=app_settings.api_prefix)
 app.include_router(library_tags.router, prefix=app_settings.api_prefix)
 app.include_router(library_trash.router, prefix=app_settings.api_prefix)
 app.include_router(slice_jobs.router, prefix=app_settings.api_prefix)
+app.include_router(slicer_pipelines.router, prefix=app_settings.api_prefix)
+app.include_router(pipeline_runs.pipeline_run_create_router, prefix=app_settings.api_prefix)
+app.include_router(pipeline_runs.pipeline_run_router, prefix=app_settings.api_prefix)
 app.include_router(slicer_presets.router, prefix=app_settings.api_prefix)
 app.include_router(archive_purge.router, prefix=app_settings.api_prefix)
 app.include_router(makerworld.router, prefix=app_settings.api_prefix)

+ 5 - 0
backend/app/models/__init__.py

@@ -18,11 +18,13 @@ from backend.app.models.notification_template import NotificationTemplate
 from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
 from backend.app.models.orca_base_cache import OrcaBaseProfile
 from backend.app.models.pending_upload import PendingUpload
+from backend.app.models.pipeline_run import PipelineJob, PipelineRun
 from backend.app.models.print_batch import PrintBatch
 from backend.app.models.printer import Printer
 from backend.app.models.printer_sensor_history import PrinterSensorHistory
 from backend.app.models.project import Project
 from backend.app.models.settings import Settings
+from backend.app.models.slicer_pipeline import SlicerPipeline
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
 from backend.app.models.sponsor_toast_state import SponsorToastState
@@ -69,6 +71,9 @@ __all__ = [
     "OIDCProvider",
     "UserOIDCLink",
     "OrcaBaseProfile",
+    "PipelineJob",
+    "PipelineRun",
+    "SlicerPipeline",
     "Spool",
     "SpoolKProfile",
     "SpoolAssignment",

+ 9 - 0
backend/app/models/api_key.py

@@ -36,6 +36,15 @@ class APIKey(Base):
     can_manage_inventory: Mapped[bool] = mapped_column(
         Boolean, default=True
     )  # Inventory write ops (incl. SpoolBuddy kiosk NFC/scale/system)
+    can_manage_maintenance: Mapped[bool] = mapped_column(
+        Boolean, default=True
+    )  # Log/reset per-printer maintenance, edit intervals, manage the type catalog (#1832 follow-up)
+    can_manage_archives: Mapped[bool] = mapped_column(
+        Boolean, default=True
+    )  # Create/update/delete print archives (not purge) (#1888)
+    can_manage_projects: Mapped[bool] = mapped_column(
+        Boolean, default=True
+    )  # Create/update/delete projects + manage membership (add archives) (#1893)
     can_access_cloud: Mapped[bool] = mapped_column(Boolean, default=False)  # Read /cloud/* on the owner's behalf
     # Narrowly-scoped settings write: only POST /settings/electricity-price.
     # Lets HA/Tibber-style automations push dynamic tariff updates without

+ 111 - 0
backend/app/models/pipeline_run.py

@@ -0,0 +1,111 @@
+"""Models for a Slicer Pipeline run (#1425 PR B).
+
+A PipelineRun is one "Run pipeline" click: slice the source file once with the
+pipeline's four preset slots, then enqueue a single print on the pipeline's
+pinned target printer (PR B = single-target dispatch). PR C extends this with
+copies > 1 and class targeting + fanout strategies.
+
+Status on a PipelineRun is mostly COMPUTED from the underlying slice_job
+(in-memory) + the linked queue_entry's state at read time — see
+``api/routes/pipeline_runs.py`` ``_compute_run_status`` for the rules. The
+``status`` column is the persisted snapshot used as a fallback / for filtering
+in list queries; it's updated on terminal transitions (slice failure, cancel,
+or queue-entry completion).
+"""
+
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+
+class PipelineRun(Base):
+    """One run-pipeline invocation. PR B always carries exactly one
+    PipelineJob (copies=1); PR C will allow N."""
+
+    __tablename__ = "pipeline_runs"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+
+    # Pipeline + source. ``ondelete='SET NULL'`` on both so run history survives
+    # the user soft-deleting a pipeline or removing the source library file.
+    pipeline_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("slicer_pipelines.id", ondelete="SET NULL"))
+    source_library_file_id: Mapped[int | None] = mapped_column(
+        Integer, ForeignKey("library_files.id", ondelete="SET NULL")
+    )
+    # Mutually exclusive with source_library_file_id. When set, the orchestrator
+    # reads ``archive.source_3mf_path`` (falling back to ``file_path``) for the
+    # slice input. Lets ArchiveCard's "Run with pipeline" reuse the same /run
+    # endpoint instead of growing a second route.
+    source_archive_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("print_archives.id", ondelete="SET NULL"))
+
+    # Set when this run was created by ``POST /pipeline-runs/{parent}/retry-failed``.
+    # Chains the new run back to the run whose failed copies it re-attempts so
+    # the dashboard can show "Retry of run #N" inline. ``SET NULL`` so cleaning
+    # up old runs doesn't dangle retries.
+    parent_run_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("pipeline_runs.id", ondelete="SET NULL"))
+
+    copies: Mapped[int] = mapped_column(Integer, default=1)
+
+    # Snapshot status — terminal transitions are persisted here, in-flight
+    # reads compute from slice_job + queue_entry. Values:
+    #   'queued', 'slicing', 'dispatching', 'in_progress',
+    #   'completed', 'failed', 'cancelled'
+    status: Mapped[str] = mapped_column(String(20), default="queued")
+
+    # Slice integration. slice_job_id is the in-memory slice_dispatch id (so
+    # it's a plain int, not an FK). sliced_library_file_id is the produced
+    # gcode.3mf row.
+    slice_job_id: Mapped[int | None] = mapped_column(Integer)
+    sliced_library_file_id: Mapped[int | None] = mapped_column(
+        Integer, ForeignKey("library_files.id", ondelete="SET NULL")
+    )
+
+    # True when the operator chose to "Run anyway" past eligibility issues
+    # (filament mismatch, etc.). Surfaced in run history so the audit log
+    # shows which runs bypassed the pre-flight.
+    eligibility_overridden: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
+
+    error_message: Mapped[str | None] = mapped_column(Text)
+
+    created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    started_at: Mapped[datetime | None] = mapped_column(DateTime)
+    completed_at: Mapped[datetime | None] = mapped_column(DateTime)
+
+    jobs: Mapped[list["PipelineJob"]] = relationship(
+        back_populates="run",
+        cascade="all, delete-orphan",
+        order_by="PipelineJob.copy_index",
+    )
+
+
+class PipelineJob(Base):
+    """One copy within a PipelineRun. PR B: always exactly one per run.
+
+    Each job binds the run to one queue entry (``queue_entry_id``). The
+    queue entry's status drives this job's status; this row mostly carries
+    the run-side narrative (dispatch timestamps, error message) so deleting
+    the queue entry later doesn't lose the audit trail.
+    """
+
+    __tablename__ = "pipeline_jobs"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    pipeline_run_id: Mapped[int] = mapped_column(Integer, ForeignKey("pipeline_runs.id", ondelete="CASCADE"))
+    copy_index: Mapped[int] = mapped_column(Integer, default=0)
+
+    assigned_printer_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("printers.id", ondelete="SET NULL"))
+    queue_entry_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("print_queue.id", ondelete="SET NULL"))
+
+    # Values: 'pending', 'awaiting_printer', 'queued', 'printing',
+    #         'completed', 'failed', 'cancelled'
+    status: Mapped[str] = mapped_column(String(20), default="pending")
+    error_message: Mapped[str | None] = mapped_column(Text)
+
+    dispatched_at: Mapped[datetime | None] = mapped_column(DateTime)
+    completed_at: Mapped[datetime | None] = mapped_column(DateTime)
+
+    run: Mapped["PipelineRun"] = relationship(back_populates="jobs")

+ 9 - 0
backend/app/models/print_queue.py

@@ -91,6 +91,15 @@ class PrintQueueItem(Base):
     # Nozzle offset calibration — dual-nozzle printers only, MQTT-gated (#1682)
     nozzle_offset_cali: Mapped[bool] = mapped_column(Boolean, default=True)
 
+    # Preheat / heat-soak override (#1468). 'inherit' uses the global
+    # preheat_enabled setting; 'on' / 'off' force the per-item decision. The
+    # chamber target falls through: per-item override → max(filament-map[loaded
+    # tray type]) → 0 (skips chamber phase). 'inherit' + global off + override
+    # null = no preheat. Default 'inherit' so existing queue items behave
+    # exactly as before the migration.
+    preheat_override: Mapped[str] = mapped_column(String(10), default="inherit")
+    preheat_chamber_target_override: Mapped[int | None] = mapped_column(Integer, nullable=True)
+
     # Status: pending, printing, completed, failed, skipped, cancelled
     status: Mapped[str] = mapped_column(String(20), default="pending")
 

+ 61 - 0
backend/app/models/slicer_pipeline.py

@@ -0,0 +1,61 @@
+"""Model for a Slicing/Printing Pipeline definition (#1425).
+
+A pipeline bundles the four slot picks a user normally makes in the SliceModal
+(printer / process / filament(s) / bed type) under a named, reusable preset.
+This is PR A — bundle definitions only. Run state and dispatch live in
+``pipeline_runs`` / ``pipeline_jobs`` (PR B + PR C).
+
+The target_* and fanout_strategy columns are materialised now to avoid a
+second migration when PR B / PR C land; PR A's API accepts the defaults and
+the UI doesn't expose them yet.
+"""
+
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class SlicerPipeline(Base):
+    """A named slicer preset bundle (printer + process + filament[s] + bed)."""
+
+    __tablename__ = "slicer_pipelines"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(200))
+    description: Mapped[str | None] = mapped_column(String(1000))
+
+    # Preset slots. ``*_source`` mirrors PresetRef.source semantics
+    # (orca_cloud / cloud / local / standard); ``*_id`` is the opaque
+    # source-specific id the slicer pipeline uses to resolve content.
+    printer_preset_source: Mapped[str] = mapped_column(String(20))
+    printer_preset_id: Mapped[str] = mapped_column(String(200))
+    process_preset_source: Mapped[str] = mapped_column(String(20))
+    process_preset_id: Mapped[str] = mapped_column(String(200))
+    # JSON array of {"source": ..., "id": ...} entries — one per AMS slot the
+    # source plate is expected to use. Stored as JSON text per Bambuddy's
+    # convention (see LocalPreset.compatible_printers).
+    filament_presets_json: Mapped[str] = mapped_column(Text)
+
+    bed_type: Mapped[str | None] = mapped_column(String(64))
+
+    # Target — PR B+ wiring; PR A treats every pipeline as a bundle without
+    # an active target. Kept materialised so PR B is code-only, not a
+    # migration. ``target_kind`` ∈ {"specific_printer", "printer_class"}.
+    target_kind: Mapped[str] = mapped_column(String(20), default="printer_class")
+    target_printer_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("printers.id", ondelete="SET NULL"))
+    target_model_class: Mapped[str | None] = mapped_column(String(20))
+
+    # Fanout strategy for PR C multi-copy runs. PR A defaults it; the UI
+    # doesn't expose it yet. Values: max_parallel / fill_one_first / round_robin.
+    fanout_strategy: Mapped[str] = mapped_column(String(20), default="max_parallel")
+
+    # Audit fields. created_by is nullable so pipelines survive user deletes
+    # and so installs without auth enabled (current_user is None) still work.
+    created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
+    is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
+
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())

+ 11 - 0
backend/app/schemas/api_key.py

@@ -12,6 +12,11 @@ class APIKeyCreate(BaseModel):
     can_read_status: bool = True
     can_manage_library: bool = True  # Upload / rename / delete own library files + MakerWorld import
     can_manage_inventory: bool = True  # Inventory writes — SpoolBuddy NFC/scale/system, manual stock edits via API
+    can_manage_maintenance: bool = (
+        True  # Log/reset maintenance items, edit intervals, manage type catalog (#1832 follow-up)
+    )
+    can_manage_archives: bool = True  # Create/update/delete print archives — not purge (#1888)
+    can_manage_projects: bool = True  # Create/update/delete projects + membership (add archives) (#1893)
     can_access_cloud: bool = False  # Read /cloud/* on the creator's behalf — default off (#1182)
     can_update_energy_cost: bool = False  # POST /settings/electricity-price only (#1356)
     printer_ids: list[int] | None = None  # null = all printers
@@ -27,6 +32,9 @@ class APIKeyUpdate(BaseModel):
     can_read_status: bool | None = None
     can_manage_library: bool | None = None
     can_manage_inventory: bool | None = None
+    can_manage_maintenance: bool | None = None
+    can_manage_archives: bool | None = None
+    can_manage_projects: bool | None = None
     can_access_cloud: bool | None = None
     can_update_energy_cost: bool | None = None
     printer_ids: list[int] | None = None
@@ -46,6 +54,9 @@ class APIKeyResponse(BaseModel):
     can_read_status: bool
     can_manage_library: bool
     can_manage_inventory: bool
+    can_manage_maintenance: bool
+    can_manage_archives: bool
+    can_manage_projects: bool
     can_access_cloud: bool
     can_update_energy_cost: bool
     printer_ids: list[int] | None

+ 173 - 0
backend/app/schemas/pipeline_run.py

@@ -0,0 +1,173 @@
+"""Pydantic schemas for PipelineRun + eligibility (#1425 PR B + PR C)."""
+
+from datetime import datetime
+from typing import Literal
+
+from pydantic import BaseModel, Field, model_validator
+
+
+class EligibilityIssueResponse(BaseModel):
+    """Single eligibility issue — see ``services/pipeline_eligibility.py`` for
+    the full list of ``kind`` values and what each means."""
+
+    kind: Literal[
+        "printer_not_set",
+        "printer_not_found",
+        "printer_disabled",
+        "printer_offline",
+        "filament_type_mismatch",
+        "filament_color_mismatch",
+        "ams_slot_missing",
+        "filament_unverified",
+        "no_class_matches",  # PR C: target_kind='printer_class' and zero printers in the install match the model
+        "class_not_set",  # PR C: target_kind='printer_class' with no target_model_class
+    ]
+    slot_index: int | None = None
+    expected: str | None = None
+    actual: str | None = None
+
+
+class PerPrinterReport(BaseModel):
+    """One row of class-targeting eligibility — per matching printer.
+
+    PR C extends the top-level report with this list so the confirmation modal
+    can show ``3 of 5 X1Cs eligible`` plus a per-printer breakdown of why each
+    candidate is or isn't usable.
+    """
+
+    printer_id: int
+    printer_name: str
+    ok: bool
+    issues: list[EligibilityIssueResponse] = []
+
+
+class EligibilityReportResponse(BaseModel):
+    """Returned by both ``POST /check-eligibility`` and (on 409) ``POST /run``
+    so the frontend can render the same modal in either flow.
+
+    ``ok`` semantics:
+      - ``target_kind='specific_printer'``: ``ok`` mirrors that single
+        printer's eligibility (no blocking issues).
+      - ``target_kind='printer_class'``: ``ok`` is True iff **at least one**
+        matching printer passes — the run can dispatch even if some
+        candidates in the class are offline / filament-mismatched, because
+        the scheduler will pick any eligible one. The per-printer list lives
+        on ``printer_reports`` so the operator sees the full picture.
+
+    ``issues`` carries class-level issues only (``no_class_matches``,
+    ``class_not_set``) — per-printer detail moves to ``printer_reports``.
+    """
+
+    ok: bool
+    target_kind: Literal["specific_printer", "printer_class"] = "specific_printer"
+    target_printer_id: int | None = None
+    target_printer_name: str | None = None
+    target_model_class: str | None = None
+    issues: list[EligibilityIssueResponse] = []
+    printer_reports: list[PerPrinterReport] = []
+
+
+class CheckEligibilityRequest(BaseModel):
+    """Exactly one of ``source_library_file_id`` / ``source_archive_id`` must
+    be set."""
+
+    source_library_file_id: int | None = None
+    source_archive_id: int | None = None
+    force: bool = Field(default=False)
+
+    @model_validator(mode="after")
+    def exactly_one_source(self) -> "CheckEligibilityRequest":
+        if (self.source_library_file_id is None) == (self.source_archive_id is None):
+            raise ValueError("exactly one of source_library_file_id or source_archive_id must be set")
+        return self
+
+
+class PipelineRunCreateRequest(BaseModel):
+    """``copies`` defaults to 1 (PR B parity). The route handler enforces the
+    ``pipeline_max_copies`` setting on top of the schema's lower bound."""
+
+    source_library_file_id: int | None = None
+    source_archive_id: int | None = None
+    copies: int = Field(default=1, ge=1, le=1000)
+    force: bool = Field(
+        default=False,
+        description=(
+            "When False (default), the route returns 409 with the eligibility "
+            "report if any blocking issue exists. When True, the run starts "
+            "even when issues exist — recorded on PipelineRun.eligibility_overridden."
+        ),
+    )
+
+    @model_validator(mode="after")
+    def exactly_one_source(self) -> "PipelineRunCreateRequest":
+        if (self.source_library_file_id is None) == (self.source_archive_id is None):
+            raise ValueError("exactly one of source_library_file_id or source_archive_id must be set")
+        return self
+
+
+class PipelineJobResponse(BaseModel):
+    id: int
+    pipeline_run_id: int
+    copy_index: int
+    assigned_printer_id: int | None
+    assigned_printer_name: str | None = None
+    queue_entry_id: int | None
+    status: Literal[
+        "pending",
+        "awaiting_printer",
+        "queued",
+        "printing",
+        "completed",
+        "failed",
+        "cancelled",
+    ]
+    error_message: str | None = None
+    dispatched_at: datetime | None = None
+    completed_at: datetime | None = None
+
+
+class PipelineRunResponse(BaseModel):
+    id: int
+    pipeline_id: int | None
+    pipeline_name: str | None = None
+    source_library_file_id: int | None
+    source_archive_id: int | None = None
+    source_filename: str | None = None
+    parent_run_id: int | None = None
+    copies: int
+    # Roll-up counts used by the dashboard's per-row summary. Computed at read
+    # time from the per-job statuses so they always match the live state.
+    copies_completed: int = 0
+    copies_failed: int = 0
+    copies_cancelled: int = 0
+    copies_in_progress: int = 0
+    status: Literal[
+        "queued",
+        "slicing",
+        "dispatching",
+        "in_progress",
+        "completed",
+        "failed",
+        "partial_failure",  # PR C: some copies succeeded, some failed/cancelled
+        "cancelled",
+    ]
+    slice_job_id: int | None
+    sliced_library_file_id: int | None
+    eligibility_overridden: bool
+    error_message: str | None = None
+    created_by: int | None
+    created_at: datetime
+    started_at: datetime | None
+    completed_at: datetime | None
+    jobs: list[PipelineJobResponse] = []
+    # Pipeline target snapshot — copied onto the response so the dashboard
+    # doesn't need a second query to display "Run on X1C class" per row.
+    target_kind: Literal["specific_printer", "printer_class"] | None = None
+    target_printer_id: int | None = None
+    target_model_class: str | None = None
+    fanout_strategy: Literal["max_parallel", "fill_one_first", "round_robin"] | None = None
+
+
+class PipelineRunListResponse(BaseModel):
+    runs: list[PipelineRunResponse] = []
+    total: int = 0  # PR C: for the dashboard's paginator

+ 12 - 1
backend/app/schemas/print_queue.py

@@ -1,7 +1,7 @@
 from datetime import datetime
 from typing import Annotated, Literal
 
-from pydantic import BaseModel, PlainSerializer, model_validator
+from pydantic import BaseModel, Field, PlainSerializer, model_validator
 
 
 # Custom serializer to ensure UTC datetimes have Z suffix
@@ -50,6 +50,11 @@ class PrintQueueItemCreate(BaseModel):
     # 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
+    # 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.
+    preheat_override: Literal["inherit", "on", "off"] = "inherit"
+    preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=60)
     # Auto-print G-code injection
     gcode_injection: bool = False
     # Batch: create multiple copies (creates a batch if > 1)
@@ -85,6 +90,8 @@ class PrintQueueItemUpdate(BaseModel):
     timelapse: bool | None = None
     use_ams: bool | None = None
     nozzle_offset_cali: bool | 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
     gcode_injection: bool | None = None
     # H2C dual-nozzle-rack slicer pick (#1780). list[int] per-filament
@@ -126,6 +133,8 @@ class PrintQueueItemResponse(BaseModel):
     timelapse: bool = False
     use_ams: bool = True
     nozzle_offset_cali: bool = True
+    preheat_override: Literal["inherit", "on", "off"] = "inherit"
+    preheat_chamber_target_override: int | None = None
     status: Literal["pending", "printing", "completed", "failed", "skipped", "cancelled"]
     started_at: UTCDatetime
     completed_at: UTCDatetime
@@ -233,6 +242,8 @@ class PrintQueueBulkUpdate(BaseModel):
     timelapse: bool | None = None
     use_ams: bool | None = None
     nozzle_offset_cali: bool | 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
     gcode_injection: bool | None = None
 

+ 51 - 0
backend/app/schemas/settings.py

@@ -140,6 +140,15 @@ class AppSettings(BaseModel):
     # Default printer for operations
     default_printer_id: int | None = Field(default=None, description="Default printer ID for uploads, reprints, etc.")
 
+    # Slicer Pipelines (#1425 PR C). Cap on the ``copies`` field in the
+    # Run-with-pipeline modal — keeps a misclick from queueing 5000 prints.
+    pipeline_max_copies: int = Field(
+        default=50,
+        ge=1,
+        le=1000,
+        description="Upper bound on the copies an operator can request when running a Slicer Pipeline. Larger fleets / production rigs can raise this; the hard ceiling at 1000 is a sanity guard against fat-fingered input.",
+    )
+
     # Virtual Printer
     virtual_printer_enabled: bool = Field(default=False, description="Enable virtual printer for slicer uploads")
     virtual_printer_access_code: str = Field(default="", description="Access code for virtual printer authentication")
@@ -318,6 +327,43 @@ class AppSettings(BaseModel):
         description="Shortest Job First — scheduler prioritizes shorter print jobs over longer ones",
     )
 
+    # Preheat / heat-soak before queued prints (#1468). The scheduler stage runs
+    # BEFORE FTP upload. Three hardware tiers behave differently:
+    #   - Chamber heater (H2C/H2D/H2DPro/H2S/X2D/X1E): M141 → wait for chamber
+    #     sensor to reach target → soak
+    #   - Chamber sensor only (X1C/P2S): M140 only → wait for radiant chamber
+    #     warm-up to reach target OR max-wait timeout → soak
+    #   - No chamber sensor (P1S/P1P/A1/A1 Mini): M140 only → fixed soak timer
+    #     (no way to verify chamber temp; relies entirely on max_wait + soak)
+    # Chamber target derives per-print from the loaded AMS filament types via
+    # preheat_filament_targets (max across loaded slots). A target of 0 skips
+    # the chamber phase but keeps the bed phase + soak. Per-queue-item
+    # `preheat_chamber_target_override` (nullable) bypasses the derivation.
+    preheat_enabled: bool = Field(
+        default=False,
+        description="Master toggle / default for new queue items. Per-item preheat_override can flip the decision per print.",
+    )
+    preheat_filament_targets: str = Field(
+        default="",
+        description=(
+            "JSON map of normalized filament type → chamber target °C. Empty = bundled defaults "
+            "(PLA/PETG/TPU/PVA: 0, PETG-CF: 40, ABS/ASA: 45, PA/PC/PC-FR: 50, PA-CF: 55, default: 0). "
+            "Scheduler picks max across loaded AMS slots; 0 disables chamber phase for that print."
+        ),
+    )
+    preheat_max_wait_seconds: int = Field(
+        default=900,
+        ge=60,
+        le=3600,
+        description="Maximum time to wait for the chamber to reach the target before falling through to the soak phase (radiant heating on X1C/P2S can take 15-30 min).",
+    )
+    preheat_soak_seconds: int = Field(
+        default=300,
+        ge=0,
+        le=1800,
+        description="Additional hold time at temperature after the chamber reaches the target (or after max_wait_seconds elapses). 0 = no soak.",
+    )
+
     # User-configurable presets for the printer-card temperature / fan-speed
     # popovers. Each is a JSON array of exactly 3 ints (the "Off" button is
     # rendered separately and is not configurable). Empty string = use built-in
@@ -458,6 +504,7 @@ class AppSettingsUpdate(BaseModel):
     date_format: str | None = None
     time_format: str | None = None
     default_printer_id: int | None = None
+    pipeline_max_copies: int | None = None
     virtual_printer_enabled: bool | None = None
     virtual_printer_access_code: str | None = None
     virtual_printer_mode: str | None = None
@@ -506,6 +553,10 @@ class AppSettingsUpdate(BaseModel):
     stagger_interval_minutes: int | None = Field(default=None, ge=1, le=60)
     require_plate_clear: bool | None = None
     queue_shortest_first: bool | None = None
+    preheat_enabled: bool | None = None
+    preheat_filament_targets: str | None = None
+    preheat_max_wait_seconds: int | None = Field(default=None, ge=60, le=3600)
+    preheat_soak_seconds: int | None = Field(default=None, ge=0, le=1800)
     nozzle_temp_presets: str | None = None
     bed_temp_presets: str | None = None
     chamber_temp_presets: str | None = None

+ 83 - 0
backend/app/schemas/slicer_pipeline.py

@@ -0,0 +1,83 @@
+"""Pydantic schemas for the Slicer Pipeline API (#1425, PR A).
+
+A pipeline bundles printer / process / filament(s) / bed-type picks under a
+reusable name. PR A surfaces only the bundle; target_kind / target_printer_id /
+target_model_class / fanout_strategy are persisted but the API treats them as
+opaque defaults — they come alive in PR B (single-target dispatch) and PR C
+(multi-copy + class targeting + fanout).
+"""
+
+from datetime import datetime
+from typing import Literal
+
+from pydantic import BaseModel, Field
+
+from backend.app.schemas.slicer import PresetRef
+
+
+class SlicerPipelineBase(BaseModel):
+    """Fields editable on create + update."""
+
+    name: str = Field(..., min_length=1, max_length=200)
+    description: str | None = Field(default=None, max_length=1000)
+
+    printer_preset: PresetRef
+    process_preset: PresetRef
+    filament_presets: list[PresetRef] = Field(
+        ...,
+        min_length=1,
+        description="One PresetRef per AMS slot. Order matches the source plate's filament-slot order.",
+    )
+    bed_type: str | None = Field(default=None, max_length=64)
+
+
+class SlicerPipelineCreate(SlicerPipelineBase):
+    """Payload for POST /slicer-pipelines."""
+
+
+class SlicerPipelineUpdate(BaseModel):
+    """Payload for PUT /slicer-pipelines/{id}. All fields optional; only those
+    present are written. Preset and filament list are replaced wholesale when
+    set (we don't support partial filament-slot edits)."""
+
+    name: str | None = Field(default=None, min_length=1, max_length=200)
+    description: str | None = Field(default=None, max_length=1000)
+    printer_preset: PresetRef | None = None
+    process_preset: PresetRef | None = None
+    filament_presets: list[PresetRef] | None = Field(default=None, min_length=1)
+    bed_type: str | None = Field(default=None, max_length=64)
+
+    # PR B target binding. ``target_kind='specific_printer'`` requires
+    # ``target_printer_id`` to be set OR cleared in the same payload (route
+    # handler enforces). ``target_kind='printer_class'`` is wired by PR C
+    # together with ``target_model_class`` (a Bambu model code like 'X1C')
+    # and the fanout strategy that distributes copies across matching
+    # printers.
+    target_kind: Literal["specific_printer", "printer_class"] | None = None
+    target_printer_id: int | None = None
+    target_model_class: str | None = Field(default=None, max_length=20)
+    fanout_strategy: Literal["max_parallel", "fill_one_first", "round_robin"] | None = None
+
+
+class SlicerPipelineResponse(SlicerPipelineBase):
+    """A single pipeline as returned by the API."""
+
+    id: int
+    created_by: int | None
+    created_at: datetime
+    updated_at: datetime
+
+    # Echoed for PR B+ readiness; PR A always returns the persisted defaults.
+    target_kind: Literal["specific_printer", "printer_class"] = "printer_class"
+    target_printer_id: int | None = None
+    target_model_class: str | None = None
+    fanout_strategy: Literal["max_parallel", "fill_one_first", "round_robin"] = "max_parallel"
+
+    model_config = {"from_attributes": True}
+
+
+class SlicerPipelineListResponse(BaseModel):
+    """Wraps the list so the response stays additive when run/job counts get
+    surfaced in PR B+ (e.g. a ``meta`` field for last-run timestamps)."""
+
+    pipelines: list[SlicerPipelineResponse] = []

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

@@ -375,42 +375,39 @@ class ThreeMFParser:
             pass  # G-code header parsing is best-effort; metadata may come from other sources
 
     def _extract_filament_info(self, data: dict):
-        """Extract filament info, preferring non-support filaments."""
+        """Extract filament info from project settings — includes support
+        materials so a PLA-model / PVA-support project shows both on the
+        archive card badge (#1881).
+
+        Earlier code filtered by ``filament_is_support``; that hid PVA
+        (and any other soluble/breakaway support material) from the card
+        even when the user had explicitly configured it, and made source
+        3MFs look single-material until the print completed. slice_info
+        (parsed separately) is still preferred when present — it lists
+        only filaments the print actually consumes, this fallback only
+        runs on unsliced source 3MFs.
+        """
         try:
             filament_types = data.get("filament_type", [])
             filament_colors = data.get("filament_colour", [])
-            filament_is_support = data.get("filament_is_support", [])
 
             if not filament_types:
                 return
 
-            # Collect all non-support filaments
-            non_support_types = []
-            non_support_colors = []
-
-            for i, ftype in enumerate(filament_types):
-                is_support = filament_is_support[i] if i < len(filament_is_support) else "0"
-                if is_support == "0":
-                    if ftype and ftype not in non_support_types:
-                        non_support_types.append(ftype)
-                    if i < len(filament_colors) and filament_colors[i]:
-                        color = filament_colors[i]
-                        if color not in non_support_colors:
-                            non_support_colors.append(color)
-
-            # Fallback to first filament if all are support
-            if not non_support_types and filament_types:
-                non_support_types = [filament_types[0]]
-            if not non_support_colors and filament_colors:
-                non_support_colors = [filament_colors[0]]
-
-            # Store filament type(s)
-            if non_support_types:
-                self.metadata["filament_type"] = ", ".join(non_support_types)
-
-            # Store all colors as comma-separated (for multi-color display)
-            if non_support_colors:
-                self.metadata["filament_color"] = ",".join(non_support_colors)
+            unique_types: list[str] = []
+            for ftype in filament_types:
+                if ftype and ftype not in unique_types:
+                    unique_types.append(ftype)
+
+            unique_colors: list[str] = []
+            for color in filament_colors:
+                if color and color not in unique_colors:
+                    unique_colors.append(color)
+
+            if unique_types:
+                self.metadata["filament_type"] = ", ".join(unique_types)
+            if unique_colors:
+                self.metadata["filament_color"] = ",".join(unique_colors)
 
         except Exception:
             pass  # Filament info is optional; fall back to slice_info values

+ 18 - 11
backend/app/services/bambu_cloud.py

@@ -66,14 +66,15 @@ def _detect_cloudflare_challenge(response) -> str | None:
     return None
 
 
-# The `/v1/iot-service/api/slicer/setting` endpoint requires a `version` query
-# parameter in the XX.YY.ZZ.WW format Bambu Studio releases use (without it the
-# API returns HTTP 400 "field 'version' is not set"; non-matching formats like
-# "bambuddy-1.0" return HTTP 422 "Invalid input parameters"). However, Bambu's
-# server accepts ANY value within that format — it doesn't validate against a
-# release manifest. We therefore use a neutral "1.0.0.0" placeholder that does
-# not impersonate any real Bambu Studio release. Our client identity is in the
-# User-Agent header.
+# The `/v1/iot-service/api/slicer/setting` endpoint subtree — the plural GET
+# for the list, the singular GET/DELETE for a specific preset by setting_id, and
+# the POST for create — requires a `version` query parameter in the XX.YY.ZZ.WW
+# format Bambu Studio releases use. Without it the API returns HTTP 400
+# "field 'version' is not set"; non-matching formats like "bambuddy-1.0" return
+# HTTP 422 "Invalid input parameters". However, Bambu's server accepts ANY value
+# within that format — it doesn't validate against a release manifest. We
+# therefore use a neutral "1.0.0.0" placeholder that does not impersonate any
+# real Bambu Studio release. Our client identity is in the User-Agent header.
 _SLICER_API_VERSION = "1.0.0.0"
 
 
@@ -397,13 +398,17 @@ class BambuCloudService:
 
         try:
             response = await self._client.get(
-                f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}", headers=self._get_headers()
+                f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}",
+                headers=self._get_headers(),
+                params={"version": _SLICER_API_VERSION},
             )
 
             if response.status_code == 200:
                 return response.json()
 
-            raise BambuCloudError(f"Failed to get setting detail: {response.status_code}")
+            # 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}")
 
         except httpx.RequestError as e:
             raise BambuCloudError(f"Request failed: {e}")
@@ -557,7 +562,9 @@ class BambuCloudService:
 
         try:
             response = await self._client.delete(
-                f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}", headers=self._get_headers()
+                f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}",
+                headers=self._get_headers(),
+                params={"version": _SLICER_API_VERSION},
             )
 
             if response.status_code in (200, 204):

+ 108 - 37
backend/app/services/bambu_mqtt.py

@@ -292,7 +292,7 @@ class PrinterState:
     ipcam: bool = False  # Live view / camera streaming enabled
     wifi_signal: int | None = None  # WiFi signal strength in dBm
     wired_network: bool = False  # Ethernet connection detected (home_flag bit 18)
-    door_open: bool = False  # Enclosure door open (home_flag bit 23, X1/P1S/P2S/H2*)
+    door_open: bool = False  # Enclosure door open (home_flag bit 23; models with a door sensor: X1/X1C/X1E/X2D/P2S/H2*)
     # Nozzle hardware info (for dual nozzle printers, index 0 = left, 1 = right)
     nozzles: list = field(default_factory=lambda: [NozzleInfo(), NozzleInfo()])
     # AI detection and print options
@@ -2177,6 +2177,35 @@ class BambuMQTTClient:
             # Trigger layer change callback if layer increased
             if new_layer > old_layer and self.on_layer_change:
                 self.on_layer_change(new_layer)
+            # #1867 last-layer finish-photo trigger. A1 Mini (and other
+            # firmware variants) skips `stg_cur=22`, so the fallback fires
+            # at gcode_state=FINISH — which runs AFTER user End G-code
+            # (e.g. SwapMod plate-swap) and captures the wrong plate.
+            # Firing on the layer_num→total_layer_num edge captures the
+            # last object layer before any end G-code executes.
+            total = self.state.total_layers or 0
+            if (
+                total > 0
+                and new_layer >= total
+                and old_layer < total
+                and self._was_running
+                and not self._finish_photo_captured
+                and self.on_finish_photo_moment
+            ):
+                self._finish_photo_captured = True
+                logger.info(
+                    f"[{self.serial_number}] FINISH PHOTO MOMENT (last-layer) — "
+                    f"layer={new_layer}/{total}, "
+                    f"timelapse_active={self._timelapse_during_print}"
+                )
+                self.on_finish_photo_moment(
+                    {
+                        "trigger": "last_layer",
+                        "filename": self._previous_gcode_file or self.state.gcode_file,
+                        "subtask_name": self.state.subtask_name,
+                        "timelapse_was_active": self._timelapse_during_print,
+                    }
+                )
         if "total_layer_num" in data:
             # Some firmware (P1S observed) resets `total_layer_num` to 0 at
             # print end — same shape as the `layer_num` reset guarded above.
@@ -5439,14 +5468,18 @@ class BambuMQTTClient:
             print_error: Canonical hex identifier for the fault — 8 chars for the
                 32-bit `print_error` path, 16 chars for the 64-bit `hms[]` path
                 (HMSError.full_code). Carried through unchanged from the route.
-                Only the `idle_ignore` branch puts it on the wire; resume / stop
-                use BambuStudio's plain shape (verified against a live H2D, the
-                `err`-bearing shape is silently rejected by the firmware).
+                Converted to its DECIMAL string form for the `ignore` /
+                `idle_ignore` commands' `err` field, which is what the firmware
+                actually compares against the active fault. The pre-#1869
+                hex-string `err` was silently rejected because the firmware was
+                being asked to match `"05008051"` against int 0x05008051
+                (= 83918929 decimal) — see BambuStudio's
+                DeviceManager.cpp:1450-1462 (`command_hms_ignore`) which passes
+                `std::to_string(int m_error_code)`.
             action: One of HMSAction's string values.
             job_id: The `subtask_id` snapshotted onto the HMSError at parse-time.
-                Preserved for symmetry with the catalog but no longer sent —
-                BambuStudio's actual resume/stop commands are plain and the
-                firmware doesn't echo `job_id` back on the response either.
+                Required by BambuStudio's `command_hms_ignore` / `command_hms_stop`
+                shapes; empty string is the no-job-id sentinel.
 
         Returns False when the MQTT client is offline or when `action` is unknown
         so the route surfaces it as a 4xx rather than a silent no-op.
@@ -5464,12 +5497,24 @@ class BambuMQTTClient:
                 self.topic_publish, json.dumps({"pushing": {"command": "pushall", "sequence_id": "0"}}), qos=1
             )
 
+        # BambuStudio's `err` field is the DECIMAL string of the error code's int
+        # value (DeviceErrorDialog.cpp passes `std::to_string(m_error_code)` to
+        # every command_hms_* call). Our route hands us the hex string —
+        # convert. Falls back to the raw input if it's not parseable so the
+        # firmware can reject it and the route can surface 502 instead of us
+        # raising ValueError mid-dispatch.
+        try:
+            err_decimal = str(int(print_error, 16))
+        except ValueError:
+            err_decimal = print_error
+
         def hms_resume():
-            # BambuStudio's actual shape — plain resume, no err / no job_id.
-            # The `err`-bearing shape (`err`, `param: "reserve"`, `job_id`) is
-            # silently rejected by Bambu firmware on print_error- and hms[]-sourced
-            # faults alike; verified by injecting candidate shapes against a live
-            # H2D paused on a wrong-plate HMS. See #1830 §(2).
+            # Plain resume — verified against the user's H2D/H2S to leave PAUSE
+            # cleanly when "Problem Solved and Resume" is clicked. BambuStudio
+            # sends `{command: "resume", err: "<decimal>", param: "reserve",
+            # job_id: ...}` from `command_hms_resume`; we kept the simpler
+            # shape historically because it works, and changing it without a
+            # field test risks regressing a path that the user has confirmed.
             publish(
                 {
                     "print": {
@@ -5481,8 +5526,8 @@ class BambuMQTTClient:
             )
 
         def hms_stop():
-            # Same as hms_resume — BambuStudio's actual shape is plain. The
-            # `err`-bearing variant is silently rejected; verified on the H2D.
+            # Same as hms_resume — plain shape, confirmed working by the user
+            # for "Stop Printing".
             publish(
                 {
                     "print": {
@@ -5493,29 +5538,46 @@ class BambuMQTTClient:
                 }
             )
 
-        def hms_ignore(persistent: bool = False):
-            # `idle_ignore` is BambuStudio's "dismiss this warning" command for
-            # non-pause warnings. type=0 dismisses once, type=1 hides the same
-            # warning permanently.
+        def hms_ignore_command():
+            # BambuStudio's `command_hms_ignore` (DeviceManager.cpp:1450) —
+            # what the "Ignore this and Resume" button actually publishes.
+            # Distinct from `idle_ignore`: this command has the firmware
+            # suppress the next re-check of the named fault AND resume the
+            # paused print in a single operation. The previous Bambuddy code
+            # redirected IGNORE_RESUME to a plain `resume`, which is why the
+            # wrong-plate HMS came back 1-2 s later: `resume` means "I fixed
+            # the problem, re-check normally" so the firmware re-detected the
+            # wrong plate and re-paused with the same code (#1869).
             #
-            # For HMS-paused state, `idle_ignore` is silently rejected by the
-            # firmware regardless of `err` (verified on a live H2D — see
-            # #1830 §(2)). The user-facing intent of "Ignore and resume" on a
-            # paused print is to continue, so we dispatch a plain resume
-            # instead. The `persistent` flag is informational in that branch —
-            # firmware can't honour "don't remind" through a resume — but the
-            # button still does what the user expects.
-            #
-            # NB: PrinterState's `state` field carries the MQTT `gcode_state`
-            # value verbatim — line 2144 stores `data["gcode_state"]` onto it.
-            if self.state.state == "PAUSE":
-                hms_resume()
-                return
+            # BambuStudio also routes IGNORE_NO_REMINDER_NEXT_TIME (a.k.a.
+            # DONT_REMIND_NEXT_TIME) to this same command — the persistent
+            # variant of "don't remind next time" lives on `idle_ignore`'s
+            # type=1, not as a separate ignore shape.
+            publish(
+                {
+                    "print": {
+                        "command": "ignore",
+                        "err": err_decimal,
+                        "param": "reserve",
+                        "job_id": job_id or "",
+                        "sequence_id": "0",
+                    }
+                }
+            )
+
+        def hms_idle_ignore(persistent: bool = False):
+            # `idle_ignore` is BambuStudio's "dismiss this warning without
+            # resuming" command for non-pause warnings — what
+            # `command_hms_idle_ignore` (DeviceManager.cpp:1424) sends.
+            # type=0 dismisses once, type=1 suppresses the same warning
+            # permanently. Used by NO_REMINDER_NEXT_TIME, which BambuStudio
+            # explicitly dispatches via `command_hms_idle_ignore(..., 0)` —
+            # NOT via the resume-bearing `ignore` command.
             publish(
                 {
                     "print": {
                         "command": "idle_ignore",
-                        "err": print_error,
+                        "err": err_decimal,
                         "type": 1 if persistent else 0,
                         "sequence_id": "0",
                     }
@@ -5577,11 +5639,20 @@ class BambuMQTTClient:
             case HMSAction.STOP_PRINTING:
                 hms_stop()
 
-            case HMSAction.IGNORE_RESUME | HMSAction.NO_REMINDER_NEXT_TIME:
-                hms_ignore(persistent=False)
-
-            case HMSAction.IGNORE_NO_REMINDER_NEXT_TIME | HMSAction.DONT_REMIND_NEXT_TIME:
-                hms_ignore(persistent=True)
+            case HMSAction.IGNORE_RESUME | HMSAction.IGNORE_NO_REMINDER_NEXT_TIME | HMSAction.DONT_REMIND_NEXT_TIME:
+                # All three buttons map to BambuStudio's `command_hms_ignore`
+                # (DeviceErrorDialog.cpp:596-602). The "no reminder next time"
+                # half of IGNORE_NO_REMINDER_NEXT_TIME is the firmware's
+                # responsibility — the wire shape is identical.
+                hms_ignore_command()
+
+            case HMSAction.NO_REMINDER_NEXT_TIME:
+                # BambuStudio's NO_REMINDER_NEXT_TIME branch dispatches
+                # `command_hms_idle_ignore` with type=0
+                # (DeviceErrorDialog.cpp:588-590). Distinct from the
+                # IGNORE_* buttons above: idle_ignore does NOT resume, only
+                # dismisses the dialog.
+                hms_idle_ignore(persistent=False)
 
             case HMSAction.FILAMENT_EXTRUDED | HMSAction.DBL_CHECK_DONE:
                 ams_control("done")

+ 61 - 8
backend/app/services/external_camera.py

@@ -461,6 +461,39 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
             await proxy_server.wait_closed()
 
 
+def _transcode_to_jpeg(data: bytes) -> bytes | None:
+    """Decode an arbitrary still image (PNG/WebP/BMP/GIF/...) and re-encode as JPEG.
+
+    Some camera/proxy snapshot endpoints serve stills as PNG or WebP rather than
+    JPEG. A browser opened directly at the URL renders those fine, but our MJPEG
+    ``multipart/x-mixed-replace`` stream hard-labels every part
+    ``Content-Type: image/jpeg`` — so a non-JPEG payload makes the browser reject
+    the frame and drop the whole stream ("connection lost", #1902). Transcoding to
+    JPEG keeps the stream genuinely MJPEG and also keeps the JPEG-only downstream
+    (plate detection, Obico, finish photo) working.
+
+    Returns None if the bytes are not a decodable image (e.g. an HTML error page)
+    or if the imaging libraries are unavailable — callers fall back to the raw
+    bytes so behaviour is never worse than before.
+    """
+    try:
+        import cv2
+        import numpy as np
+    except ImportError:
+        return None
+    try:
+        img = cv2.imdecode(np.frombuffer(data, dtype=np.uint8), cv2.IMREAD_COLOR)
+        if img is None:
+            return None
+        ok, buf = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 85])
+        if not ok:
+            return None
+        return buf.tobytes()
+    except Exception as e:  # cv2 raises cv2.error (a subclass of Exception) on bad input
+        logger.debug("Snapshot transcode to JPEG failed: %s", e)
+        return None
+
+
 async def _capture_snapshot(url: str, timeout: int) -> bytes | None:
     """Fetch snapshot from HTTP URL.
 
@@ -484,14 +517,6 @@ async def _capture_snapshot(url: str, timeout: int) -> bytes | None:
                 return None
 
             data = await response.read()
-
-            # Validate it looks like JPEG
-            if not data.startswith(b"\xff\xd8"):
-                logger.warning("Snapshot does not appear to be JPEG")
-                # Still return it - might be valid with different header
-
-            return data
-
     except TimeoutError:
         logger.warning("Snapshot capture timed out after %ss", timeout)
         return None
@@ -499,6 +524,34 @@ async def _capture_snapshot(url: str, timeout: int) -> bytes | None:
         logger.error("Snapshot capture failed: %s", e)
         return None
 
+    # Fast path: already JPEG (SOI marker), stream it as-is (no decode/re-encode).
+    if data.startswith(b"\xff\xd8"):
+        return data
+
+    # Not JPEG. Many snapshot endpoints serve PNG/WebP/BMP — transcode to JPEG so
+    # the browser's MJPEG stream (and JPEG-only downstream) keep working instead of
+    # dropping the connection (#1902). Run off the event loop: cv2 decode/encode is
+    # CPU-bound and this can be polled at up to 15 fps while a camera view is open.
+    transcoded = await asyncio.to_thread(_transcode_to_jpeg, data)
+    if transcoded is not None:
+        logger.debug(
+            "Transcoded non-JPEG snapshot (%d bytes, header %s) to JPEG",
+            len(data),
+            data[:4].hex(),
+        )
+        return transcoded
+
+    # Couldn't decode it as an image at all — most likely not an image response
+    # (HTML error page, auth redirect, wrong URL). Return the raw bytes as a last
+    # resort (unchanged behaviour) but log enough to debug.
+    logger.warning(
+        "External camera snapshot is not a decodable image "
+        "(%d bytes, header %s) — verify the camera URL returns an image",
+        len(data),
+        data[:4].hex(),
+    )
+    return data
+
 
 async def test_connection(url: str, camera_type: str) -> dict:
     """Test camera connection.

+ 58 - 22
backend/app/services/label_renderer.py

@@ -130,7 +130,13 @@ def _qr_png_bytes(payload: str, *, box_size: int = 4, border: int = 2) -> bytes:
         return b""
     qr = qrcode.QRCode(
         version=None,
-        error_correction=qrcode.constants.ERROR_CORRECT_M,
+        # ERROR_CORRECT_L (7% recovery) rather than M (15%): a label QR only
+        # needs to survive being scanned off clean stock, not physical damage,
+        # and L encodes the same payload in a lower version (fewer, chunkier
+        # modules). That extra module size is what makes the code printable on
+        # low-resolution 203 dpi thermal printers, where M-level density bled
+        # the modules together on small labels (#1870).
+        error_correction=qrcode.constants.ERROR_CORRECT_L,
         box_size=box_size,
         border=border,
     )
@@ -168,6 +174,19 @@ def _draw_swatch(c: rl_canvas.Canvas, x: float, y: float, w: float, h: float, da
     c.rect(x, y, w, h, stroke=1, fill=0)
 
 
+def _roomy_qr_size(inner_w: float, inner_h: float) -> float:
+    """QR edge length (points) for the roomy layout.
+
+    Historically a flat 20% of inner width, which on the narrowest label
+    (box_40x30, ~37.6 mm inner) rendered a ~7.5 mm QR — at 203 dpi each module
+    fell below ~2 dots and the code bled into itself on thermal printers
+    (#1870). A 12 mm floor keeps small labels scannable; the code is still
+    capped by the inner height, an 18 mm absolute max, and ~45% of inner width
+    so it can't crowd out the text column on an ultra-narrow label.
+    """
+    return min(max(inner_w * 0.20, 12 * mm), inner_h, 18 * mm, inner_w * 0.45)
+
+
 def _draw_qr(c: rl_canvas.Canvas, x: float, y: float, size: float, payload: str) -> None:
     """Embed a square QR at (x, y) with edge length ``size`` (in points)."""
     png = _qr_png_bytes(payload)
@@ -189,7 +208,9 @@ def _truncate_to_width(c: rl_canvas.Canvas, text: str, font: str, size: float, m
     return text + ell if text else ell
 
 
-def _draw_label(c: rl_canvas.Canvas, x: float, y: float, w: float, h: float, data: LabelData) -> None:
+def _draw_label(
+    c: rl_canvas.Canvas, x: float, y: float, w: float, h: float, data: LabelData, monochrome: bool = False
+) -> None:
     """Render one label inside the box (x, y, w, h). Origin is bottom-left.
 
     Two layouts, picked by available height:
@@ -219,9 +240,9 @@ def _draw_label(c: rl_canvas.Canvas, x: float, y: float, w: float, h: float, dat
     is_tight = h < 20 * mm
 
     if is_tight:
-        _draw_label_tight(c, x, y, w, h, inner_x, inner_y, inner_w, inner_h, pad, data)
+        _draw_label_tight(c, x, y, w, h, inner_x, inner_y, inner_w, inner_h, pad, data, monochrome)
     else:
-        _draw_label_roomy(c, x, y, w, h, inner_x, inner_y, inner_w, inner_h, pad, data)
+        _draw_label_roomy(c, x, y, w, h, inner_x, inner_y, inner_w, inner_h, pad, data, monochrome)
 
 
 def _draw_label_tight(
@@ -236,11 +257,17 @@ def _draw_label_tight(
     inner_h: float,
     pad: float,
     data: LabelData,
+    monochrome: bool = False,
 ) -> None:
     """Tight layout (h < 20 mm). Swatch + brand/material/hex/ID, no QR."""
-    swatch_w = min(inner_h, inner_w * 0.35)
-    swatch_y = inner_y + (inner_h - swatch_w) / 2
-    _draw_swatch(c, inner_x, swatch_y, swatch_w, swatch_w, data)
+    # Monochrome: drop the colour swatch (see _draw_label_roomy) and give the
+    # width to the text column (#1870).
+    if monochrome:
+        swatch_w = 0.0
+    else:
+        swatch_w = min(inner_h, inner_w * 0.35)
+        swatch_y = inner_y + (inner_h - swatch_w) / 2
+        _draw_swatch(c, inner_x, swatch_y, swatch_w, swatch_w, data)
 
     text_x = inner_x + swatch_w + pad
     text_w = inner_w - swatch_w - pad
@@ -296,17 +323,22 @@ def _draw_label_roomy(
     inner_h: float,
     pad: float,
     data: LabelData,
+    monochrome: bool = False,
 ) -> None:
     """Box-label / Avery layout. Swatch left, QR right, text middle."""
     # Swatch: full inner height, ~18% of inner width but capped so we never
-    # eat the text column on extreme aspect ratios.
-    swatch_w = min(inner_w * 0.18, inner_h, 16 * mm)
-    swatch_h = inner_h
-    _draw_swatch(c, inner_x, inner_y, swatch_w, swatch_h, data)
-
-    # QR: square, capped at the smaller of (a fraction of width, the inner
-    # height, or 18 mm — beyond that the QR is overkill for the print size).
-    qr_size = min(inner_w * 0.20, inner_h, 18 * mm)
+    # eat the text column on extreme aspect ratios. Omitted entirely in
+    # monochrome mode — on a B&W thermal printer a colour block prints as a
+    # muddy grey that conveys nothing, so we reclaim the space for text and
+    # rely on the hex-code line to carry the colour (#1870, requested by
+    # @Geoff-S). The hex code already renders below whenever rgba is set.
+    if monochrome:
+        swatch_w = 0.0
+    else:
+        swatch_w = min(inner_w * 0.18, inner_h, 16 * mm)
+        _draw_swatch(c, inner_x, inner_y, swatch_w, inner_h, data)
+
+    qr_size = _roomy_qr_size(inner_w, inner_h)
     qr_x = x + w - pad - qr_size
     qr_y = inner_y + (inner_h - qr_size) / 2
     _draw_qr(c, qr_x, qr_y, qr_size, data.deeplink_url)
@@ -394,7 +426,7 @@ _SHEET_TEMPLATES: dict[str, tuple] = {
 }
 
 
-def _render_single_label_pdf(template: TemplateName, data_list: list[LabelData]) -> bytes:
+def _render_single_label_pdf(template: TemplateName, data_list: list[LabelData], monochrome: bool = False) -> bytes:
     w_mm, h_mm = _SINGLE_LABEL_SIZES_MM[template]
     page_w, page_h = w_mm * mm, h_mm * mm
 
@@ -403,14 +435,14 @@ def _render_single_label_pdf(template: TemplateName, data_list: list[LabelData])
     c.setTitle(f"Bambuddy spool labels ({template})")
 
     for data in data_list:
-        _draw_label(c, 0, 0, page_w, page_h, data)
+        _draw_label(c, 0, 0, page_w, page_h, data, monochrome)
         c.showPage()
 
     c.save()
     return buf.getvalue()
 
 
-def _render_sheet_pdf(template: TemplateName, data_list: list[LabelData]) -> bytes:
+def _render_sheet_pdf(template: TemplateName, data_list: list[LabelData], monochrome: bool = False) -> bytes:
     page_size, w_mm, h_mm, cols, rows, top_mm, left_mm, col_gap_mm, row_gap_mm = _SHEET_TEMPLATES[template]
     page_w, page_h = page_size
 
@@ -433,23 +465,27 @@ def _render_sheet_pdf(template: TemplateName, data_list: list[LabelData]) -> byt
             col = idx % cols
             x = left_margin + col * (label_w + col_gap)
             y = page_h - top_margin - (row + 1) * label_h - row * row_gap
-            _draw_label(c, x, y, label_w, label_h, data)
+            _draw_label(c, x, y, label_w, label_h, data, monochrome)
         c.showPage()
 
     c.save()
     return buf.getvalue()
 
 
-def render_labels(template: TemplateName, data_list: list[LabelData]) -> bytes:
+def render_labels(template: TemplateName, data_list: list[LabelData], *, monochrome: bool = False) -> bytes:
     """Render ``data_list`` to a PDF using the named template. Returns bytes.
 
     Empty ``data_list`` still produces a valid (empty) PDF — callers should
     short-circuit beforehand if that's not desired.
+
+    ``monochrome`` drops the colour swatch (which prints as a useless grey block
+    on black-and-white thermal printers) and reclaims the space for text; the
+    hex-code line still carries the colour. See #1870.
     """
     if template in _SINGLE_LABEL_SIZES_MM:
-        return _render_single_label_pdf(template, data_list)
+        return _render_single_label_pdf(template, data_list, monochrome)
     if template in _SHEET_TEMPLATES:
-        return _render_sheet_pdf(template, data_list)
+        return _render_sheet_pdf(template, data_list, monochrome)
     raise ValueError(f"Unknown label template: {template!r}")
 
 

+ 17 - 11
backend/app/services/network_utils.py

@@ -27,17 +27,21 @@ def _is_excluded(name: str) -> bool:
 
 
 def _get_network_interfaces_psutil() -> list[dict]:
-    """Windows path: enumerate interfaces via psutil.
+    """Non-Linux path (Windows, macOS, BSD): enumerate interfaces via psutil.
 
-    fcntl + ioctl is Linux-only, and the ``ip`` command isn't available
-    on Windows either, so both Linux code paths return empty here. psutil
-    is already a Bambuddy dep (``psutil>=6.0.0``) and gives us cross-
-    platform name + IPv4 + netmask in one call.
+    The ioctl request numbers in the Linux path (SIOCGIFADDR 0x8915,
+    SIOCGIFNETMASK 0x891B) and the sockaddr layout they return are
+    Linux-specific. On macOS/BSD ``fcntl`` still imports, so those ioctls
+    don't raise ImportError — they raise ``OSError`` per interface and the
+    Linux path silently returns an empty list (no VP bind interfaces).
+    Windows has no ``fcntl``/``ip`` at all. psutil is already a Bambuddy dep
+    (``psutil>=6.0.0``) and gives cross-platform name + IPv4 + netmask in one
+    call, so we use it for everything that isn't Linux.
 
     Filters: IPv4 only (matches the Linux path), skip loopback and
     link-local (169.254.0.0/16), skip interfaces psutil reports as down.
-    No name-based exclusion — users on Windows may legitimately want to
-    bind a VP to a Hyper-V / WSL / Tailscale virtual adapter.
+    No name-based exclusion — users may legitimately want to bind a VP to a
+    Hyper-V / WSL / Tailscale / utun virtual adapter.
     """
     try:
         import psutil
@@ -100,10 +104,12 @@ def get_network_interfaces() -> list[dict]:
     Returns:
         List of dicts with name, ip, netmask, subnet, broadcast
     """
-    # Windows has no fcntl and no `ip` binary; the Linux ioctl path below
-    # raises ImportError on import fcntl. Route to the psutil-based path
-    # instead. The Linux path stays as-is for behavioural parity.
-    if sys.platform == "win32":
+    # Only Linux has the SIOCGIFADDR/SIOCGIFNETMASK ioctls + sockaddr layout the
+    # path below relies on. Windows lacks fcntl entirely; macOS/BSD have fcntl but
+    # different ioctl numbers, so the ioctl path there fails per-interface and
+    # returns an empty list (breaking the VP bind-interface dropdown on macOS).
+    # Route everything non-Linux to the cross-platform psutil path.
+    if not sys.platform.startswith("linux"):
         return _get_network_interfaces_psutil()
 
     interfaces = []

+ 360 - 0
backend/app/services/pipeline_eligibility.py

@@ -0,0 +1,360 @@
+"""Eligibility matcher for Slicer Pipeline runs (#1425 PR B).
+
+Given a pipeline + the user's pinned target printer, this returns a structured
+report of issues the operator should resolve before running. The frontend
+displays the report; the user can ``Run anyway`` to proceed (lenient policy —
+the print may still fail at the printer, but Bambuddy isn't going to refuse
+the click).
+
+Issue kinds (pinned for tests + i18n keys):
+  - printer_not_set         — pipeline has no target_printer_id
+  - printer_not_found       — target_printer_id points at a deleted/missing row
+  - printer_disabled        — Printer.is_active is False (#1476)
+  - printer_offline         — MQTT not connected
+  - filament_type_mismatch  — AMS slot loaded with wrong filament type
+  - filament_color_mismatch — type matches, colour differs
+  - ams_slot_missing        — pipeline expects N filament slots but AMS exposes fewer
+  - filament_unverified     — pipeline filament preset is a non-local tier we
+                              can't statically read (cloud / orca_cloud / standard);
+                              the run will proceed, but the operator should
+                              double-check
+
+The matcher is a pure-ish function over (pipeline, printer row, live AMS state,
+local-preset dict) so unit tests can drive it with fixtures without spinning up
+MQTT. The route handler is the only place that talks to ``printer_manager``.
+"""
+
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass
+from typing import Literal
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.local_preset import LocalPreset
+from backend.app.models.printer import Printer
+from backend.app.models.slicer_pipeline import SlicerPipeline
+
+IssueKind = Literal[
+    "printer_not_set",
+    "printer_not_found",
+    "printer_disabled",
+    "printer_offline",
+    "filament_type_mismatch",
+    "filament_color_mismatch",
+    "ams_slot_missing",
+    "filament_unverified",
+    "no_class_matches",
+    "class_not_set",
+]
+
+
+@dataclass(frozen=True)
+class EligibilityIssue:
+    kind: IssueKind
+    slot_index: int | None = None
+    expected: str | None = None
+    actual: str | None = None
+
+
+@dataclass(frozen=True)
+class PerPrinterReport:
+    """One row of the class-targeting eligibility breakdown."""
+
+    printer_id: int
+    printer_name: str
+    ok: bool
+    issues: tuple[EligibilityIssue, ...]
+
+
+@dataclass(frozen=True)
+class EligibilityReport:
+    ok: bool
+    target_kind: Literal["specific_printer", "printer_class"]
+    target_printer_id: int | None
+    target_printer_name: str | None
+    target_model_class: str | None
+    issues: tuple[EligibilityIssue, ...]
+    printer_reports: tuple[PerPrinterReport, ...] = ()
+
+
+# Same equivalence map as print_scheduler._canonical_filament_type but kept
+# local so this module has no upward dependency on the scheduler. Mirrors the
+# scheduler's behaviour: BBL-prefixed product names normalise to the base type
+# (e.g. "PLA Basic" → "PLA"). When the scheduler's map gets a new alias, this
+# one needs the same entry.
+_FILAMENT_EQUIV_MAP = {
+    "PLA": "PLA",
+    "PLA BASIC": "PLA",
+    "PLA MATTE": "PLA",
+    "PLA SILK": "PLA",
+    "PLA PRO": "PLA",
+    "PLA TOUGH": "PLA",
+    "PETG": "PETG",
+    "PETG HF": "PETG",
+    "PETG BASIC": "PETG",
+    "PETG TRANSLUCENT": "PETG",
+    "ABS": "ABS",
+    "ASA": "ASA",
+    "TPU": "TPU",
+    "TPU 95A": "TPU",
+    "PC": "PC",
+    "PA": "PA",
+    "PA-CF": "PA",
+    "PVA": "PVA",
+}
+
+
+def _canonical(ftype: str) -> str:
+    upper = (ftype or "").strip().upper()
+    return _FILAMENT_EQUIV_MAP.get(upper, upper)
+
+
+def _normalise_colour(colour: str | None) -> str:
+    if not colour:
+        return ""
+    return colour.replace("#", "").lower()[:6]
+
+
+def _ams_slots(raw_data: dict) -> list[tuple[str, str]]:
+    """Flatten AMS + external spool into ``[(type, colour_hex6), ...]`` in slot
+    order. Uses the same field shape as print_scheduler._check_required_filaments.
+    """
+    out: list[tuple[str, str]] = []
+    for ams_unit in raw_data.get("ams") or []:
+        for tray in ams_unit.get("tray") or []:
+            tray_type = tray.get("tray_type") or ""
+            tray_colour = tray.get("tray_color") or ""
+            out.append((_canonical(tray_type), _normalise_colour(tray_colour)))
+    for vt in raw_data.get("vt_tray") or []:
+        vt_type = vt.get("tray_type") or ""
+        vt_colour = vt.get("tray_color") or ""
+        out.append((_canonical(vt_type), _normalise_colour(vt_colour)))
+    return out
+
+
+async def _expected_filament(
+    db: AsyncSession,
+    source: str,
+    preset_id: str,
+) -> tuple[str | None, str | None]:
+    """Return ``(canonical_type, normalised_colour)`` for a pipeline filament
+    slot's PresetRef, or ``(None, None)`` when the preset can't be resolved
+    statically (cloud / orca_cloud / standard — read at slice time, not here).
+    """
+    if source != "local":
+        # Cloud / orca_cloud / standard: surface as ``filament_unverified``
+        # in the report, the matcher decides.
+        return (None, None)
+    try:
+        local_id = int(preset_id)
+    except (TypeError, ValueError):
+        return (None, None)
+    row = (await db.execute(select(LocalPreset).where(LocalPreset.id == local_id))).scalar_one_or_none()
+    if row is None:
+        return (None, None)
+    return (_canonical(row.filament_type or ""), _normalise_colour(row.default_filament_colour))
+
+
+async def _check_one_printer(
+    db: AsyncSession,
+    pipeline: SlicerPipeline,
+    printer: Printer,
+    printer_raw_status: dict | None,
+) -> tuple[bool, tuple[EligibilityIssue, ...]]:
+    """Run the per-printer eligibility checks. Returns ``(ok, issues)`` so the
+    caller can flatten them into either a single-printer or class-targeting
+    report. Pulled out of the original entry function so PR C's class branch
+    can reuse it for each candidate printer."""
+    issues: list[EligibilityIssue] = []
+
+    if not printer.is_active:
+        issues.append(EligibilityIssue(kind="printer_disabled"))
+
+    if not printer_raw_status or not printer_raw_status.get("connected"):
+        issues.append(EligibilityIssue(kind="printer_offline"))
+        return (not issues, tuple(issues))
+
+    try:
+        filament_refs = json.loads(pipeline.filament_presets_json or "[]")
+    except (json.JSONDecodeError, TypeError):
+        filament_refs = []
+
+    ams_slots = _ams_slots(printer_raw_status.get("raw_data") or {})
+
+    for slot_index, ref in enumerate(filament_refs):
+        if not isinstance(ref, dict):
+            continue
+        source = ref.get("source", "")
+        preset_id = ref.get("id", "")
+        expected_type, expected_colour = await _expected_filament(db, source, str(preset_id))
+
+        if expected_type is None:
+            issues.append(
+                EligibilityIssue(
+                    kind="filament_unverified",
+                    slot_index=slot_index,
+                    expected=f"{source}:{preset_id}",
+                )
+            )
+            continue
+
+        if slot_index >= len(ams_slots):
+            issues.append(
+                EligibilityIssue(
+                    kind="ams_slot_missing",
+                    slot_index=slot_index,
+                    expected=expected_type,
+                )
+            )
+            continue
+
+        actual_type, actual_colour = ams_slots[slot_index]
+        if expected_type and actual_type and expected_type != actual_type:
+            issues.append(
+                EligibilityIssue(
+                    kind="filament_type_mismatch",
+                    slot_index=slot_index,
+                    expected=expected_type,
+                    actual=actual_type or "(empty)",
+                )
+            )
+            continue
+        if expected_colour and actual_colour and expected_colour != actual_colour:
+            issues.append(
+                EligibilityIssue(
+                    kind="filament_color_mismatch",
+                    slot_index=slot_index,
+                    expected=expected_colour,
+                    actual=actual_colour,
+                )
+            )
+
+    # ``filament_unverified`` is informational — doesn't flip ok=False.
+    blocking_issues = [i for i in issues if i.kind != "filament_unverified"]
+    return (not blocking_issues, tuple(issues))
+
+
+async def check_pipeline_eligibility(
+    db: AsyncSession,
+    pipeline: SlicerPipeline,
+    printer_raw_status: dict | None = None,
+    *,
+    status_lookup: object = None,
+) -> EligibilityReport:
+    """Build the eligibility report.
+
+    Two calling shapes, chosen by ``pipeline.target_kind``:
+      - ``specific_printer``: ``printer_raw_status`` carries the live
+        ``PrinterState`` dict (``connected`` + ``raw_data``) for the pinned
+        target_printer_id. PR B signature, preserved.
+      - ``printer_class``: ``status_lookup`` is a callable
+        ``(printer_id) -> dict | None`` that the matcher calls for each
+        printer whose model matches ``pipeline.target_model_class``.
+    """
+    # PR A pipelines default target_kind to 'printer_class' but PR B and
+    # earlier UI only let users pin a specific_printer; treat
+    # ``target_printer_id is not None`` as the source of truth for the
+    # specific-printer path until the editor exposes target_kind explicitly.
+    if pipeline.target_printer_id is not None or pipeline.target_kind == "specific_printer":
+        # Specific-printer branch (PR B parity).
+        if pipeline.target_printer_id is None:
+            return EligibilityReport(
+                ok=False,
+                target_kind="specific_printer",
+                target_printer_id=None,
+                target_printer_name=None,
+                target_model_class=None,
+                issues=(EligibilityIssue(kind="printer_not_set"),),
+            )
+
+        printer = (
+            await db.execute(select(Printer).where(Printer.id == pipeline.target_printer_id))
+        ).scalar_one_or_none()
+        if printer is None:
+            return EligibilityReport(
+                ok=False,
+                target_kind="specific_printer",
+                target_printer_id=pipeline.target_printer_id,
+                target_printer_name=None,
+                target_model_class=None,
+                issues=(EligibilityIssue(kind="printer_not_found"),),
+            )
+
+        ok, issues = await _check_one_printer(db, pipeline, printer, printer_raw_status)
+        return EligibilityReport(
+            ok=ok,
+            target_kind="specific_printer",
+            target_printer_id=printer.id,
+            target_printer_name=printer.name,
+            target_model_class=None,
+            issues=issues,
+        )
+
+    # Class-targeting branch (PR C).
+    if not pipeline.target_model_class:
+        return EligibilityReport(
+            ok=False,
+            target_kind="printer_class",
+            target_printer_id=None,
+            target_printer_name=None,
+            target_model_class=None,
+            issues=(EligibilityIssue(kind="class_not_set"),),
+        )
+
+    candidates = (await db.execute(select(Printer).where(Printer.model == pipeline.target_model_class))).scalars().all()
+
+    if not candidates:
+        return EligibilityReport(
+            ok=False,
+            target_kind="printer_class",
+            target_printer_id=None,
+            target_printer_name=None,
+            target_model_class=pipeline.target_model_class,
+            issues=(
+                EligibilityIssue(
+                    kind="no_class_matches",
+                    expected=pipeline.target_model_class,
+                ),
+            ),
+        )
+
+    reports: list[PerPrinterReport] = []
+    if status_lookup is None:
+        # Treat all printers as offline when no lookup was provided — keeps
+        # the matcher pure-ish for unit tests.
+        for printer in candidates:
+            ok, issues = await _check_one_printer(db, pipeline, printer, None)
+            reports.append(
+                PerPrinterReport(
+                    printer_id=printer.id,
+                    printer_name=printer.name,
+                    ok=ok,
+                    issues=issues,
+                )
+            )
+    else:
+        for printer in candidates:
+            raw = status_lookup(printer.id)
+            ok, issues = await _check_one_printer(db, pipeline, printer, raw)
+            reports.append(
+                PerPrinterReport(
+                    printer_id=printer.id,
+                    printer_name=printer.name,
+                    ok=ok,
+                    issues=issues,
+                )
+            )
+
+    any_ok = any(r.ok for r in reports)
+    return EligibilityReport(
+        ok=any_ok,
+        target_kind="printer_class",
+        target_printer_id=None,
+        target_printer_name=None,
+        target_model_class=pipeline.target_model_class,
+        issues=(),
+        printer_reports=tuple(reports),
+    )

+ 575 - 55
backend/app/services/print_scheduler.py

@@ -7,13 +7,14 @@ import time
 from datetime import datetime, timezone
 from pathlib import Path
 
-from sqlalchemy import func, select
+from sqlalchemy import func, select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
 from backend.app.core.config import settings
 from backend.app.core.database import async_session, run_with_retry
 from backend.app.core.tasks import spawn_background_task
+from backend.app.core.websocket import ws_manager
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile
 from backend.app.models.print_queue import PrintQueueItem
@@ -33,6 +34,9 @@ from backend.app.services.filament_deficit import compute_deficit_for_queue_item
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import (
     printer_manager,
+    supports_airduct,
+    supports_chamber_heater,
+    supports_chamber_temp,
     supports_drying,
     supports_drying_while_printing,
 )
@@ -42,6 +46,77 @@ from backend.app.utils.printer_models import normalize_printer_model
 
 logger = logging.getLogger(__name__)
 
+# Dispatch-toast progress throttling (#1625 follow-up). Mirrors the legacy
+# background_dispatch.py upload_progress_callback (200 ms time gate + 256 KB
+# byte gate) from before the scheduler unification. Time gate keeps small
+# files from going silent (a single 8 KB chunk fires once and that's it);
+# byte gate caps the broadcast rate on slow LAN where 200 ms covers many
+# chunks. uploaded >= total always emits so the bar closes cleanly even on
+# sub-200 ms files.
+_DISPATCH_PROGRESS_BYTE_STEP = 256 * 1024
+_DISPATCH_PROGRESS_MIN_INTERVAL_SECS = 0.2
+
+
+class _UploadProgressBridge:
+    """Thread-safe bridge from ``upload_file_async`` to the WS broadcaster.
+
+    ``upload_file_async`` runs the FTP transfer in an executor thread and
+    invokes its ``progress_callback`` from that thread, so the callback
+    body cannot ``await`` directly. This bridge captures the asyncio loop
+    at construction (on the scheduler thread) and uses
+    ``run_coroutine_threadsafe`` to hop back. The byte/time throttle
+    matches the legacy background_dispatch.py path 1:1 so the toast feels
+    identical to the pre-#1625 experience.
+
+    Failures inside the emit are swallowed — progress is a UX nicety, the
+    upload itself must not fail because of a WS hiccup.
+    """
+
+    def __init__(self, user_id: int | None, queue_item_id: int):
+        self._user_id = user_id
+        self._queue_item_id = queue_item_id
+        try:
+            self._loop = asyncio.get_running_loop()
+        except RuntimeError:
+            self._loop = None
+        self._last_emit_bytes = 0
+        self._last_emit_monotonic = 0.0
+        self._has_emitted = False
+
+    def __call__(self, bytes_transferred: int, total_bytes: int) -> None:
+        if self._loop is None or total_bytes <= 0:
+            return
+        now = time.monotonic()
+        # Mirrors legacy bg-dispatch: emit if first call OR upload complete
+        # OR 200 ms elapsed OR ≥256 KB transferred since last emit. Two of
+        # the four matter most: first-call so the user sees something even
+        # for sub-chunk-size files; uploaded >= total so the bar locks at
+        # 100% even when the throttle would otherwise eat it.
+        should_emit = (
+            not self._has_emitted
+            or bytes_transferred >= total_bytes
+            or now - self._last_emit_monotonic >= _DISPATCH_PROGRESS_MIN_INTERVAL_SECS
+            or bytes_transferred - self._last_emit_bytes >= _DISPATCH_PROGRESS_BYTE_STEP
+        )
+        if not should_emit:
+            return
+        self._has_emitted = True
+        self._last_emit_bytes = bytes_transferred
+        self._last_emit_monotonic = now
+        try:
+            asyncio.run_coroutine_threadsafe(
+                ws_manager.send_queue_item_upload_progress(
+                    user_id=self._user_id,
+                    queue_item_id=self._queue_item_id,
+                    bytes_transferred=bytes_transferred,
+                    total_bytes=total_bytes,
+                ),
+                self._loop,
+            )
+        except Exception:
+            pass  # progress is best-effort, never block the upload
+
+
 # Bambu firmware states that mean the project_file has actually been accepted
 # and the printer is now processing / running / paused mid-print. Used by the
 # dispatch watchdog (#1370): a transition into one of these states means the
@@ -90,7 +165,6 @@ class PrintScheduler:
         self._check_interval = 30  # seconds
         self._power_on_wait_time = 180  # seconds to wait for printer after power on (3 min)
         self._power_on_check_interval = 10  # seconds between connection checks
-        self._min_drying_seconds = 1800  # 30 minutes minimum before humidity re-check can stop drying
         # Track which printers are currently auto-drying (printer_id -> start timestamp)
         self._drying_in_progress: dict[int, float] = {}
         # Defensive in-memory dispatch hold (#1157): a printer that just received
@@ -160,8 +234,14 @@ class PrintScheduler:
                 )
             items = list(result.scalars().all())
 
-            # Read plate-clear setting once per queue check
-            require_plate_clear = await self._get_bool_setting(db, "require_plate_clear", default=True)
+            # Read plate-clear setting once per queue check. Default MUST be
+            # False to match the schema (SettingsSchema.require_plate_clear
+            # defaults False) and the frontend (toggle + card badge both treat a
+            # missing value as off). When no settings row exists, a True default
+            # here re-enabled the plate-clear gate the UI showed as disabled,
+            # blocking dispatch to FINISH-state printers forever with no UI path
+            # to clear it (#1865).
+            require_plate_clear = await self._get_bool_setting(db, "require_plate_clear", default=False)
 
             if not items:
                 # No pending items — still check auto-drying on idle printers
@@ -810,6 +890,14 @@ class PrintScheduler:
             logger.warning("Cannot compute AMS mapping: printer %s status unavailable", printer_id)
             return None
 
+        # Filament Track Switch (FTS): when installed it routes any AMS slot to
+        # either extruder, so the per-nozzle hard filter below must NOT apply.
+        # Otherwise a print on one nozzle can't use a spool physically loaded in
+        # an AMS on the *other* nozzle, and the matcher falls through to a
+        # same-type wrong-colour spool on the target nozzle — the H2C + FTS
+        # wrong-filament bug (#2186). Mirrors the frontend skip added for #1162.
+        fts_installed = bool(getattr(getattr(status, "fila_switch", None), "installed", False))
+
         # Get filament requirements from source file
         filament_reqs = await self._get_filament_requirements(db, item)
         if not filament_reqs:
@@ -884,7 +972,7 @@ class PrintScheduler:
 
         # Compute mapping: match required filaments to available slots
         return self._match_filaments_to_slots(
-            filament_reqs, loaded_filaments, prefer_lowest, inventory_remain_overrides
+            filament_reqs, loaded_filaments, prefer_lowest, inventory_remain_overrides, fts_installed
         )
 
     def _build_override_direct_mapping(self, force_overrides: list[dict], status) -> list[int] | None:
@@ -1194,6 +1282,7 @@ class PrintScheduler:
         loaded: list[dict],
         prefer_lowest: bool = False,
         inventory_remain_overrides: dict[int, float] | None = None,
+        fts_installed: bool = False,
     ) -> list[int] | None:
         """Match required filaments to loaded filaments and build AMS mapping.
 
@@ -1236,8 +1325,11 @@ class PrintScheduler:
             # Nozzle-aware filtering: restrict to trays on the correct nozzle.
             # Hard filter — cross-nozzle assignment causes print failures
             # ("position of left hotend is abnormal"), so never fall back.
+            # Skipped when an FTS is installed: it routes any AMS slot to either
+            # extruder, so restricting to one nozzle would wrongly exclude the
+            # correct spool sitting in the other nozzle's AMS (#2186).
             req_nozzle_id = req.get("nozzle_id")
-            if req_nozzle_id is not None:
+            if req_nozzle_id is not None and not fts_installed:
                 available = [f for f in available if f.get("extruder_id") == req_nozzle_id]
 
             # Sort by remaining filament (ascending) so lowest-remain spool wins .find().
@@ -1482,6 +1574,17 @@ class PrintScheduler:
             return setting.value.lower() == "true"
         return default
 
+    async def _get_int_setting(self, db: AsyncSession, key: str, default: int) -> int:
+        """Read an int setting; falls back to default on missing/unparseable rows."""
+        result = await db.execute(select(Settings).where(Settings.key == key))
+        setting = result.scalar_one_or_none()
+        if setting and setting.value:
+            try:
+                return int(setting.value)
+            except ValueError:
+                pass
+        return default
+
     async def _get_drying_presets(self, db: AsyncSession) -> dict[str, dict[str, int]]:
         """Get drying presets (user-configured or built-in defaults)."""
         result = await db.execute(select(Settings).where(Settings.key == "drying_presets"))
@@ -1734,33 +1837,29 @@ class PrintScheduler:
                             humidity = int(h_idx)
                         except (ValueError, TypeError):
                             pass
-                # Already drying — check if humidity dropped below threshold (with minimum drying time)
+                # Already drying — let it run to its configured duration (#1892).
+                #
+                # We deliberately do NOT stop drying from a humidity re-check here.
+                # Relative humidity drops steeply in heated air, so the AMS sensor
+                # reads ~15-20% within minutes of the dryer starting even while the
+                # filament is still saturated. A humidity-based early-stop therefore
+                # always fires at the minimum-time floor, truncating both user-started
+                # manual cycles and Bambuddy's own preset-duration dries to ~30 min.
+                # The firmware stops when the configured duration elapses; scheduling
+                # stops (print takes priority, queue no longer needs drying) are
+                # handled separately via _stop_drying().
                 if dry_time > 0:
                     if pid not in self._drying_in_progress:
-                        # Drying we didn't start (manual or from before restart) — track but don't stop
+                        # Drying we didn't start (manual or from before restart) —
+                        # track it so scheduling stops still apply; never auto-stop it.
                         self._drying_in_progress[pid] = time.monotonic()
-                    started_at = self._drying_in_progress[pid]
-                    elapsed = time.monotonic() - started_at
-                    if humidity is not None and humidity <= humidity_threshold and elapsed >= self._min_drying_seconds:
-                        logger.info(
-                            "Auto-drying: printer %d AMS %d — humidity %d%% <= threshold %d%% after %dm, stopping drying",
-                            pid,
-                            ams_id,
-                            humidity,
-                            humidity_threshold,
-                            int(elapsed / 60),
-                        )
-                        printer_manager.send_drying_command(pid, ams_id, temp=0, duration=0, mode=0)
-                    else:
-                        logger.debug(
-                            "Auto-drying: printer %d AMS %d — drying (%dm left, humidity %s%%, elapsed %dm/%dm min)",
-                            pid,
-                            ams_id,
-                            dry_time,
-                            humidity,
-                            int(elapsed / 60),
-                            self._min_drying_seconds // 60,
-                        )
+                    logger.debug(
+                        "Auto-drying: printer %d AMS %d — drying (%dm left, humidity %s%%), letting it run",
+                        pid,
+                        ams_id,
+                        dry_time,
+                        humidity,
+                    )
                     continue
 
                 # Humidity below threshold — no need to start drying
@@ -1866,6 +1965,294 @@ class PrintScheduler:
         result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
         return list(result.scalars().all())
 
+    # Bundled defaults for preheat_filament_targets (#1468). Values are the
+    # chamber-temperature recommendations BambuStudio ships for the matching
+    # filament profile; users can override via Settings → Workflow → Preheat
+    # card. "default" applies when a loaded tray's normalised type isn't in
+    # the map (rare — Bambu RFID-tagged spools always carry a known type).
+    DEFAULT_PREHEAT_FILAMENT_TARGETS: dict[str, int] = {
+        "PLA": 0,
+        "PETG": 0,
+        "PETG-CF": 40,
+        "ABS": 45,
+        "ASA": 45,
+        "PA": 50,
+        "PA-CF": 55,
+        "PC": 50,
+        "PC-FR": 50,
+        "TPU": 0,
+        "PVA": 0,
+        "default": 0,
+    }
+
+    async def _get_preheat_filament_targets(self, db: AsyncSession) -> dict[str, int]:
+        """Parse the user-configured filament→chamber-target map, falling back
+        to DEFAULT_PREHEAT_FILAMENT_TARGETS on missing / malformed JSON. Keys
+        are uppercased and the 'default' fallback is always present in the
+        returned dict so the resolution loop can index it unconditionally."""
+        raw = await self._get_setting(db, "preheat_filament_targets")
+        if not raw:
+            return dict(self.DEFAULT_PREHEAT_FILAMENT_TARGETS)
+        try:
+            parsed = json.loads(raw)
+            if not isinstance(parsed, dict):
+                raise ValueError("not an object")
+        except (json.JSONDecodeError, ValueError) as exc:
+            logger.warning("preheat_filament_targets unparseable, using defaults: %s", exc)
+            return dict(self.DEFAULT_PREHEAT_FILAMENT_TARGETS)
+        # Coerce values to int; drop unparseable rows so a stray string
+        # doesn't crash the loop.
+        out: dict[str, int] = {}
+        for key, value in parsed.items():
+            try:
+                out[str(key).upper()] = int(value)
+            except (TypeError, ValueError):
+                continue
+        if "DEFAULT" not in out:
+            out["DEFAULT"] = self.DEFAULT_PREHEAT_FILAMENT_TARGETS["default"]
+        return out
+
+    @staticmethod
+    def _normalize_filament_type(tray_type: str) -> str:
+        """Reduce the printer's tray_type to a preset-lookup key. Mirrors the
+        existing drying-preset normalisation (split-at-space, upper-case) so
+        the two maps share vocabulary — "PLA Basic" → "PLA", "PA-CF" stays
+        "PA-CF" (no space to split on)."""
+        return tray_type.split()[0].upper() if tray_type else ""
+
+    def _derive_chamber_target(
+        self,
+        printer: Printer,
+        targets: dict[str, int],
+    ) -> int:
+        """Look up the chamber target for each loaded AMS tray and return the
+        max. Returns 0 when no AMS data is available (e.g. external-spool
+        prints) or when every loaded slot maps to 0 — the chamber phase then
+        short-circuits in the main loop.
+
+        Reads from `printer_manager.get_status(...).raw_data['ams']`, which is
+        the same source the dispatcher uses for AMS slot mapping. Empty / RFID-
+        less slots have empty `tray_type` and contribute nothing."""
+        state = printer_manager.get_status(printer.id)
+        if state is None:
+            return 0
+        ams_list = (state.raw_data or {}).get("ams") if state.raw_data else None
+        # Older Bambu firmware nests AMS as {"ams": {"ams": [...]}} — try both.
+        if isinstance(ams_list, dict):
+            ams_list = ams_list.get("ams") or []
+        if not isinstance(ams_list, list):
+            return 0
+        best = 0
+        for ams in ams_list:
+            for tray in (ams.get("tray") or []) if isinstance(ams, dict) else []:
+                normalised = self._normalize_filament_type(tray.get("tray_type") or "")
+                if not normalised:
+                    continue
+                target = targets.get(normalised, targets.get("DEFAULT", 0))
+                if target > best:
+                    best = target
+        return best
+
+    async def _preheat_and_soak(
+        self,
+        db: AsyncSession,
+        item: PrintQueueItem,
+        printer: Printer,
+        archive: PrintArchive | None,
+    ) -> None:
+        """Run the per-printer preheat + heat-soak stage before FTP upload (#1468).
+
+        Resolution order:
+          1. `item.preheat_override` — 'off' skips entirely; 'inherit' falls back
+             to the global `preheat_enabled` setting; 'on' forces the stage on
+             even if the global is off.
+          2. Chamber target — `item.preheat_chamber_target_override` if non-null;
+             else max of `preheat_filament_targets[normalize(t.tray_type)]`
+             across loaded AMS slots; else 0 (skips chamber phase, keeps bed
+             phase + soak timer).
+          3. Three hardware tiers branch the wait loop:
+             - Chamber heater (H2C/H2D/H2DPro/H2S/X2D/X1E via supports_chamber_heater):
+               send M141 to the resolved target, then wait for the chamber sensor
+               to reach it (or the max-wait timeout to elapse).
+             - Chamber sensor only (X1C/P2S via supports_chamber_temp ∧ ¬supports_chamber_heater):
+               no M141; the bed is the only heat source, so we wait for the chamber
+               sensor to rise via bed radiation OR fall through on timeout.
+             - No chamber sensor (P1S/P1P/A1/A1 Mini): no way to verify chamber
+               temperature; the function just heats the bed and holds for the
+               configured soak duration.
+
+        The bed target comes from the archive's parsed metadata
+        (`bed_temperature`); if missing the preheat stage logs and returns
+        without dispatching anything, rather than guessing at a default that
+        might wreck filament setup.
+
+        Failures are logged but never re-raised — preheat is best-effort. A
+        printer that goes offline mid-soak, a refused gcode command, or a
+        missing temperature reading must not turn into a failed queue item; the
+        normal upload + start path runs immediately after this method returns.
+        """
+        override = (getattr(item, "preheat_override", None) or "inherit").lower()
+        if override == "off":
+            return
+        if override == "inherit":
+            enabled = await self._get_bool_setting(db, "preheat_enabled", default=False)
+            if not enabled:
+                return
+        # override == "on" forces the stage on regardless of the global setting.
+
+        max_wait = await self._get_int_setting(db, "preheat_max_wait_seconds", default=900)
+        soak_seconds = await self._get_int_setting(db, "preheat_soak_seconds", default=300)
+
+        # Chamber target resolution:
+        #   1. Explicit per-item override beats everything (user knows best).
+        #   2. Otherwise derive from loaded AMS filament types via the per-
+        #      filament target map. PLA-only print derives 0 → chamber phase
+        #      auto-skips without the user touching anything.
+        explicit_target = getattr(item, "preheat_chamber_target_override", None)
+        if explicit_target is not None and explicit_target > 0:
+            chamber_target = int(explicit_target)
+            chamber_source = "item-override"
+        elif explicit_target == 0:
+            chamber_target = 0  # explicit 0 means "no chamber, even if filament wants it"
+            chamber_source = "item-override-zero"
+        else:
+            targets = await self._get_preheat_filament_targets(db)
+            chamber_target = self._derive_chamber_target(printer, targets)
+            chamber_source = "filament-map"
+
+        bed_target = int(archive.bed_temperature) if archive and archive.bed_temperature else 0
+        if bed_target <= 0:
+            logger.info(
+                "Queue item %s: preheat skipped — archive has no bed_temperature metadata",
+                item.id,
+            )
+            return
+
+        client = printer_manager.get_client(printer.id)
+        if client is None:
+            logger.warning("Queue item %s: preheat skipped — printer client unavailable", item.id)
+            return
+
+        model = printer.model or ""
+        has_heater = supports_chamber_heater(model)
+        has_sensor = supports_chamber_temp(model)
+        do_chamber = chamber_target > 0 and (has_heater or has_sensor)
+
+        logger.info(
+            "Queue item %s: preheat starting — bed=%d°C chamber_target=%d°C (source=%s override=%s "
+            "model=%s has_heater=%s has_sensor=%s) max_wait=%ds soak=%ds",
+            item.id,
+            bed_target,
+            chamber_target if do_chamber else 0,
+            chamber_source,
+            override,
+            model,
+            has_heater,
+            has_sensor,
+            max_wait,
+            soak_seconds,
+        )
+
+        # Dispatch heaters. set_bed_temperature / set_chamber_temperature already
+        # cache the target locally so the polling reads below see consistent
+        # state (firmware MQTT echoes lag by ~1s).
+        try:
+            client.set_bed_temperature(bed_target)
+        except Exception as exc:
+            logger.warning("Queue item %s: preheat bed M140 failed: %s", item.id, exc)
+            return
+
+        # Airduct mode (#1468 follow-up). Models with the cooling/heating flap
+        # (H2C/H2D/H2D Pro/H2S/X2D/P2S) keep the flap whatever the user last
+        # left it on, regardless of M141. Default cooling actively vents the
+        # chamber, so a `chamber_target > 0` print with the flap stuck in
+        # cooling never converges — the heater fights the open exhaust. We
+        # flip the flap BEFORE M141 to "heating" when the preheat wants
+        # chamber heat, and back to "cooling" when it doesn't (PLA-only print
+        # on an H2D that was previously running ABS would otherwise stay in
+        # heating mode and overheat PLA). The current-state read keeps the
+        # command idempotent — no MQTT chatter when the flap is already where
+        # we want it.
+        if supports_airduct(model):
+            desired_airduct = "heating" if chamber_target > 0 else "cooling"
+            desired_id = 1 if desired_airduct == "heating" else 0
+            current_state = printer_manager.get_status(printer.id)
+            current_airduct = getattr(current_state, "airduct_mode", None) if current_state else None
+            if current_airduct != desired_id:
+                try:
+                    client.set_airduct_mode(desired_airduct)
+                except Exception as exc:
+                    logger.warning(
+                        "Queue item %s: preheat airduct %s mode failed: %s",
+                        item.id,
+                        desired_airduct,
+                        exc,
+                    )
+
+        if do_chamber and has_heater:
+            try:
+                client.set_chamber_temperature(chamber_target)
+            except Exception as exc:
+                logger.warning("Queue item %s: preheat chamber M141 failed: %s", item.id, exc)
+
+        # Wait for convergence. Bed warm-up is fast (~5 min from cold); chamber
+        # via M141 takes a few minutes; chamber via bed radiation can take 20+.
+        # Poll every 3s — frequent enough for responsive logging without
+        # spamming the MQTT state stream. The "converged" predicate is:
+        #   bed reached target (within 2°C tolerance for floating-point + heater hysteresis),
+        #   AND
+        #   chamber phase satisfied (no chamber phase, no sensor, or sensor reached target).
+        BED_TOLERANCE = 2.0
+        CHAMBER_TOLERANCE = 2.0
+        POLL_INTERVAL = 3.0
+        deadline = asyncio.get_event_loop().time() + max_wait
+
+        while True:
+            state = printer_manager.get_status(printer.id)
+            if state is None:
+                logger.warning("Queue item %s: preheat lost state during wait", item.id)
+                break
+
+            temps = state.temperatures or {}
+            bed_now = float(temps.get("bed", 0) or 0)
+            chamber_now = float(temps.get("chamber", 0) or 0)
+            bed_ok = bed_now >= bed_target - BED_TOLERANCE
+
+            if not do_chamber:
+                chamber_ok = True  # phase disabled or model has neither sensor nor heater
+            elif not has_sensor:
+                chamber_ok = True  # P1S etc — can't read, rely on soak timer only
+            else:
+                chamber_ok = chamber_now >= chamber_target - CHAMBER_TOLERANCE
+
+            if bed_ok and chamber_ok:
+                logger.info(
+                    "Queue item %s: preheat target reached (bed=%.1f chamber=%.1f) — entering soak",
+                    item.id,
+                    bed_now,
+                    chamber_now,
+                )
+                break
+
+            if asyncio.get_event_loop().time() >= deadline:
+                logger.info(
+                    "Queue item %s: preheat max_wait reached (bed=%.1f/%d chamber=%.1f/%d) — falling through to soak",
+                    item.id,
+                    bed_now,
+                    bed_target,
+                    chamber_now,
+                    chamber_target if do_chamber else 0,
+                )
+                break
+
+            await asyncio.sleep(POLL_INTERVAL)
+
+        if soak_seconds > 0:
+            logger.info("Queue item %s: preheat soak — holding for %ds", item.id, soak_seconds)
+            await asyncio.sleep(soak_seconds)
+
+        logger.info("Queue item %s: preheat complete — proceeding to upload", item.id)
+
     async def _power_on_and_wait(self, plug: SmartPlug, printer_id: int, db: AsyncSession) -> bool:
         """Turn on smart plug and wait for printer to connect.
 
@@ -1955,30 +2342,20 @@ class PrintScheduler:
         return prev_item.status in ("completed", "cancelled")
 
     async def _power_off_if_needed(self, db: AsyncSession, item: PrintQueueItem):
-        """Power off printer if auto_off_after is enabled (waits for cooldown)."""
+        """Schedule power-off if the queue item enabled auto_off_after.
+
+        Delegates to the smart-plug manager so the off honours each plug's
+        configured strategy (time delay or temperature threshold), is cancelled
+        if the printer starts printing again, and never cuts power on a loaded
+        print (#1890). Previously this hardcoded a 50°C / 600s cooldown wait and
+        powered off on the timeout regardless of print state.
+        """
         if not item.auto_off_after:
             return
-
-        plugs = await self._get_smart_plugs(db, item.printer_id)
-        plug_ids = [p.id for p in plugs if p.enabled]
-        if plug_ids:
-            logger.info("Auto-off: Waiting for printer %s to cool down before power off...", item.printer_id)
-            # Wait for cooldown (up to 10 minutes)
-            await printer_manager.wait_for_cooldown(item.printer_id, target_temp=50.0, timeout=600)
-            # Re-fetch plugs in a fresh session after the long cooldown wait
-            async with async_session() as new_db:
-                for plug_id in plug_ids:
-                    try:
-                        result = await new_db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
-                        plug = result.scalar_one_or_none()
-                        if plug and plug.enabled:
-                            logger.info("Auto-off: Powering off plug '%s' for printer %s", plug.name, item.printer_id)
-                            service = await smart_plug_manager.get_service_for_plug(plug, new_db)
-                            await service.turn_off(plug)
-                    except Exception as e:
-                        logger.warning(
-                            "Auto-off: Failed to power off plug %s for printer %s: %s", plug_id, item.printer_id, e
-                        )
+        try:
+            await smart_plug_manager.schedule_off_after_queue_job(item.printer_id, db)
+        except Exception as e:
+            logger.warning("Auto-off: Failed to schedule power-off for printer %s: %s", item.printer_id, e)
 
     async def _get_job_name(self, db: AsyncSession, item: PrintQueueItem) -> str:
         """Get a human-readable name for a queue item."""
@@ -2112,6 +2489,22 @@ class PrintScheduler:
             await self._power_off_if_needed(db, item)
             return
 
+        # Cancel-while-dispatching race (#1853): the scheduler's snapshot of
+        # `items` was taken at the top of check_queue, but the user can /cancel
+        # any pending row in the gap before we reach this point. Re-read the
+        # row and bail out cleanly instead of starting an FTP upload for a row
+        # that's already cancelled. The atomic CAS at the pending→printing
+        # transition (below, before start_print) is the load-bearing guard;
+        # this is the early-exit optimisation that avoids wasted FTP I/O.
+        await db.refresh(item)
+        if item.status != "pending":
+            logger.info(
+                "Queue item %s no longer pending (status=%s) — aborting dispatch",
+                item.id,
+                item.status,
+            )
+            return
+
         # Determine source: archive or library file
         archive = None
         library_file = None
@@ -2178,7 +2571,12 @@ class PrintScheduler:
                         await db.delete(library_file)
                         file_path = settings.base_dir / archive.file_path
                         filename = archive.filename
-                    await db.flush()
+                    # Commit, not flush — flush opens the SQLite write
+                    # transaction (item.archive_id update + library_file
+                    # delete) and would hold the WAL writer lock through the
+                    # FTP upload below, causing "database is locked" cascades
+                    # for sensor history + concurrent cancels (#1853).
+                    await db.commit()
                     logger.info(
                         "Queue item %s: Created archive %s from library file %s",
                         item.id,
@@ -2231,6 +2629,13 @@ class PrintScheduler:
             await self._power_off_if_needed(db, item)
             return
 
+        # Preheat / heat-soak (#1468) — fires before upload so the printer's
+        # bed (and chamber, if applicable) is at temperature when the firmware
+        # starts the actual print routine. Best-effort: any failure logs and
+        # falls through to the normal upload+start path rather than turning a
+        # configuration issue into a failed queue item.
+        await self._preheat_and_soak(db, item, printer, archive)
+
         # G-code injection for auto-print systems (#422)
         injected_path = None
         if item.gcode_injection:
@@ -2285,6 +2690,28 @@ class PrintScheduler:
         except Exception as e:
             logger.debug("Queue item %s: Delete failed (may not exist): %s", item.id, e)
 
+        # Dispatch toast — announce the upload start with the total byte
+        # count so the frontend can render an honest progress bar.
+        toast_uid = item.created_by_id
+        toast_file_name = filename.replace(".gcode.3mf", "").replace(".3mf", "")
+        try:
+            total_bytes = file_path.stat().st_size
+        except OSError:
+            total_bytes = 0
+        try:
+            await ws_manager.send_queue_item_uploading(
+                user_id=toast_uid,
+                queue_item_id=item.id,
+                printer_id=item.printer_id,
+                printer_name=printer.name,
+                file_name=toast_file_name,
+                total_bytes=total_bytes,
+            )
+        except Exception:
+            pass  # toast is best-effort
+
+        progress_bridge = _UploadProgressBridge(toast_uid, item.id)
+
         try:
             if ftp_retry_enabled:
                 uploaded = await with_ftp_retry(
@@ -2295,6 +2722,7 @@ class PrintScheduler:
                     remote_path,
                     socket_timeout=ftp_timeout,
                     printer_model=printer.model,
+                    progress_callback=progress_bridge,
                     max_retries=ftp_retry_count,
                     retry_delay=ftp_retry_delay,
                     operation_name=f"Upload print to {printer.name}",
@@ -2307,6 +2735,7 @@ class PrintScheduler:
                     remote_path,
                     socket_timeout=ftp_timeout,
                     printer_model=printer.model,
+                    progress_callback=progress_bridge,
                 )
         except Exception as e:
             uploaded = False
@@ -2338,6 +2767,15 @@ class PrintScheduler:
                 reason="Failed to upload file to printer",
                 db=db,
             )
+            try:
+                await ws_manager.send_queue_item_failed(
+                    user_id=toast_uid,
+                    queue_item_id=item.id,
+                    printer_id=item.printer_id,
+                    reason="upload_failed",
+                )
+            except Exception:
+                pass
             await self._power_off_if_needed(db, item)
             return
 
@@ -2375,9 +2813,57 @@ class PrintScheduler:
         # If we crash after this commit but before start_print(), the item will be
         # in "printing" status without actually printing - but that's safer than
         # accidentally reprinting the same file hours later.
-        item.status = "printing"
-        item.started_at = datetime.now(timezone.utc)
+        #
+        # Atomic CAS (#1853): a user pressing /cancel mid-dispatch (between the
+        # initial pending read at the top of check_queue and this point) flips
+        # the row to "cancelled" in a separate session. Without the WHERE
+        # status='pending' clause, the unconditional update here would silently
+        # overwrite that cancellation and we'd ship the MQTT start_print below
+        # — printer obeys, user sees "I pressed cancel and the print started".
+        # rowcount==0 means the user won the race; bail out, best-effort delete
+        # the file we just uploaded, do NOT send start_print.
+        now_utc = datetime.now(timezone.utc)
+        cas = await db.execute(
+            update(PrintQueueItem)
+            .where(PrintQueueItem.id == item.id)
+            .where(PrintQueueItem.status == "pending")
+            .values(status="printing", started_at=now_utc)
+        )
         await db.commit()
+        if cas.rowcount == 0:
+            logger.info(
+                "Queue item %s no longer pending at print-command time "
+                "(cancelled or removed mid-dispatch) — aborting before MQTT send (#1853)",
+                item.id,
+            )
+            try:
+                await delete_file_async(
+                    printer.ip_address,
+                    printer.access_code,
+                    remote_path,
+                    socket_timeout=ftp_timeout,
+                    printer_model=printer.model,
+                )
+            except Exception as cleanup_err:
+                logger.debug(
+                    "Queue item %s: best-effort cleanup of uploaded file failed: %s",
+                    item.id,
+                    cleanup_err,
+                )
+            try:
+                await ws_manager.send_queue_item_failed(
+                    user_id=toast_uid,
+                    queue_item_id=item.id,
+                    printer_id=item.printer_id,
+                    reason="cancelled_mid_dispatch",
+                )
+            except Exception:
+                pass
+            return
+        # Sync the in-memory item so subsequent code that reads item.status /
+        # item.started_at sees the values we just persisted.
+        item.status = "printing"
+        item.started_at = now_utc
 
         for cleanup_path in cleanup_disk_paths:
             try:
@@ -2439,6 +2925,13 @@ class PrintScheduler:
 
         if started:
             logger.info("Queue item %s: Print started successfully - %s", item.id, filename)
+            # No dispatch-toast event here: the legacy bg-dispatch path kept
+            # status='processing' from upload start until the printer acked
+            # (or timed out). The frontend derives "Awaiting printer…" purely
+            # from upload_progress_pct >= 99.9; an explicit 'dispatched' WS
+            # event would push the status chip out of 'PROCESSING' prematurely
+            # — which is exactly what the screenshot at #1625-followup
+            # complained about.
 
             # Register the local 3MF in the cover-cache so /cover skips FTP
             # (#1166 follow-up). file_path was resolved earlier from either the
@@ -2468,6 +2961,7 @@ class PrintScheduler:
                         pre_state,
                         pre_subtask_id,
                         pre_gcode_file,
+                        created_by_id=toast_uid,
                     ),
                     name=f"watchdog-print-start-{item.id}",
                 )
@@ -2533,6 +3027,15 @@ class PrintScheduler:
                 reason="Failed to send print command to printer - check printer connection and status",
                 db=db,
             )
+            try:
+                await ws_manager.send_queue_item_failed(
+                    user_id=toast_uid,
+                    queue_item_id=item.id,
+                    printer_id=item.printer_id,
+                    reason="start_command_failed",
+                )
+            except Exception:
+                pass
 
             await self._power_off_if_needed(db, item)
 
@@ -2546,6 +3049,7 @@ class PrintScheduler:
         timeout: float = 90.0,
         phase_b_timeout: float = 180.0,
         poll_interval: float = 3.0,
+        created_by_id: int | None = None,
     ) -> None:
         """Revert a queue item if the printer never acknowledges the start command.
 
@@ -2597,6 +3101,14 @@ class PrintScheduler:
                 # would otherwise look like "command landed" and leave the
                 # queue item stuck in 'printing' forever (#1370).
                 scheduler._release_dispatch_hold(printer_id)
+                try:
+                    await ws_manager.send_queue_item_acked(
+                        user_id=created_by_id,
+                        queue_item_id=queue_item_id,
+                        printer_id=printer_id,
+                    )
+                except Exception:
+                    pass
                 return
             if pre_subtask_id is not None and status.subtask_id is not None and status.subtask_id != pre_subtask_id:
                 # Phase A exit — printer accepted the file (subtask_id flipped
@@ -2618,6 +3130,14 @@ class PrintScheduler:
                 last_status = status
                 if status.state in _ACTIVE_PRINT_STATES:
                     scheduler._release_dispatch_hold(printer_id)
+                    try:
+                        await ws_manager.send_queue_item_acked(
+                            user_id=created_by_id,
+                            queue_item_id=queue_item_id,
+                            printer_id=printer_id,
+                        )
+                    except Exception:
+                        pass
                     return
 
         # No active-state transition. Revert the item so the scheduler can retry.

+ 63 - 0
backend/app/services/printer_manager.py

@@ -122,6 +122,51 @@ def supports_chamber_heater(model: str | None) -> bool:
     return model.strip().upper() in CHAMBER_HEATER_MODELS
 
 
+# Models with a cooling / heating airduct flap. Same set as the frontend
+# PrintersPage airduct-toggle whitelist (P2S, X2D, H2D, H2C, H2S, H2D Pro).
+# X1E has a chamber heater but NO airduct flap — the warm-air recirculation
+# happens via the fixed front-door inlet, so no `set_airduct` command is
+# needed (and the firmware ignores it). P2S has an airduct but no heater —
+# the flap manages chamber airflow even without an active heater. The
+# intersection (chamber heater AND airduct) is what the preheat stage cares
+# about: when M141 fires we also need to assert heating mode, otherwise the
+# default cooling mode actively fights the chamber heater.
+CHAMBER_AIRDUCT_MODELS = frozenset(
+    [
+        # Display names
+        "P2S",
+        "X2D",
+        "H2C",
+        "H2D",
+        "H2DPRO",
+        "H2S",
+        # Internal codes (from MQTT/SSDP)
+        "N7",  # P2S
+        "N6",  # X2D
+        "O1C",  # H2C
+        "O1C2",  # H2C dual-nozzle variant
+        "O1D",  # H2D
+        "O1E",  # H2D Pro
+        "O2D",  # H2D Pro alternate code
+        "O1S",  # H2S
+    ]
+)
+
+
+def supports_airduct(model: str | None) -> bool:
+    """Check if a printer model has a cooling / heating airduct mode toggle.
+
+    Mirrors the frontend PrintersPage `['P2S', 'X2D', 'H2D', 'H2C', 'H2S']`
+    + H2D Pro whitelist. Distinct from `supports_chamber_heater` — P2S has
+    the airduct toggle but no active heater, and X1E has the heater but no
+    airduct. The preheat stage cares about the intersection (heater AND
+    airduct) so it can flip the flap to heating before energising M141.
+    """
+    if not model:
+        return False
+    return model.strip().upper() in CHAMBER_AIRDUCT_MODELS
+
+
 def has_stg_cur_idle_bug(model: str | None) -> bool:
     """Check if a printer model may incorrectly report stg_cur=0 when idle.
 
@@ -557,6 +602,24 @@ class PrinterManager:
             return client.state
         return None
 
+    # Gcode states in which a job is loaded / in progress and cutting power
+    # would ruin the print. PAUSE is included on purpose — a paused print is
+    # still loaded on the bed. Used by the smart-plug auto-off guard (#1890) so
+    # a re-print started from the touchscreen isn't killed mid-print.
+    ACTIVE_PRINT_STATES = ("RUNNING", "PAUSE", "PREPARE", "SLICING")
+
+    def is_print_active(self, printer_id: int) -> bool:
+        """True when the printer currently has a print loaded / in progress.
+
+        Returns False when disconnected or in any idle/terminal state
+        (IDLE / FINISH / FAILED / unknown), so callers fail *open* only for
+        the safe "nothing is printing" case. #1890.
+        """
+        state = self.get_status(printer_id)
+        if not state or not state.connected:
+            return False
+        return state.state in self.ACTIVE_PRINT_STATES
+
     def get_model(self, printer_id: int) -> str | None:
         """Get the cached model for a printer."""
         return self._models.get(printer_id)

+ 9 - 1
backend/app/services/slicer_3mf_convert.py

@@ -264,11 +264,19 @@ def substitute_unused_plate_filaments(source_3mf_bytes: bytes, plate_id: int | N
         return items
     # Local import keeps the bytes->ZipFile boundary in this module and
     # avoids dragging zipfile into every caller.
-    from backend.app.utils.threemf_tools import extract_plate_extruder_set_from_3mf
+    from backend.app.utils.threemf_tools import (
+        extract_plate_extruder_set_from_3mf,
+        extract_support_filament_slots_from_3mf,
+    )
 
     try:
         with zipfile.ZipFile(BytesIO(source_3mf_bytes), "r") as zf:
+            # Geometry-derived slots (per-object metadata + paint_color)
+            # plus process-derived support-filament slots. Supports aren't
+            # attached to object geometry so the geometry pass alone
+            # misses PVA-in-support-slot setups (#1881).
             used = extract_plate_extruder_set_from_3mf(zf, plate_id)
+            used |= extract_support_filament_slots_from_3mf(zf)
     except (zipfile.BadZipFile, OSError) as exc:
         logger.warning("Plate-filament parse failed (%s); leaving filament list unchanged", exc)
         return items

+ 98 - 7
backend/app/services/smart_plug_manager.py

@@ -227,13 +227,16 @@ class SmartPlugManager:
                 logger.debug("Smart plug '%s' is disabled, skipping auto-on", plug.name)
                 continue
 
+            # Cancel any pending off task FIRST — a re-print must abort a
+            # scheduled auto-off regardless of the plug's auto_on setting
+            # (#1890). Previously this lived behind the auto_on gate, so a plug
+            # with auto_on disabled kept its pending off and cut power mid-print.
+            self._cancel_pending_off(plug.id)
+
             if not plug.auto_on:
                 logger.debug("Smart plug '%s' auto_on is disabled", plug.name)
                 continue
 
-            # Cancel any pending off task
-            self._cancel_pending_off(plug.id)
-
             # Turn on the plug
             logger.info("Print started on printer %s, turning on plug '%s'", printer_id, plug.name)
             try:
@@ -289,10 +292,51 @@ class SmartPlugManager:
                 plug.name,
             )
 
-            if plug.off_delay_mode == "time":
-                self._schedule_delayed_off(plug, printer_id, plug.off_delay_minutes * 60)
-            elif plug.off_delay_mode == "temperature":
-                self._schedule_temp_based_off(plug, printer_id, plug.off_temp_threshold)
+            self._schedule_off_per_mode(plug, printer_id)
+
+    def _schedule_off_per_mode(self, plug: "SmartPlug", printer_id: int):
+        """Schedule an auto-off using the plug's configured off strategy.
+
+        Honours the per-plug ``off_delay_mode`` — ``time`` waits
+        ``off_delay_minutes``; ``temperature`` waits until the nozzle drops
+        below ``off_temp_threshold`` (#1890 — the queue/scheduler auto-off
+        paths used to hardcode 50°C / 600s and ignore these settings). Both
+        branches register a cancellable task in ``_pending_off``, so a re-print
+        cancels the pending off via :meth:`on_print_start`.
+        """
+        if plug.off_delay_mode == "temperature":
+            self._schedule_temp_based_off(plug, printer_id, plug.off_temp_threshold)
+        else:
+            # Default / "time": also the safe fallback for any unexpected value.
+            self._schedule_delayed_off(plug, printer_id, plug.off_delay_minutes * 60)
+
+    async def schedule_off_after_queue_job(self, printer_id: int, db: AsyncSession):
+        """Schedule auto-off for a printer after a queue job that opted in.
+
+        The print-queue "auto off after this job" toggle (`auto_off_after`) is
+        a per-job override, independent of the plug's global ``auto_off`` flag —
+        so unlike :meth:`on_print_complete` this does NOT gate on ``plug.auto_off``.
+        It still honours ``enabled`` and skips HA-script entities (which can only
+        be triggered, not turned off), and uses each plug's configured off
+        strategy via :meth:`_schedule_off_per_mode`. Replaces the three inline
+        ``wait_for_cooldown(50°C, 600s)`` blocks that ignored plug settings,
+        fired on the cooldown *timeout* regardless of print state, and could not
+        be cancelled by a re-print (#1890).
+        """
+        plugs = await self._get_plugs_for_printer(printer_id, db)
+        for plug in plugs:
+            if not plug.enabled:
+                logger.debug("Smart plug '%s' is disabled, skipping queue auto-off", plug.name)
+                continue
+            if plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."):
+                logger.debug("Smart plug '%s' is a HA script entity, skipping queue auto-off", plug.name)
+                continue
+            logger.info(
+                "Queue job finished on printer %s, scheduling turn-off for plug '%s'",
+                printer_id,
+                plug.name,
+            )
+            self._schedule_off_per_mode(plug, printer_id)
 
     async def on_drying_complete(self, printer_id: int, db: AsyncSession):
         """Schedule turn-off for plugs flagged ``auto_off_after_drying`` when
@@ -381,6 +425,21 @@ class SmartPlugManager:
         try:
             await asyncio.sleep(delay_seconds)
 
+            # #1890: never cut power while a print is loaded / running. The
+            # delay fires unconditionally after N minutes, so if the user
+            # re-started (or reprinted) in the meantime, the printer is active
+            # again — skip the off and clear the pending flag rather than
+            # killing the print mid-way.
+            if printer_manager.is_print_active(printer_id):
+                logger.info(
+                    "Skipping auto-off for plug %s: printer %s is printing again (state=%s)",
+                    plug_id,
+                    printer_id,
+                    getattr(printer_manager.get_status(printer_id), "state", "unknown"),
+                )
+                await self._mark_auto_off_pending(plug_id, False)
+                return
+
             # Create a minimal plug-like object for the service
             class PlugInfo:
                 def __init__(self):
@@ -489,6 +548,22 @@ class SmartPlugManager:
                         )
 
                     if max_nozzle_temp < temp_threshold:
+                        # #1890: the nozzle can dip below the threshold between
+                        # a finished print and a fresh one starting (e.g. a
+                        # touchscreen reprint during the PREPARE/heating phase).
+                        # Guard the turn-off so we never cut power on a loaded
+                        # print; keep polling until it's genuinely idle again.
+                        if printer_manager.is_print_active(printer_id):
+                            logger.info(
+                                "Deferring temp-based auto-off for plug %s: printer %s is printing again (state=%s)",
+                                plug_id,
+                                printer_id,
+                                getattr(printer_manager.get_status(printer_id), "state", "unknown"),
+                            )
+                            await asyncio.sleep(check_interval)
+                            elapsed += check_interval
+                            continue
+
                         # All nozzles are below threshold, turn off
                         class PlugInfo:
                             def __init__(self):
@@ -626,6 +701,22 @@ class SmartPlugManager:
 
                     logger.info("Resuming pending auto-off for plug '%s' (printer %s)", plug.name, plug.printer_id)
 
+                    # #1890: never resume a power-off onto a live print. If the
+                    # printer started a new print during the downtime, the stale
+                    # pending off must be dropped, not executed — same guard the
+                    # live off-executors use.
+                    if printer_manager.is_print_active(plug.printer_id):
+                        logger.info(
+                            "Not resuming auto-off for plug '%s': printer %s is printing (state=%s); clearing pending",
+                            plug.name,
+                            plug.printer_id,
+                            getattr(printer_manager.get_status(plug.printer_id), "state", "unknown"),
+                        )
+                        plug.auto_off_pending = False
+                        plug.auto_off_pending_since = None
+                        await db.commit()
+                        continue
+
                     # Resume the appropriate off mode
                     if plug.off_delay_mode == "temperature":
                         self._schedule_temp_based_off(plug, plug.printer_id, plug.off_temp_threshold)

+ 228 - 17
backend/app/services/spoolman_tracking.py

@@ -569,6 +569,135 @@ async def _report_spool_usage_for_slots(
     return spools_updated
 
 
+async def _report_spool_usage_split_by_tray_changes(
+    client,
+    filament_usage: list[dict],
+    tray_changes: list[tuple[int, int]],
+    ams_trays: dict[int, dict],
+    layer_usage: dict[int, dict[int, float]] | None,
+    filament_properties: dict | None,
+    total_layers: int,
+    last_layer_num: int,
+    method_label: str,
+    printer_serial: str,
+    printer_id: int,
+    slot_colors_out: dict[int, str] | None = None,
+) -> tuple[int, set[int]]:
+    """Split each slot's grams across ``tray_changes`` and charge per-segment.
+
+    Mirrors ``usage_tracker`` Path 1's tray-switch branch so Spoolman and
+    the internal Spool inventory attribute mid-print AMS-backup switches
+    identically (#1793 — reporter's origin spool was over-charged the
+    whole print because this path didn't exist). ``compute_tray_split_grams``
+    holds the shared segment-math; this function wraps the per-segment
+    spool resolution + ``use_spool`` sink for the Spoolman side.
+
+    Returns ``(spools_updated, handled_global_tray_ids)`` — the caller
+    passes ``handled_global_tray_ids`` into the remain-delta fallback so
+    a tray attributed here is not double-charged there.
+    """
+    from backend.app.utils.tray_split import compute_tray_split_grams
+
+    spools_updated = 0
+    handled_global_tray_ids: set[int] = set()
+
+    for usage in filament_usage:
+        slot_id = usage.get("slot_id", 0)
+        total_weight = usage.get("used_g", 0)
+        if total_weight <= 0 or slot_id <= 0:
+            continue
+
+        props = (filament_properties or {}).get(str(slot_id)) or (filament_properties or {}).get(slot_id) or {}
+        segments = compute_tray_split_grams(
+            tray_changes=tray_changes,
+            total_weight=float(total_weight),
+            slot_id=slot_id,
+            layer_usage=layer_usage,
+            density=float(props.get("density", 1.24)),
+            diameter=float(props.get("diameter", 1.75)),
+            total_layers=total_layers,
+            last_layer_num=last_layer_num,
+        )
+
+        for seg_idx, tray_global, segment_grams in segments:
+            if segment_grams <= 0:
+                continue
+
+            # Mark this tray as handled BEFORE the resolution attempt so
+            # remain-delta doesn't double-charge it, even if we fail to
+            # find a spool below. Matches usage_tracker behaviour: the
+            # tray was physically fed from during this print, whether or
+            # not Spoolman happens to have a matching row.
+            handled_global_tray_ids.add(tray_global)
+
+            tray_info = ams_trays.get(tray_global) or {}
+            spool_id_to_use: int | None = None
+            resolution_path = ""
+            spool_color_hex: str | None = None
+
+            spool_tag = _resolve_spool_tag(tray_info, printer_serial, tray_global) if tray_info else ""
+            if spool_tag:
+                spool = await client.find_spool_by_tag(spool_tag)
+                if spool:
+                    spool_id_to_use = spool["id"]
+                    resolution_path = "tag"
+                    spool_color_hex = (spool.get("filament") or {}).get("color_hex")
+
+            if spool_id_to_use is None:
+                seg_ams_id, seg_tray_id = _global_tray_id_to_ams_slot(tray_global)
+                spool_id_to_use = await _resolve_spool_id_via_slot_assignment(printer_id, seg_ams_id, seg_tray_id)
+                if spool_id_to_use is not None:
+                    resolution_path = "slot-assignment"
+
+            if spool_id_to_use is None:
+                logger.info(
+                    "[SPOOLMAN] Split slot %s seg %s tray=%d: no spool resolved — %.2fg lost from split accounting",
+                    slot_id,
+                    seg_idx,
+                    tray_global,
+                    segment_grams,
+                )
+                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
+
+            try:
+                await client.use_spool(spool_id_to_use, round(segment_grams, 2))
+                logger.info(
+                    "[SPOOLMAN] %s: slot %s seg %s tray=%d: %.2fg -> spool %s (via %s)",
+                    method_label,
+                    slot_id,
+                    seg_idx,
+                    tray_global,
+                    segment_grams,
+                    spool_id_to_use,
+                    resolution_path,
+                )
+                spools_updated += 1
+            except (SpoolmanNotFoundError, SpoolmanClientError, SpoolmanUnavailableError) as exc:
+                logger.warning(
+                    "[SPOOLMAN] Split slot %s seg %s: failed to record usage for spool %s: %s",
+                    slot_id,
+                    seg_idx,
+                    spool_id_to_use,
+                    exc,
+                )
+
+    return spools_updated, handled_global_tray_ids
+
+
 async def _report_partial_usage(
     printer_id: int,
     tracking,
@@ -789,6 +918,13 @@ async def report_usage(printer_id: int, archive_id: int):
         ams_trays = {int(k): v for k, v in (tracking.ams_trays or {}).items()}
         slot_to_tray = tracking.slot_to_tray
         tray_remain_start = tracking.tray_remain_start or {}
+        # ``layer_usage`` and ``filament_properties`` were added later than
+        # the base tracking fields; use ``getattr`` so tests that stub
+        # ``tracking`` as a lightweight SimpleNamespace stay valid, and
+        # historic ORM rows loaded without these columns can't AttributeError
+        # on read.
+        layer_usage_raw = getattr(tracking, "layer_usage", None) or {}
+        filament_properties = getattr(tracking, "filament_properties", None) or {}
         printer_serial = await _get_printer_serial(printer_id)
 
         # Delete tracking row (we're done with it)
@@ -809,29 +945,104 @@ async def report_usage(printer_id: int, archive_id: int):
             logger.warning("[SPOOLMAN] Not reachable for usage reporting")
             return
 
+        # Consult the live printer state for the tray-change log written by
+        # ``bambu_mqtt.py`` on every mid-print ``tray_now`` change (#957).
+        # When there's more than one entry, the print traversed >1 AMS tray
+        # and the split path attributes each segment to the tray that was
+        # loaded at the time — matches the internal Spool inventory writer
+        # in ``usage_tracker.py``. Without this, an AMS-backup runout switch
+        # charges the whole slot to the origin spool and pushes it past
+        # ``initial_weight`` (#1793).
+        #
+        # Split only for SINGLE-slot prints — same gate as
+        # ``usage_tracker.py:1002``. Multi-slot (multi-colour) prints
+        # naturally cycle trays for every colour change, so splitting each
+        # slot's grams across ALL tray_change_log entries would attribute
+        # slot 1's grams to the segments where slot 2's tray was loaded and
+        # vice versa. Multi-slot prints fall through to the existing
+        # single-tray path (which uses the stable ``slot_to_tray`` mapping).
+        nonzero_slots = [u for u in filament_usage if u.get("used_g", 0) > 0]
+        tray_changes: list[tuple[int, int]] = []
+        _state = None
+        if len(nonzero_slots) == 1:
+            from backend.app.services.printer_manager import printer_manager as _pm
+
+            _state = _pm.get_status(printer_id)
+            if _state is not None:
+                tray_changes = list(getattr(_state, "tray_change_log", []) or [])
+        _total_layers = int(getattr(_state, "total_layers", 0) or 0) if _state else 0
+        _current_layer = int(getattr(_state, "layer_num", 0) or 0) if _state else 0
+        # For the linear-fallback denominator when total_layers is 0 (P1S
+        # firmware resets it at print end). At completion the current layer
+        # is the print's last valid layer.
+        _layer_denom_hint = _total_layers or _current_layer
+
         slot_colors: dict[int, str] = {}
         handled_global_tray_ids: set[int] = set()
         spools_updated = 0
 
         # --- Path 1: 3MF per-slot estimates -----------------------------
         if filament_usage:
-            logger.info("[SPOOLMAN] Reporting per-filament usage for archive %s", archive_id)
-            usage_items = [(u.get("slot_id", 0), u.get("used_g", 0)) for u in filament_usage]
-            spools_updated = await _report_spool_usage_for_slots(
-                client,
-                usage_items,
-                ams_trays,
-                slot_to_tray,
-                f"Archive {archive_id}",
-                printer_serial,
-                printer_id=printer_id,
-                slot_colors_out=slot_colors,
-            )
-            # Track which physical slots the 3MF path already covered so
-            # Path 2 doesn't double-charge them.
-            for u in filament_usage:
-                slot_id = u.get("slot_id", 0)
-                handled_global_tray_ids.add(_resolve_global_tray_id(slot_id, slot_to_tray, ams_trays))
+            if len(tray_changes) > 1:
+                # Tray-split path — attribute per-segment to the tray that
+                # was loaded at that time.
+                logger.info(
+                    "[SPOOLMAN] Reporting per-filament usage for archive %s with tray-split "
+                    "(tray_change_log=%s, denom_layers=%d)",
+                    archive_id,
+                    tray_changes,
+                    _layer_denom_hint,
+                )
+                # ``tracking.layer_usage`` was serialized to JSON so int keys
+                # come back as strings. Restore them for the split math.
+                layer_usage = None
+                if layer_usage_raw:
+                    try:
+                        layer_usage = {
+                            int(layer): {int(fid): mm for fid, mm in filaments.items()}
+                            for layer, filaments in layer_usage_raw.items()
+                        }
+                    except (TypeError, ValueError, AttributeError):
+                        # AttributeError catches ``inner.items()`` when the
+                        # inner value isn't dict-shaped (corrupt JSON row).
+                        # Missing gcode falls through to the linear-ratio
+                        # branch inside ``compute_tray_split_grams`` — still
+                        # gives a correct split, just less precise.
+                        layer_usage = None
+                split_updated, split_handled = await _report_spool_usage_split_by_tray_changes(
+                    client,
+                    filament_usage,
+                    tray_changes,
+                    ams_trays,
+                    layer_usage,
+                    filament_properties,
+                    _total_layers,
+                    _layer_denom_hint,
+                    f"Archive {archive_id}",
+                    printer_serial,
+                    printer_id=printer_id,
+                    slot_colors_out=slot_colors,
+                )
+                spools_updated += split_updated
+                handled_global_tray_ids |= split_handled
+            else:
+                logger.info("[SPOOLMAN] Reporting per-filament usage for archive %s", archive_id)
+                usage_items = [(u.get("slot_id", 0), u.get("used_g", 0)) for u in filament_usage]
+                spools_updated = await _report_spool_usage_for_slots(
+                    client,
+                    usage_items,
+                    ams_trays,
+                    slot_to_tray,
+                    f"Archive {archive_id}",
+                    printer_serial,
+                    printer_id=printer_id,
+                    slot_colors_out=slot_colors,
+                )
+                # Track which physical slots the 3MF path already covered so
+                # Path 2 doesn't double-charge them.
+                for u in filament_usage:
+                    slot_id = u.get("slot_id", 0)
+                    handled_global_tray_ids.add(_resolve_global_tray_id(slot_id, slot_to_tray, ams_trays))
 
         # --- Path 2: AMS remain%-delta for slots 3MF didn't cover -------
         # Triggered for no-3MF "Untitled" prints (#1820) AND for partial

+ 19 - 41
backend/app/services/usage_tracker.py

@@ -1083,6 +1083,10 @@ async def _track_from_3mf(
             continue
 
         # --- Mid-print tray switch: split weight across trays ---
+        # Split math is shared with the Spoolman writer via
+        # ``utils.tray_split.compute_tray_split_grams`` (#1793) — both
+        # inventory backends must attribute segments identically or a
+        # user running dual-mode sees divergent totals.
         if len(tray_changes) > 1:
             # Compute total weight for this slot (same logic as normal path)
             if layer_grams and slot_id in layer_grams:
@@ -1100,8 +1104,6 @@ async def _track_from_3mf(
                 from backend.app.utils.threemf_tools import (
                     extract_filament_properties_from_3mf,
                     extract_layer_filament_usage_from_3mf,
-                    get_cumulative_usage_at_layer,
-                    mm_to_grams,
                 )
 
                 split_layer_usage = extract_layer_filament_usage_from_3mf(file_path)
@@ -1110,46 +1112,20 @@ async def _track_from_3mf(
             except Exception:
                 pass  # Fall back to linear splitting
 
-            density = split_props.get("density", 1.24)
-            diameter = split_props.get("diameter", 1.75)
-            filament_id = slot_id - 1  # 0-based for gcode
-
-            sum_previous = 0.0
-            for seg_idx, (tray_global, seg_start_layer) in enumerate(tray_changes):
-                is_last = seg_idx + 1 >= len(tray_changes)
+            from backend.app.utils.tray_split import compute_tray_split_grams
+
+            segments = compute_tray_split_grams(
+                tray_changes=tray_changes,
+                total_weight=total_weight,
+                slot_id=slot_id,
+                layer_usage=split_layer_usage,
+                density=split_props.get("density", 1.24),
+                diameter=split_props.get("diameter", 1.75),
+                total_layers=(state.total_layers if state else 0) or 0,
+                last_layer_num=last_layer_num,
+            )
 
-                if is_last:
-                    # Last segment: remainder to avoid rounding drift
-                    segment_grams = total_weight - sum_previous
-                elif split_layer_usage:
-                    seg_end_layer = tray_changes[seg_idx + 1][1]
-                    mm_at_start = get_cumulative_usage_at_layer(split_layer_usage, seg_start_layer).get(filament_id, 0)
-                    mm_at_end = get_cumulative_usage_at_layer(split_layer_usage, seg_end_layer).get(filament_id, 0)
-                    segment_grams = mm_to_grams(mm_at_end - mm_at_start, diameter, density)
-                else:
-                    # No per-layer data: linear fallback by layer ratio (#1771).
-                    # Cascade denominators because firmware on some models (P1S
-                    # observed) resets `total_layer_num` to 0 at print end —
-                    # `last_layer_num` is the print's last-valid layer captured
-                    # mid-print and survives that reset (same shape as the
-                    # `last_progress` fallback at line 1040). Equal-split is the
-                    # last-resort fence: still wrong, but bounded — never dumps
-                    # the entire print onto the last segment, which was the
-                    # original #1771 symptom for the reporter (P1S, AMS Backup
-                    # fed from spool 1 then spool 2, all 260 g credited to
-                    # spool 2 even though spool 1 had given up its 180 g).
-                    seg_end_layer = tray_changes[seg_idx + 1][1]
-                    denom = (state.total_layers if state else 0) or last_layer_num
-                    if denom > 0:
-                        segment_grams = total_weight * (seg_end_layer - seg_start_layer) / denom
-                    else:
-                        # No layer information available from any source —
-                        # spread evenly across segments. The last segment will
-                        # get the rounding remainder via the `is_last` branch
-                        # above on its own iteration.
-                        segment_grams = total_weight / len(tray_changes)
-
-                sum_previous += segment_grams
+            for seg_idx, tray_global, segment_grams in segments:
                 if segment_grams <= 0:
                     continue
 
@@ -1168,6 +1144,8 @@ async def _track_from_3mf(
                 if seg_key in handled_trays:
                     continue
 
+                seg_start_layer = tray_changes[seg_idx][1]
+                is_last = seg_idx + 1 >= len(tray_changes)
                 logger.info(
                     "[UsageTracker] 3MF split: segment %d tray=%d (AMS%d-T%d) layers %d-%s -> %.1fg",
                     seg_idx,

+ 38 - 0
backend/app/services/virtual_printer/ftp_server.py

@@ -13,6 +13,7 @@ import logging
 import os
 import random
 import ssl
+import zipfile
 from collections.abc import Callable
 from pathlib import Path
 
@@ -476,6 +477,43 @@ class FTPSession:
             await self.send(426, f"Transfer failed: {write_failed}")
             return
 
+        # Defense in depth (#1896): a clean read-loop EOF does NOT prove the
+        # upload arrived intact. Under uvloop, the SSL layer can silently drop
+        # already-received but still-buffered data when the client closes the
+        # data connection without a TLS close_notify (a "ragged EOF") while the
+        # transport is flow-control-paused on slow storage — read() then returns
+        # b"" and we would otherwise reply 226 for a tail-truncated file, archive
+        # it, queue it, and forward the corrupt job to the real printer.
+        #
+        # Bambu 3MF uploads are ZIP containers whose End-Of-Central-Directory
+        # record sits at the very end of the file, so any lost tail makes the
+        # archive impossible to open. Verify that before acknowledging success:
+        # a truncated file is treated exactly like a failed transfer (426 +
+        # drop) so the slicer surfaces an actionable send error instead of the
+        # printer choking on a half-written job later. Only ZIP-based (.3mf)
+        # uploads are validated — other filetypes keep the prior pass-through
+        # behaviour. Reading the central directory is O(dir), not O(file): no
+        # decompression, negligible next to the write loop above.
+        if filename.lower().endswith(".3mf"):
+            try:
+                with zipfile.ZipFile(file_path) as zf:
+                    zf.namelist()
+            except Exception as e:
+                logger.error(
+                    "FTP upload of %s is a corrupt/truncated 3MF (%s bytes): %s(%s) — "
+                    "rejecting with 426 instead of archiving a broken file",
+                    filename,
+                    total_received,
+                    type(e).__name__,
+                    e,
+                )
+                try:
+                    file_path.unlink(missing_ok=True)
+                except OSError:
+                    pass
+                await self.send(426, "Transfer failed: uploaded 3MF is incomplete or corrupt")
+                return
+
         # Confirm + notify
         logger.info("FTP saved file: %s (%s bytes)", file_path, total_received)
         await self.send(226, "Transfer complete")

+ 22 - 11
backend/app/services/virtual_printer/manager.py

@@ -1095,25 +1095,36 @@ class VirtualPrinterInstance:
             self._mqtt.set_bridge(self._mqtt_bridge)
             await self._mqtt_bridge.start()
 
-            # RTSPS camera passthrough on port 322. BambuStudio's camera button
-            # connects to the device IP it bound on (the VP), not the IP in
-            # `ipcam.rtsp_url`. Without a listener on <bind_ip>:322 the slicer
-            # gets connection refused → "LAN connection failed". Same raw TCP
-            # pass-through used by SlicerProxyManager in proxy mode.
+            # Camera passthrough. BambuStudio / OrcaSlicer connect the "camera"
+            # button to the device IP they bound on (the VP), not the IP in the
+            # printer's `ipcam.rtsp_url`. Without a listener the slicer gets
+            # connection refused → "LAN connection failed" (RTSP models) or
+            # OrcaSlicer error `[2:-10061]` (chamber-image models, #1868).
+            #
+            # The port depends on the TARGET printer's model:
+            #   RTSPS (X1/X2/H2/P2S)        → 322
+            #   chamber-image (A1/P1P/P1S)  → 6000
+            #
+            # `get_camera_port()` is the same source of truth used by
+            # `routes/camera.py`, so slicer and Bambuddy UI agree.
             target_client = self._printer_manager.get_client(self.target_printer_id)
             target_ip = getattr(target_client, "ip_address", None) if target_client else None
+            target_model = getattr(target_client, "model", None) if target_client else None
             if target_ip:
+                from backend.app.services.camera import get_camera_port
+
+                camera_port = get_camera_port(target_model)
                 self._rtsp_proxy = TCPProxy(
-                    name="RTSP",
-                    listen_port=322,
+                    name=f"Camera-{camera_port}",
+                    listen_port=camera_port,
                     target_host=target_ip,
-                    target_port=322,
+                    target_port=camera_port,
                     bind_address=bind_addr,
                 )
                 self._tasks.append(
                     asyncio.create_task(
-                        run_with_logging(self._rtsp_proxy.start(), "RTSP"),
-                        name=f"vp_{self.id}_rtsp",
+                        run_with_logging(self._rtsp_proxy.start(), f"Camera-{camera_port}"),
+                        name=f"vp_{self.id}_camera",
                     )
                 )
 
@@ -1197,7 +1208,7 @@ class VirtualPrinterInstance:
             try:
                 await self._rtsp_proxy.stop()
             except Exception:
-                logger.exception("[VP %s] RTSP proxy stop failed", self.name)
+                logger.exception("[VP %s] Camera proxy stop failed", self.name)
             self._rtsp_proxy = None
         if self._ftp:
             await self._ftp.stop()

+ 55 - 3
backend/app/services/virtual_printer/mqtt_server.py

@@ -594,12 +594,45 @@ class SimpleMQTTServer:
                     # Enable TCP keepalive so a hard network drop is detected
                     # by the OS within a few minutes rather than waiting for
                     # the next outbound write to ECONNRESET.
+                    #
+                    # Also tighten the Linux keepalive schedule. Defaults are
+                    # tcp_keepalive_time=7200 s (2 h before first probe),
+                    # tcp_keepalive_intvl=75, tcp_keepalive_probes=9 — so a
+                    # macOS client that goes to sleep silently is only
+                    # detected as dead ~2 h 11 min later, and until then the
+                    # push loop keeps stalling on drain-timeouts to the
+                    # zombie socket. #1872: a P1S sleep/wake left the pre-
+                    # sleep session in _clients for 5+ min with no eviction
+                    # signal. New settings (idle=60 s, interval=15 s,
+                    # count=4) detect a dead peer in ~2 min. `getattr` guards
+                    # keep this cross-platform — macOS has TCP_KEEPINTVL but
+                    # not TCP_KEEPIDLE (uses TCP_KEEPALIVE); other platforms
+                    # silently skip.
                     sock = writer.get_extra_info("socket")
                     if sock is not None:
                         try:
                             sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
                         except OSError as e:
                             logger.debug("%sFailed to set SO_KEEPALIVE on %s: %s", self._log_prefix, client_id, e)
+                        for opt_name, opt_value in (
+                            ("TCP_KEEPIDLE", 60),
+                            ("TCP_KEEPINTVL", 15),
+                            ("TCP_KEEPCNT", 4),
+                        ):
+                            opt = getattr(socket, opt_name, None)
+                            if opt is None:
+                                continue
+                            try:
+                                sock.setsockopt(socket.IPPROTO_TCP, opt, opt_value)
+                            except OSError as e:
+                                logger.debug(
+                                    "%sFailed to set %s=%s on %s: %s",
+                                    self._log_prefix,
+                                    opt_name,
+                                    opt_value,
+                                    client_id,
+                                    e,
+                                )
                     # Register client for periodic status pushes; start with
                     # self.serial as the fallback until we learn the slicer's
                     # preferred serial from the first SUBSCRIBE/PUBLISH.
@@ -1145,11 +1178,30 @@ class SimpleMQTTServer:
 
         writer.write(packet)
         # Timeout the drain to prevent blocking the event loop if the
-        # MQTT client stops reading (e.g. slicer busy with FTP upload).
+        # MQTT client stops reading (e.g. slicer busy with FTP upload,
+        # macOS suspends the client mid-session — #1872).
+        #
+        # On timeout, close the writer and raise BrokenPipeError so the
+        # push-loop's ``except OSError`` at ``_periodic_status_push``
+        # evicts the client from ``self._clients`` on this same tick.
+        # Before this, timeouts logged at DEBUG and returned silently,
+        # so the zombie writer sat in ``self._clients`` until SO_KEEPALIVE
+        # detected the dead peer (~2 h on Linux defaults). That kept the
+        # push loop spending 5 s per iteration on the stalled client and
+        # left the slicer's UI unaware the session was gone.
         try:
             await asyncio.wait_for(writer.drain(), timeout=5)
-        except TimeoutError:
-            logger.debug("MQTT drain timeout for %s — client may be busy", topic)
+        except TimeoutError as e:
+            logger.info(
+                "%sMQTT drain timeout for %s — closing stalled writer",
+                self._log_prefix,
+                topic,
+            )
+            try:
+                writer.close()
+            except Exception:
+                pass  # best-effort — writer may already be broken
+            raise BrokenPipeError(f"drain timeout on {topic}") from e
 
     async def _send_print_response(
         self, writer: asyncio.StreamWriter, sequence_id: str, filename: str, serial: str | None = None

+ 71 - 7
backend/app/utils/threemf_tools.py

@@ -351,13 +351,32 @@ def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | Non
             nozzle_counts = [n.partition("#")[2] for n in stats_str.split("|")]
             active_extruders.append(1 if any(c not in ("0", "") for c in nozzle_counts) else 0)
 
-        if sum(active_extruders) == 1:
+        # Parse slice_info once: needed by both the single-active shortcut
+        # (to verify the slice is actually single-group, #1825) and Priority 1.
+        si_root: ET.Element | None = None
+        distinct_group_ids: set[int] = set()
+        if "Metadata/slice_info.config" in zf.namelist():
+            si_content = zf.read("Metadata/slice_info.config").decode()
+            si_root = ET.fromstring(si_content)
+            for filament_elem in si_root.findall(".//filament"):
+                gid = filament_elem.get("group_id")
+                if gid is not None:
+                    try:
+                        distinct_group_ids.add(int(gid))
+                    except (ValueError, TypeError):
+                        pass
+
+        # Single-active shortcut: only safe when the slice actually uses one
+        # group. extruder_nozzle_stats can under-report a second installed
+        # nozzle when its volume-type differs from the profile's enumerated
+        # types (HT-AMS / High-Flow asymmetry on H2D, #1825); without this
+        # guard the shortcut collapses a real multi-extruder slice onto one
+        # nozzle and the group_id mapping below is skipped.
+        if sum(active_extruders) == 1 and len(distinct_group_ids) <= 1:
             nozzle_mapping: dict[int, int] = {}
             active_idx = active_extruders.index(1)
             target_extruder = int(physical_extruder_map[active_idx])
-            if "Metadata/slice_info.config" in zf.namelist():
-                si_content = zf.read("Metadata/slice_info.config").decode()
-                si_root = ET.fromstring(si_content)
+            if si_root is not None:
                 for filament_elem in si_root.findall(".//filament"):
                     try:
                         nozzle_mapping[int(filament_elem.get("id"))] = target_extruder
@@ -368,9 +387,7 @@ def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | Non
         # Priority 1: Use group_id from slice_info filament elements.
         # This reflects the actual slicer assignment (respects "Auto For Flush").
         nozzle_mapping: dict[int, int] = {}
-        if "Metadata/slice_info.config" in zf.namelist():
-            si_content = zf.read("Metadata/slice_info.config").decode()
-            si_root = ET.fromstring(si_content)
+        if si_root is not None:
             for filament_elem in si_root.findall(".//filament"):
                 group_id_str = filament_elem.get("group_id")
                 filament_id_str = filament_elem.get("id")
@@ -842,6 +859,53 @@ def extract_project_filaments_from_3mf(zf: zipfile.ZipFile) -> list[dict]:
     return out
 
 
+def extract_support_filament_slots_from_3mf(zf: zipfile.ZipFile) -> set[int]:
+    """Slots referenced by the process settings for support material.
+
+    Supports aren't attached to object geometry — they're generated by
+    the slicer's process pass — so :func:`extract_plate_extruder_set_from_3mf`,
+    which walks per-object extruder metadata + paint_color triangles,
+    doesn't see them. Callers that need the complete set of slots a
+    plate print will exercise (e.g. the SliceModal's filament-
+    substitution logic) must union this in — otherwise a support-only
+    slot (typical PLA-model + PVA-support setup) looks "unused" and its
+    user-picked profile gets silently overwritten with slot 1's,
+    producing a single-material print (#1881).
+
+    Returns the empty set when supports are disabled, ``support_filament``
+    / ``support_interface_filament`` are 0 (== "same as model"), the
+    project has no embedded settings, or the file isn't a valid 3MF.
+    """
+    if "Metadata/project_settings.config" not in zf.namelist():
+        return set()
+    try:
+        cfg = json.loads(zf.read("Metadata/project_settings.config").decode("utf-8"))
+    except (json.JSONDecodeError, UnicodeDecodeError, OSError):
+        return set()
+    if not isinstance(cfg, dict):
+        return set()
+    # BambuStudio serialises bool config options as string "1"/"0" in
+    # project_settings.config, but forks / older versions occasionally
+    # write real booleans or ints — accept anything that isn't
+    # unambiguously falsy.
+    enable = cfg.get("enable_support")
+    if enable in (False, 0, "0", "false", "False", "", None):
+        return set()
+    out: set[int] = set()
+    for key in ("support_filament", "support_interface_filament"):
+        raw = cfg.get(key)
+        if raw is None:
+            continue
+        try:
+            slot = int(raw)
+        except (ValueError, TypeError):
+            continue
+        # Slot 0 means "same as model" — no dedicated slot to preserve.
+        if slot > 0:
+            out.add(slot)
+    return out
+
+
 _PAINT_COLOR_ATTR_RE = re.compile(rb'paint_color="([0-9A-Fa-f]+)"')
 
 # Painted-face quadtree leaves include both real filament assignments and

+ 94 - 0
backend/app/utils/tray_split.py

@@ -0,0 +1,94 @@
+"""Weight-split math for prints that traversed >1 AMS tray mid-print.
+
+`state.tray_change_log` records `(global_tray_id, layer_num)` tuples every
+time `tray_now` changes during a print (see `bambu_mqtt.py:1861`). At
+completion, both the internal Spool inventory (`usage_tracker.py`) and the
+Spoolman writer (`spoolman_tracking.py`) need to split a slot's total
+weight across the segments those changes define — one call site per
+inventory backend, one identical splitting algorithm.
+
+The algorithm lives here so the two callers cannot drift: #1793 came from
+`spoolman_tracking` never carrying the split at all, while `usage_tracker`
+had shipped it since #957 and refined it in #1771. Sharing the helper is
+the structural fix; each caller wraps its own "resolve segment tray →
+charge N grams" side effect.
+"""
+
+from __future__ import annotations
+
+# Qualified-name access (``threemf_tools.mm_to_grams(...)`` rather than
+# ``from … import mm_to_grams``) so ``unittest.mock.patch`` on the
+# threemf_tools module lands in the helper too — the pre-refactor
+# ``usage_tracker`` call site imported at call-time inside a try block,
+# which had the same testability property.
+from backend.app.utils import threemf_tools
+
+
+def compute_tray_split_grams(
+    tray_changes: list[tuple[int, int]],
+    total_weight: float,
+    slot_id: int,
+    layer_usage: dict[int, dict[int, float]] | None,
+    density: float,
+    diameter: float,
+    total_layers: int,
+    last_layer_num: int,
+) -> list[tuple[int, int, float]]:
+    """Split ``total_weight`` for a single slice slot across tray segments.
+
+    ``tray_changes`` is the ordered list ``[(global_tray_id, seg_start_layer), ...]``
+    exactly as it appears in ``state.tray_change_log``. The last segment
+    runs to the end of the print; every other segment ends at the next
+    entry's ``seg_start_layer``.
+
+    Preference order for per-segment grams — matches ``usage_tracker`` so
+    both inventory backends split identically:
+
+    1. **G-code cumulative extrusion** (``layer_usage``, indexed by 0-based
+       filament id). Precise: uses the mm actually consumed between
+       ``seg_start_layer`` and the next segment's start, then converts via
+       Spoolman-authoritative ``density`` / ``diameter``.
+    2. **Linear layer-ratio** — ``total_weight * segment_layers / denom``,
+       with ``denom = total_layers or last_layer_num``. Firmware on P1S
+       (observed) resets ``total_layer_num`` to 0 at print end, so the
+       captured ``last_layer_num`` is the durable denominator (see
+       ``usage_tracker.py:1132``). #1771 addressed the pre-fix behaviour
+       of dumping everything onto the last segment.
+    3. **Equal-split** — when neither denominator is available (server
+       restart mid-print, missing state). Wrong but bounded — the last
+       segment absorbs any rounding drift via the ``is_last`` branch.
+
+    Returns ``[(seg_idx, global_tray_id, segment_grams)]``. Empty when
+    ``tray_changes`` is empty; the caller decides whether to fall through
+    to single-tray attribution (``len(tray_changes) <= 1``).
+    """
+    if not tray_changes:
+        return []
+
+    filament_id = slot_id - 1
+    n_segments = len(tray_changes)
+    denom = total_layers or last_layer_num
+    results: list[tuple[int, int, float]] = []
+    sum_previous = 0.0
+
+    for seg_idx, (tray_global, seg_start_layer) in enumerate(tray_changes):
+        is_last = seg_idx + 1 >= n_segments
+
+        if is_last:
+            segment_grams = total_weight - sum_previous
+        elif layer_usage:
+            seg_end_layer = tray_changes[seg_idx + 1][1]
+            mm_at_start = threemf_tools.get_cumulative_usage_at_layer(layer_usage, seg_start_layer).get(filament_id, 0)
+            mm_at_end = threemf_tools.get_cumulative_usage_at_layer(layer_usage, seg_end_layer).get(filament_id, 0)
+            segment_grams = threemf_tools.mm_to_grams(mm_at_end - mm_at_start, diameter, density)
+        else:
+            seg_end_layer = tray_changes[seg_idx + 1][1]
+            if denom > 0:
+                segment_grams = total_weight * (seg_end_layer - seg_start_layer) / denom
+            else:
+                segment_grams = total_weight / n_segments
+
+        sum_previous += segment_grams
+        results.append((seg_idx, tray_global, segment_grams))
+
+    return results

+ 56 - 1
backend/tests/integration/test_auth_apikey_rbac.py

@@ -158,6 +158,15 @@ class TestApiKeyDenylistIntegrity:
             Permission.PRINTERS_READ,
             Permission.PRINTERS_CONTROL,
             Permission.ARCHIVES_READ,
+            # #1888: archive delete/update moved off the denylist to the
+            # can_manage_archives allowlist scope; must not be denied.
+            Permission.ARCHIVES_DELETE_ALL,
+            Permission.ARCHIVES_UPDATE_ALL,
+            # #1893: project CRUD moved off the denylist to the
+            # can_manage_projects allowlist scope; must not be denied.
+            Permission.PROJECTS_CREATE,
+            Permission.PROJECTS_UPDATE,
+            Permission.PROJECTS_DELETE,
             # SpoolBuddy kiosk reads settings (e.g. language) via API key — must stay allowed.
             Permission.SETTINGS_READ,
         }
@@ -215,6 +224,9 @@ class TestApiKeyScopeAllowlist:
             "can_control_printer",
             "can_manage_library",
             "can_manage_inventory",
+            "can_manage_maintenance",
+            "can_manage_archives",
+            "can_manage_projects",
             "can_access_cloud",
         }
         used_flags = set(_APIKEY_SCOPE_BY_PERMISSION.values())
@@ -241,6 +253,9 @@ class TestApiKeyScopeAllowlist:
             "can_control_printer",
             "can_manage_library",
             "can_manage_inventory",
+            "can_manage_maintenance",
+            "can_manage_archives",
+            "can_manage_projects",
             "can_access_cloud",
         ],
     )
@@ -268,12 +283,18 @@ class _FakeApiKey:
         can_control_printer=False,
         can_manage_library=False,
         can_manage_inventory=False,
+        can_manage_maintenance=False,
+        can_manage_archives=False,
+        can_manage_projects=False,
     ):
         self.can_read_status = can_read_status
         self.can_queue = can_queue
         self.can_control_printer = can_control_printer
         self.can_manage_library = can_manage_library
         self.can_manage_inventory = can_manage_inventory
+        self.can_manage_maintenance = can_manage_maintenance
+        self.can_manage_archives = can_manage_archives
+        self.can_manage_projects = can_manage_projects
 
 
 class TestCheckApiKeyPermissionsMatrix:
@@ -315,6 +336,27 @@ class TestCheckApiKeyPermissionsMatrix:
         ("INVENTORY_UPDATE", "can_manage_inventory", "update spool / SpoolBuddy kiosk write"),
         ("INVENTORY_DELETE", "can_manage_inventory", "delete spool record"),
         ("INVENTORY_FORECAST_WRITE", "can_manage_inventory", "update forecast SKU settings"),
+        # can_manage_maintenance (#1832 follow-up) — HA "cleaned nozzle" / reset counter
+        # is the load-bearing use case; MAINTENANCE_UPDATE gates POST /maintenance/items/{id}/perform.
+        ("MAINTENANCE_CREATE", "can_manage_maintenance", "assign maintenance type to printer"),
+        ("MAINTENANCE_UPDATE", "can_manage_maintenance", "log maintenance / edit interval"),
+        ("MAINTENANCE_DELETE", "can_manage_maintenance", "remove custom maintenance item"),
+        # can_manage_archives (#1888) — prune print history via API key. OWN and
+        # ALL ownership variants both fold into the same scope (API keys have no
+        # per-row ownership identity), matching the can_manage_library shape.
+        # ARCHIVES_PURGE stays admin-only (see _ADMIN_CASES).
+        ("ARCHIVES_CREATE", "can_manage_archives", "create an archive"),
+        ("ARCHIVES_UPDATE_OWN", "can_manage_archives", "edit own archive"),
+        ("ARCHIVES_UPDATE_ALL", "can_manage_archives", "edit any archive"),
+        ("ARCHIVES_DELETE_OWN", "can_manage_archives", "delete own archive"),
+        ("ARCHIVES_DELETE_ALL", "can_manage_archives", "delete any archive"),
+        # can_manage_projects (#1893) — project CRUD + membership via API key.
+        # Projects gate on plain PROJECTS_* (no OWN/ALL split), so the three
+        # permissions map directly to the one scope. PROJECTS_READ stays under
+        # can_read_status. Membership edits (add-archives) gate on PROJECTS_UPDATE.
+        ("PROJECTS_CREATE", "can_manage_projects", "create a project"),
+        ("PROJECTS_UPDATE", "can_manage_projects", "update a project / add archives"),
+        ("PROJECTS_DELETE", "can_manage_projects", "delete a project"),
     ]
 
     _ADMIN_CASES = [
@@ -330,6 +372,10 @@ class TestCheckApiKeyPermissionsMatrix:
         # LIBRARY_DELETE_ALL / LIBRARY_UPDATE_ALL moved to can_manage_library
         # under #1832 — covered by the _SCOPE_CASES matrix above.
         "LIBRARY_PURGE",
+        # ARCHIVES_PURGE stays admin-only even though the rest of archive
+        # management moved to can_manage_archives under #1888 — it drops the
+        # print's stats contribution, mirroring LIBRARY_PURGE.
+        "ARCHIVES_PURGE",
         "DISCOVERY_SCAN",
     ]
 
@@ -354,7 +400,16 @@ class TestCheckApiKeyPermissionsMatrix:
         # Wrong flag set, required flag off → 403 (no cross-scope leakage)
         other_flags = {
             f
-            for f in ("can_read_status", "can_queue", "can_control_printer", "can_manage_library")
+            for f in (
+                "can_read_status",
+                "can_queue",
+                "can_control_printer",
+                "can_manage_library",
+                "can_manage_inventory",
+                "can_manage_maintenance",
+                "can_manage_archives",
+                "can_manage_projects",
+            )
             if f != required_flag
         }
         for other in other_flags:

+ 2 - 2
backend/tests/integration/test_labels.py

@@ -129,9 +129,9 @@ class TestLocalInventoryLabels:
 
         original = labels_module.render_labels
 
-        def _capture(template, data_list):
+        def _capture(template, data_list, **kwargs):
             captured["ids"] = [d.spool_id for d in data_list]
-            return original(template, data_list)
+            return original(template, data_list, **kwargs)
 
         with patch.object(labels_module, "render_labels", side_effect=_capture):
             resp = await async_client.post(

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

@@ -1288,6 +1288,50 @@ class TestSlicerRejectionMessage:
         assert _slicer_rejection_message("") is None
         assert _slicer_rejection_message("Slicer sidecar unreachable: connection reset") is None
 
+    def test_replaces_input_preset_invalid_placeholder_with_cli_error_line(self):
+        # #1851: the CLI emits its catch-all "input preset file is invalid"
+        # placeholder for every -5 exit, including real preset-vs-printer
+        # compatibility rejections. The actual diagnostic only appears in the
+        # stdout `[error] run NNNN:` line; the function must prefer that.
+        text = (
+            "Slicer CLI failed (500): Slicing failed with error from slicer: "
+            "The input preset file is invalid and can not be parsed.: "
+            "Slicer process failed (exit code 251)\n"
+            "stdout: [2026-06-29 04:12:11.952784] [trace] Initializing StaticPrintConfigs\n"
+            "[2026-06-29 04:12:12.175810] [error] run 3008: filament preset "
+            "Generic PLA @BBL H2C (slot 1) is not compatible with printer "
+            "Bambu Lab A1 0.4 nozzle.\n"
+            "run found error, return -5, exit..."
+        )
+        assert (
+            _slicer_rejection_message(text) == "filament preset Generic PLA @BBL H2C (slot 1) is not compatible with "
+            "printer Bambu Lab A1 0.4 nozzle."
+        )
+
+    def test_keeps_meaningful_reason_even_when_cli_error_line_present(self):
+        # When the headline error_string is already a useful reason (here:
+        # the bed-boundary rejection), don't override it with a generic
+        # `[error]` line that may just be the same message restated. Avoids
+        # double-text duplication in the user-facing detail.
+        text = (
+            "Slicer CLI failed (500): Slicing failed with error from slicer: "
+            "Some objects are located over the boundary of the heated bed.: "
+            "Slicer process failed (exit code 204)\n"
+            "stdout: [error] some unrelated stdout chatter"
+        )
+        assert _slicer_rejection_message(text) == "Some objects are located over the boundary of the heated bed."
+
+    def test_cli_error_line_without_run_prefix(self):
+        # The CLI sometimes logs `[error] <msg>` without the `run NNNN:`
+        # prefix (different code paths). The regex must still pick it up.
+        text = (
+            "Slicer CLI failed (500): Slicing failed with error from slicer: "
+            "The input preset file is invalid and can not be parsed.: "
+            "Slicer process failed (exit code 251)\n"
+            "stdout: [2026-06-29 12:00:00.000000] [error] Configuration parse failed: missing key 'printer_settings_id'"
+        )
+        assert _slicer_rejection_message(text) == "Configuration parse failed: missing key 'printer_settings_id'"
+
 
 class TestSliceSlicerRejection:
     @pytest.mark.asyncio

+ 927 - 0
backend/tests/integration/test_pipeline_runs_api.py

@@ -0,0 +1,927 @@
+"""Integration tests for Slicer Pipeline runs (#1425 PR B).
+
+Slicing itself is a network call to the slicer sidecar — these tests
+stub ``slice_and_persist`` so the orchestration logic is exercised without
+needing a live sidecar in CI.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+
+def _pipeline_payload(**overrides) -> dict:
+    payload = {
+        "name": "Production Batch",
+        "description": None,
+        "printer_preset": {"source": "local", "id": "1"},
+        "process_preset": {"source": "local", "id": "2"},
+        "filament_presets": [{"source": "local", "id": "3"}],
+        "bed_type": None,
+    }
+    payload.update(overrides)
+    return payload
+
+
+@pytest.fixture
+async def pipeline_factory(async_client: AsyncClient):
+    """Create pipelines via the API + optionally set a target printer."""
+
+    async def _make(target_printer_id: int | None = None, **overrides) -> dict:
+        resp = await async_client.post("/api/v1/slicer-pipelines/", json=_pipeline_payload(**overrides))
+        assert resp.status_code == 201, resp.text
+        pipeline = resp.json()
+        if target_printer_id is not None:
+            put_resp = await async_client.put(
+                f"/api/v1/slicer-pipelines/{pipeline['id']}",
+                json={"target_kind": "specific_printer", "target_printer_id": target_printer_id},
+            )
+            assert put_resp.status_code == 200, put_resp.text
+            pipeline = put_resp.json()
+        return pipeline
+
+    return _make
+
+
+@pytest.fixture
+async def printer_factory(db_session):
+    """Insert a Printer row for tests that need a target_printer_id."""
+    from backend.app.models.printer import Printer
+
+    counter = [0]
+
+    async def _make(**overrides) -> Printer:
+        counter[0] += 1
+        defaults = {
+            "name": f"X1C #{counter[0]}",
+            "serial_number": f"SERIAL{counter[0]:04d}",
+            "ip_address": "192.0.2.1",
+            "access_code": "ABCD1234",
+            "model": "Bambu Lab X1 Carbon",
+            "is_active": True,
+        }
+        defaults.update(overrides)
+        printer = Printer(**defaults)
+        db_session.add(printer)
+        await db_session.commit()
+        await db_session.refresh(printer)
+        return printer
+
+    return _make
+
+
+@pytest.fixture
+async def library_file_factory(db_session):
+    """Insert a LibraryFile row for tests that need a source_library_file_id."""
+    from pathlib import Path
+
+    from backend.app.core.config import settings as app_settings
+    from backend.app.models.library import LibraryFile
+
+    counter = [0]
+
+    async def _make(**overrides) -> LibraryFile:
+        counter[0] += 1
+        # Materialise an empty file on disk so the orchestration's path-exists
+        # guard passes when tests reach it.
+        rel = f"test_pipeline_run_{counter[0]}.3mf"
+        abs_path = Path(app_settings.base_dir) / rel
+        abs_path.parent.mkdir(parents=True, exist_ok=True)
+        abs_path.write_bytes(b"")
+        defaults = {
+            "filename": f"cube_{counter[0]}.3mf",
+            "file_path": rel,
+            "file_type": "3mf",
+            "file_size": 0,
+            "file_hash": f"hash_{counter[0]}",
+            "source_type": "uploaded",
+        }
+        defaults.update(overrides)
+        row = LibraryFile(**defaults)
+        db_session.add(row)
+        await db_session.commit()
+        await db_session.refresh(row)
+        return row
+
+    return _make
+
+
+class TestSlicerPipelineTarget:
+    """PUT /slicer-pipelines/{id} accepts the new target fields."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_writes_target(self, async_client: AsyncClient, pipeline_factory, printer_factory):
+        printer = await printer_factory()
+        pipeline = await pipeline_factory()
+        resp = await async_client.put(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}",
+            json={"target_kind": "specific_printer", "target_printer_id": printer.id},
+        )
+        assert resp.status_code == 200, resp.text
+        updated = resp.json()
+        assert updated["target_kind"] == "specific_printer"
+        assert updated["target_printer_id"] == printer.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_target_printer_id_zero_clears(
+        self, async_client: AsyncClient, pipeline_factory, printer_factory
+    ):
+        """Empty-select dropdown sends target_printer_id=0 → backend treats
+        as 'clear' rather than referencing printer #0 (which doesn't exist)."""
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        resp = await async_client.put(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}",
+            json={"target_printer_id": 0},
+        )
+        assert resp.status_code == 200, resp.text
+        assert resp.json()["target_printer_id"] is None
+
+
+class TestCheckEligibility:
+    """POST /slicer-pipelines/{id}/check-eligibility surfaces structured issues."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_no_target_set(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        library_file_factory,
+    ):
+        pipeline = await pipeline_factory()  # no target set
+        src = await library_file_factory()
+        resp = await async_client.post(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
+            json={"source_library_file_id": src.id},
+        )
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["ok"] is False
+        kinds = [i["kind"] for i in body["issues"]]
+        # PR A defaults target_kind to 'printer_class' so a freshly-saved
+        # pipeline with no target_model_class surfaces ``class_not_set``; the
+        # PR B UI path that hadn't pinned a target_printer_id would surface
+        # ``printer_not_set``. Both signal the same thing to the operator;
+        # accept either.
+        assert kinds == ["class_not_set"] or kinds == ["printer_not_set"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_printer_disabled(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+    ):
+        printer = await printer_factory(is_active=False)
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+        with patch("backend.app.api.routes.pipeline_runs._load_printer_status", new=AsyncMock(return_value=None)):
+            resp = await async_client.post(
+                f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
+                json={"source_library_file_id": src.id},
+            )
+        assert resp.status_code == 200
+        body = resp.json()
+        kinds = [i["kind"] for i in body["issues"]]
+        assert "printer_disabled" in kinds
+        # printer_offline also fires because get_status returns None — both
+        # issues are expected and both block.
+        assert "printer_offline" in kinds
+        assert body["ok"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_online_match_clears_issues(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        """Patch printer_manager so AMS slot 0 carries the same canonical
+        type the pipeline's local-tier filament preset declares."""
+        from backend.app.models.local_preset import LocalPreset
+
+        preset = LocalPreset(
+            name="My PLA",
+            preset_type="filament",
+            source="manual",
+            setting="{}",
+            filament_type="PLA",
+            default_filament_colour="#FFFFFF",
+        )
+        db_session.add(preset)
+        await db_session.commit()
+        await db_session.refresh(preset)
+
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(
+            target_printer_id=printer.id,
+            filament_presets=[{"source": "local", "id": str(preset.id)}],
+        )
+        src = await library_file_factory()
+
+        live_status = {
+            "connected": True,
+            "raw_data": {"ams": [{"tray": [{"tray_type": "PLA Basic", "tray_color": "FFFFFFFF"}]}]},
+        }
+        with patch(
+            "backend.app.api.routes.pipeline_runs._load_printer_status",
+            new=AsyncMock(return_value=live_status),
+        ):
+            resp = await async_client.post(
+                f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
+                json={"source_library_file_id": src.id},
+            )
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["ok"] is True
+        assert body["issues"] == []
+        assert body["target_printer_name"] == printer.name
+
+
+class TestRunPipeline:
+    """POST /slicer-pipelines/{id}/run orchestrates slice + enqueue."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_run_with_issues_and_no_force_returns_409(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        library_file_factory,
+    ):
+        pipeline = await pipeline_factory()  # no target set
+        src = await library_file_factory()
+        resp = await async_client.post(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
+            json={"source_library_file_id": src.id},
+        )
+        assert resp.status_code == 409
+        # Eligibility report rides in detail.
+        detail = resp.json()["detail"]
+        assert detail["ok"] is False
+        # printer_not_set or class_not_set — depends on the PR A default
+        # target_kind. Both mean "no target chosen yet".
+        kinds = [i["kind"] for i in detail["issues"]]
+        assert "printer_not_set" in kinds or "class_not_set" in kinds
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_run_force_with_no_target_still_400(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        library_file_factory,
+    ):
+        """``force=True`` bypasses the 409 but the run endpoint still needs a
+        target to enqueue against — the second guard returns 400."""
+        pipeline = await pipeline_factory()
+        src = await library_file_factory()
+        resp = await async_client.post(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
+            json={"source_library_file_id": src.id, "force": True},
+        )
+        assert resp.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_run_creates_run_and_job(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+    ):
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+
+        live_status = {"connected": True, "raw_data": {"ams": []}}
+        # AMS empty → eligibility surfaces filament_unverified (non-blocking)
+        # for the standard-tier filament refs the default factory uses; report
+        # is ok=True so no force needed.
+        from dataclasses import dataclass
+
+        @dataclass
+        class _FakeSliceJob:
+            id: int = 9001
+
+        with (
+            patch(
+                "backend.app.api.routes.pipeline_runs._load_printer_status",
+                new=AsyncMock(return_value=live_status),
+            ),
+            patch(
+                "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
+                new=AsyncMock(return_value=_FakeSliceJob()),
+            ),
+        ):
+            resp = await async_client.post(
+                f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
+                json={"source_library_file_id": src.id},
+            )
+        assert resp.status_code == 202, resp.text
+        body = resp.json()
+        assert body["pipeline_id"] == pipeline["id"]
+        assert body["source_library_file_id"] == src.id
+        assert body["copies"] == 1
+        assert body["status"] == "queued"
+        assert len(body["jobs"]) == 1
+        assert body["jobs"][0]["copy_index"] == 0
+        assert body["eligibility_overridden"] is False
+        # slice_job_id rides on the response so the frontend can call
+        # trackJob and render the progress toast.
+        assert body["slice_job_id"] == 9001
+
+
+class TestRunListAndGet:
+    """Run history surfaces."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_runs_empty(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+    ):
+        pipeline = await pipeline_factory()
+        resp = await async_client.get(f"/api/v1/slicer-pipelines/{pipeline['id']}/runs")
+        assert resp.status_code == 200
+        assert resp.json() == {"runs": [], "total": 0}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_run_404(
+        self,
+        async_client: AsyncClient,
+    ):
+        resp = await async_client.get("/api/v1/pipeline-runs/99999")
+        assert resp.status_code == 404
+
+
+class TestCancelRun:
+    """Cancellation marks the run + linked queue entry."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cancel_unknown_run_404(self, async_client: AsyncClient):
+        resp = await async_client.post("/api/v1/pipeline-runs/99999/cancel")
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cancel_marks_queued_run(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+        live_status = {"connected": True, "raw_data": {"ams": []}}
+        from dataclasses import dataclass
+
+        @dataclass
+        class _FakeSliceJob:
+            id: int = 9001
+
+        with (
+            patch(
+                "backend.app.api.routes.pipeline_runs._load_printer_status",
+                new=AsyncMock(return_value=live_status),
+            ),
+            patch(
+                "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
+                new=AsyncMock(return_value=_FakeSliceJob()),
+            ),
+        ):
+            run_resp = await async_client.post(
+                f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
+                json={"source_library_file_id": src.id},
+            )
+        run_id = run_resp.json()["id"]
+        cancel_resp = await async_client.post(f"/api/v1/pipeline-runs/{run_id}/cancel")
+        assert cancel_resp.status_code == 200
+        assert cancel_resp.json()["status"] == "cancelled"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_run_accepts_archive_source(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        db_session,
+    ):
+        """``source_archive_id`` is accepted in place of source_library_file_id."""
+        from pathlib import Path
+
+        from backend.app.core.config import settings as app_settings
+        from backend.app.models.archive import PrintArchive
+
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+
+        rel = "test_pipeline_archive_source.3mf"
+        (Path(app_settings.base_dir) / rel).write_bytes(b"")
+        archive = PrintArchive(
+            printer_id=printer.id,
+            filename="Archive Source.3mf",
+            file_path=rel,
+            file_size=0,
+            source_3mf_path=rel,
+        )
+        db_session.add(archive)
+        await db_session.commit()
+        await db_session.refresh(archive)
+
+        from dataclasses import dataclass
+
+        @dataclass
+        class _FakeSliceJob:
+            id: int = 7777
+
+        live_status = {"connected": True, "raw_data": {"ams": []}}
+        with (
+            patch(
+                "backend.app.api.routes.pipeline_runs._load_printer_status",
+                new=AsyncMock(return_value=live_status),
+            ),
+            patch(
+                "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
+                new=AsyncMock(return_value=_FakeSliceJob()),
+            ),
+        ):
+            resp = await async_client.post(
+                f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
+                json={"source_archive_id": archive.id},
+            )
+        assert resp.status_code == 202, resp.text
+        body = resp.json()
+        assert body["source_library_file_id"] is None
+        assert body["source_archive_id"] == archive.id
+        assert body["slice_job_id"] == 7777
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_run_rejects_no_source(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+    ):
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        resp = await async_client.post(f"/api/v1/slicer-pipelines/{pipeline['id']}/run", json={})
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_run_rejects_both_sources(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+    ):
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+        resp = await async_client.post(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
+            json={"source_library_file_id": src.id, "source_archive_id": 99},
+        )
+        assert resp.status_code == 422
+
+
+class TestPipelineC:
+    """PR C — multi-copy, class targeting, fanout strategies, retry-failed,
+    dashboard list, max-copies cap."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_copies_cap_enforced(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+    ):
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+        # Default cap is 50; over-request returns 422 even with valid eligibility.
+        resp = await async_client.post(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
+            json={"source_library_file_id": src.id, "copies": 9999},
+        )
+        assert resp.status_code == 422  # schema gate (le=1000)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_run_copies_3_creates_3_jobs(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+    ):
+        from dataclasses import dataclass
+
+        @dataclass
+        class _FakeSliceJob:
+            id: int = 5555
+
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+
+        live_status = {"connected": True, "raw_data": {"ams": []}}
+        with (
+            patch(
+                "backend.app.api.routes.pipeline_runs._load_printer_status",
+                new=AsyncMock(return_value=live_status),
+            ),
+            patch(
+                "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
+                new=AsyncMock(return_value=_FakeSliceJob()),
+            ),
+        ):
+            resp = await async_client.post(
+                f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
+                json={"source_library_file_id": src.id, "copies": 3},
+            )
+        assert resp.status_code == 202, resp.text
+        body = resp.json()
+        assert body["copies"] == 3
+        assert len(body["jobs"]) == 3
+        assert [j["copy_index"] for j in body["jobs"]] == [0, 1, 2]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_class_eligibility_per_printer_breakdown(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+    ):
+        """target_kind='printer_class' surfaces per-printer reports."""
+        await printer_factory(model="X1C")
+        await printer_factory(model="X1C")
+        await printer_factory(model="P1S")  # noise — different model
+        pipeline = await pipeline_factory()
+        # Wire class targeting via PUT.
+        put_resp = await async_client.put(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}",
+            json={
+                "target_kind": "printer_class",
+                "target_printer_id": 0,
+                "target_model_class": "X1C",
+                "fanout_strategy": "max_parallel",
+            },
+        )
+        assert put_resp.status_code == 200, put_resp.text
+        src = await library_file_factory()
+        resp = await async_client.post(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
+            json={"source_library_file_id": src.id},
+        )
+        assert resp.status_code == 200, resp.text
+        body = resp.json()
+        assert body["target_kind"] == "printer_class"
+        assert body["target_model_class"] == "X1C"
+        # Two X1Cs were created — both should appear in the per-printer breakdown.
+        assert len(body["printer_reports"]) == 2
+        assert all(r["printer_name"].startswith("X1C") for r in body["printer_reports"])
+        # AMS empty + no live state → both are offline, so ok=False.
+        assert body["ok"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_class_eligibility_no_matching_printers(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+    ):
+        await printer_factory(model="P1S")  # only a P1S in the install
+        pipeline = await pipeline_factory()
+        await async_client.put(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}",
+            json={
+                "target_kind": "printer_class",
+                "target_printer_id": 0,
+                "target_model_class": "X1C",
+            },
+        )
+        src = await library_file_factory()
+        resp = await async_client.post(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
+            json={"source_library_file_id": src.id},
+        )
+        body = resp.json()
+        assert body["ok"] is False
+        assert any(i["kind"] == "no_class_matches" for i in body["issues"])
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_all_runs_dashboard_endpoint(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        from backend.app.models.pipeline_run import PipelineRun
+
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+        for i in range(3):
+            run = PipelineRun(
+                pipeline_id=pipeline["id"],
+                source_library_file_id=src.id,
+                copies=1,
+                status="completed" if i % 2 == 0 else "failed",
+            )
+            db_session.add(run)
+        await db_session.commit()
+
+        resp = await async_client.get("/api/v1/pipeline-runs?limit=10")
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["total"] == 3
+        assert len(body["runs"]) == 3
+        # Newest first.
+        assert body["runs"][0]["id"] > body["runs"][-1]["id"]
+
+        # Filter by status.
+        resp = await async_client.get("/api/v1/pipeline-runs?status=failed")
+        body = resp.json()
+        assert all(r["status"] == "failed" for r in body["runs"])
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_retry_failed_creates_child_run(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        from dataclasses import dataclass
+
+        from backend.app.models.pipeline_run import PipelineJob, PipelineRun
+
+        @dataclass
+        class _FakeSliceJob:
+            id: int = 6666
+
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+        # Build a parent run with 3 jobs: 1 completed, 2 failed → retry
+        # should request copies=2.
+        parent = PipelineRun(
+            pipeline_id=pipeline["id"],
+            source_library_file_id=src.id,
+            copies=3,
+            status="partial_failure",
+        )
+        db_session.add(parent)
+        await db_session.flush()
+        for idx, status in enumerate(["completed", "failed", "failed"]):
+            db_session.add(PipelineJob(pipeline_run_id=parent.id, copy_index=idx, status=status))
+        await db_session.commit()
+        await db_session.refresh(parent)
+
+        live_status = {"connected": True, "raw_data": {"ams": []}}
+        with (
+            patch(
+                "backend.app.api.routes.pipeline_runs._load_printer_status",
+                new=AsyncMock(return_value=live_status),
+            ),
+            patch(
+                "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
+                new=AsyncMock(return_value=_FakeSliceJob()),
+            ),
+        ):
+            resp = await async_client.post(f"/api/v1/pipeline-runs/{parent.id}/retry-failed")
+        assert resp.status_code == 202, resp.text
+        body = resp.json()
+        assert body["copies"] == 2  # only the 2 failed copies
+        assert body["parent_run_id"] == parent.id
+
+
+class TestPolishFollowUp:
+    """Polish-pass fixes: dashboard target filters, clear endpoint, and the
+    deleted-queue-entry → cancelled rollup behaviour."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_dashboard_filters_by_target_printer(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        from backend.app.models.pipeline_run import PipelineRun
+
+        printer_a = await printer_factory()
+        printer_b = await printer_factory()
+        pipe_a = await pipeline_factory(target_printer_id=printer_a.id)
+        pipe_b = await pipeline_factory(target_printer_id=printer_b.id)
+        src = await library_file_factory()
+        for pipe in (pipe_a, pipe_a, pipe_b):
+            db_session.add(
+                PipelineRun(
+                    pipeline_id=pipe["id"],
+                    source_library_file_id=src.id,
+                    copies=1,
+                    status="completed",
+                )
+            )
+        await db_session.commit()
+
+        resp = await async_client.get(f"/api/v1/pipeline-runs?target_printer_id={printer_a.id}")
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["total"] == 2
+        assert all(r["target_printer_id"] == printer_a.id for r in body["runs"])
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_dashboard_filters_by_target_model_class(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        from backend.app.models.pipeline_run import PipelineRun
+
+        await printer_factory(model="X1C")
+        await printer_factory(model="P1S")
+        # Two pipelines, one class-targeting X1C, one P1S.
+        pipe_x = await pipeline_factory()
+        await async_client.put(
+            f"/api/v1/slicer-pipelines/{pipe_x['id']}",
+            json={"target_kind": "printer_class", "target_printer_id": 0, "target_model_class": "X1C"},
+        )
+        pipe_p = await pipeline_factory()
+        await async_client.put(
+            f"/api/v1/slicer-pipelines/{pipe_p['id']}",
+            json={"target_kind": "printer_class", "target_printer_id": 0, "target_model_class": "P1S"},
+        )
+        src = await library_file_factory()
+        for pipe in (pipe_x, pipe_p, pipe_p):
+            db_session.add(
+                PipelineRun(
+                    pipeline_id=pipe["id"],
+                    source_library_file_id=src.id,
+                    copies=1,
+                    status="completed",
+                )
+            )
+        await db_session.commit()
+
+        resp = await async_client.get("/api/v1/pipeline-runs?target_model_class=P1S")
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["total"] == 2
+        assert all(r["target_model_class"] == "P1S" for r in body["runs"])
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_clear_endpoint_deletes_terminal_runs_only(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        from backend.app.models.pipeline_run import PipelineRun
+
+        printer = await printer_factory()
+        pipe = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+        for status in ("completed", "failed", "cancelled", "partial_failure", "dispatching", "in_progress"):
+            db_session.add(
+                PipelineRun(
+                    pipeline_id=pipe["id"],
+                    source_library_file_id=src.id,
+                    copies=1,
+                    status=status,
+                )
+            )
+        await db_session.commit()
+
+        resp = await async_client.post("/api/v1/pipeline-runs/clear")
+        assert resp.status_code == 200, resp.text
+        assert resp.json()["deleted"] == 4  # 4 terminal statuses cleared
+
+        # The in-flight rows survive.
+        survivors = (await async_client.get("/api/v1/pipeline-runs")).json()
+        assert survivors["total"] == 2
+        assert {r["status"] for r in survivors["runs"]} == {"dispatching", "in_progress"}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_deleted_queue_entry_rolls_up_as_cancelled(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        """When the queue entry that a PipelineJob is linked to gets deleted
+        from the print-queue page, the job's live status should roll up to
+        ``cancelled`` so the run doesn't sit forever showing ``queued`` /
+        ``dispatching``."""
+        from backend.app.models.pipeline_run import PipelineJob, PipelineRun
+
+        printer = await printer_factory()
+        pipe = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+        # Simulate the state PR C leaves a successful dispatch in: run is
+        # 'dispatching' and the job has a queue_entry_id pointing at a
+        # PrintQueueItem that no longer exists.
+        run = PipelineRun(
+            pipeline_id=pipe["id"],
+            source_library_file_id=src.id,
+            copies=1,
+            status="dispatching",
+        )
+        db_session.add(run)
+        await db_session.flush()
+        db_session.add(
+            PipelineJob(
+                pipeline_run_id=run.id,
+                copy_index=0,
+                queue_entry_id=999999,  # Doesn't exist — simulates manual delete from queue.
+                assigned_printer_id=printer.id,
+                status="queued",
+            )
+        )
+        await db_session.commit()
+        await db_session.refresh(run)
+
+        resp = await async_client.get(f"/api/v1/pipeline-runs/{run.id}")
+        assert resp.status_code == 200, resp.text
+        body = resp.json()
+        # Job rolled up to cancelled because the queue entry is gone.
+        assert body["jobs"][0]["status"] == "cancelled"
+        # Run also rolls up — all jobs cancelled → run reads as cancelled.
+        assert body["status"] == "cancelled"
+
+
+class TestCancelTerminal:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cancel_terminal_run_is_idempotent(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        from backend.app.models.pipeline_run import PipelineRun
+
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+        run = PipelineRun(
+            pipeline_id=pipeline["id"],
+            source_library_file_id=src.id,
+            copies=1,
+            status="completed",
+        )
+        db_session.add(run)
+        await db_session.commit()
+        await db_session.refresh(run)
+        resp = await async_client.post(f"/api/v1/pipeline-runs/{run.id}/cancel")
+        assert resp.status_code == 200
+        assert resp.json()["status"] == "completed"  # unchanged

+ 61 - 14
backend/tests/integration/test_printers_api.py

@@ -1770,24 +1770,26 @@ class TestExecuteHMSActionAPI:
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_execute_hms_action_success(self, async_client: AsyncClient, printer_factory):
-        """200 happy path — dispatcher returns True AND printer state moves
-        within the ack-wait window. The state delta is the firmware's only
-        proof that the command landed (publish success is necessary but not
-        sufficient; see #1830 §(3))."""
+        """200 happy path — dispatcher returns True AND the printer pushes at
+        least one MQTT message into the ack-wait window. A fresh inbound
+        message is the firmware's proof that the command landed (publish
+        success is necessary but not sufficient; see #1830 §(3))."""
         printer = await printer_factory(name="Test Printer")
 
         mock_client = MagicMock()
-        # Pre-action state — paused with a fault.
+        # Pre-action state — paused with a fault, last message arrived at t=0.
         mock_client.state.state = "PAUSE"
         mock_client.state.print_error = 0x05008051
         mock_client.state.hms_errors = [object()]
+        mock_client._last_message_time = 100.0
 
         def _act(*_a, **_kw):
-            # Simulate the printer accepting the command and clearing the fault
-            # by the time the ack-wait expires.
-            mock_client.state.state = "FAILED"
-            mock_client.state.print_error = 0
-            mock_client.state.hms_errors = []
+            # Simulate the printer pushing a status update within the ack-wait
+            # window. The pushall that follows every command is what produces
+            # this — the actual state fields don't have to move (#1869: a
+            # wrong-plate IGNORE_RESUME re-pauses with the same fault but the
+            # printer DID push back).
+            mock_client._last_message_time = 100.5
             return True
 
         mock_client.execute_hms_action.side_effect = _act
@@ -1830,8 +1832,8 @@ class TestExecuteHMSActionAPI:
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_execute_hms_action_no_printer_ack_returns_502(self, async_client: AsyncClient, printer_factory):
-        """502 when publish succeeded but printer state didn't move within the
-        ack-wait window. This is the silent-rejection failure mode #1830
+        """502 when publish succeeded but no MQTT message arrives back within
+        the ack-wait window. This is the silent-rejection failure mode #1830
         identifies: the broker ACKs the publish at QoS 1 but the firmware
         drops the command (err mismatch, wrong shape, state mismatch).
         Surfacing this as 502 instead of 200 stops the UI from claiming
@@ -1842,8 +1844,9 @@ class TestExecuteHMSActionAPI:
         mock_client.state.state = "PAUSE"
         mock_client.state.print_error = 0x05008051
         mock_client.state.hms_errors = [object()]
+        mock_client._last_message_time = 100.0
         mock_client.execute_hms_action.return_value = True  # publish "succeeded"
-        # Crucially: state does NOT change → ack-wait detects no movement.
+        # Crucially: _last_message_time does NOT advance → no inbound push.
 
         with (
             patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
@@ -1858,6 +1861,49 @@ class TestExecuteHMSActionAPI:
             assert response.status_code == 502
             assert "acknowledge" in response.json()["detail"].lower()
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_execute_hms_action_ignore_resume_repauses_within_window_still_acks(
+        self, async_client: AsyncClient, printer_factory
+    ):
+        """200 when the printer ack'd the command but immediately re-paused
+        with the same fault — e.g. wrong-plate IGNORE_RESUME (#1869). The
+        previous (gcode_state, hms_errors-len) diff produced a false 502
+        because both fields round-tripped to their pre-publish values inside
+        the ack window. Probing `_last_message_time` survives the round-trip
+        because the printer's status push lands regardless of the eventual
+        state."""
+        printer = await printer_factory(name="Test Printer")
+
+        mock_client = MagicMock()
+        mock_client.state.state = "PAUSE"
+        mock_client.state.print_error = 0x05008051
+        mock_client.state.hms_errors = [object()]
+        mock_client._last_message_time = 100.0
+
+        def _act(*_a, **_kw):
+            # Printer ack'd, briefly resumed, re-detected the wrong plate, and
+            # re-paused with the same fault. Net diff on state fields is zero,
+            # but a fresh status push DID arrive.
+            mock_client._last_message_time = 100.4
+            mock_client.state.state = "PAUSE"  # round-tripped
+            mock_client.state.hms_errors = [object()]  # same length
+            return True
+
+        mock_client.execute_hms_action.side_effect = _act
+
+        with (
+            patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
+            patch("backend.app.api.routes.printers.HMS_ACTION_ACK_WAIT_SECONDS", 0.01),
+        ):
+            mock_pm.get_client.return_value = mock_client
+
+            body = {"print_error": "05008051", "action": "IGNORE_RESUME", "job_id": None}
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/execute-action", json=body)
+
+            assert response.status_code == 200
+            assert response.json()["success"] is True
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_execute_hms_action_accepts_16_char_full_code(self, async_client: AsyncClient, printer_factory):
@@ -1870,9 +1916,10 @@ class TestExecuteHMSActionAPI:
         mock_client.state.state = "RUNNING"
         mock_client.state.print_error = 0
         mock_client.state.hms_errors = [object()]
+        mock_client._last_message_time = 100.0
 
         def _act(*_a, **_kw):
-            mock_client.state.hms_errors = []
+            mock_client._last_message_time = 100.4
             return True
 
         mock_client.execute_hms_action.side_effect = _act

+ 57 - 0
backend/tests/integration/test_read_permission_backfill_migration.py

@@ -218,6 +218,63 @@ class TestReadPermissionMigration:
         perms = await _get_perms("Operators")
         assert "orca_cloud:auth" in perms
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_administrators_printer_sensor_history_read_backfilled(self, async_client: AsyncClient):
+        """Admin without `printer_sensor_history:read` (older custom edit or
+        a DB seeded before that permission existed) gets it backfilled —
+        regression for the gap maziggy hit on a live install where the
+        per-permission admin backfills missed it."""
+        await seed_default_groups()
+        async with _database_module.async_session() as session:
+            grp = (await session.execute(select(Group).where(Group.name == "Administrators"))).scalar_one()
+            grp.permissions = [p for p in (grp.permissions or []) if p != "printer_sensor_history:read"]
+            await session.commit()
+
+        await seed_default_groups()
+
+        perms = await _get_perms("Administrators")
+        assert "printer_sensor_history:read" in perms
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_administrators_sync_covers_every_current_permission(self, async_client: AsyncClient):
+        """Generic invariant: ALL_PERMISSIONS sync ensures every Permission
+        enum value is present on the Administrators group, no matter what
+        was stripped pre-backfill. Catches every future "new permission
+        missing on upgrade" regression without needing a one-off test."""
+        from backend.app.core.permissions import ALL_PERMISSIONS
+
+        await seed_default_groups()
+        # Wipe the admin group's permission list entirely and force the sync
+        # to put everything back.
+        async with _database_module.async_session() as session:
+            grp = (await session.execute(select(Group).where(Group.name == "Administrators"))).scalar_one()
+            grp.permissions = []
+            await session.commit()
+
+        await seed_default_groups()
+
+        perms = await _get_perms("Administrators")
+        missing = [p for p in ALL_PERMISSIONS if p not in perms]
+        assert not missing, f"Administrators missing permissions after backfill: {missing}"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_administrators_sync_is_additive_only(self, async_client: AsyncClient):
+        """The sync block must never remove a permission an operator added by
+        hand — only add missing entries from ALL_PERMISSIONS."""
+        await seed_default_groups()
+        async with _database_module.async_session() as session:
+            grp = (await session.execute(select(Group).where(Group.name == "Administrators"))).scalar_one()
+            grp.permissions = [*(grp.permissions or []), "custom:plugin_permission"]
+            await session.commit()
+
+        await seed_default_groups()
+
+        perms = await _get_perms("Administrators")
+        assert "custom:plugin_permission" in perms
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_viewers_do_not_get_orca_cloud_auth(self, async_client: AsyncClient):

+ 158 - 0
backend/tests/integration/test_slicer_pipelines_api.py

@@ -0,0 +1,158 @@
+"""Integration tests for the Slicer Pipelines API (#1425 PR A)."""
+
+import pytest
+from httpx import AsyncClient
+
+
+def _preset_ref(source: str, id_: str) -> dict:
+    return {"source": source, "id": id_}
+
+
+def _payload(**overrides) -> dict:
+    payload = {
+        "name": "Production Batch",
+        "description": "High speed PLA on X1C",
+        "printer_preset": _preset_ref("local", "42"),
+        "process_preset": _preset_ref("local", "7"),
+        "filament_presets": [_preset_ref("local", "11"), _preset_ref("standard", "PLA Basic")],
+        "bed_type": "Textured PEI Plate",
+    }
+    payload.update(overrides)
+    return payload
+
+
+class TestSlicerPipelinesAPI:
+    """CRUD + edge cases for /api/v1/slicer-pipelines."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_empty(self, async_client: AsyncClient):
+        """Empty list response uses the canonical {pipelines: []} envelope."""
+        resp = await async_client.get("/api/v1/slicer-pipelines/")
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data == {"pipelines": []}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_and_list(self, async_client: AsyncClient):
+        """A newly-created pipeline appears in the list with its full shape."""
+        resp = await async_client.post("/api/v1/slicer-pipelines/", json=_payload())
+        assert resp.status_code == 201, resp.text
+        created = resp.json()
+        assert created["name"] == "Production Batch"
+        assert created["printer_preset"] == _preset_ref("local", "42")
+        assert created["process_preset"] == _preset_ref("local", "7")
+        assert created["filament_presets"] == [
+            _preset_ref("local", "11"),
+            _preset_ref("standard", "PLA Basic"),
+        ]
+        assert created["bed_type"] == "Textured PEI Plate"
+        # PR A defaults persisted but not user-set
+        assert created["target_kind"] == "printer_class"
+        assert created["target_printer_id"] is None
+        assert created["fanout_strategy"] == "max_parallel"
+
+        list_resp = await async_client.get("/api/v1/slicer-pipelines/")
+        assert list_resp.status_code == 200
+        ids = [p["id"] for p in list_resp.json()["pipelines"]]
+        assert created["id"] in ids
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_by_id(self, async_client: AsyncClient):
+        """Round-trips the preset slots through JSON storage faithfully."""
+        created = (await async_client.post("/api/v1/slicer-pipelines/", json=_payload())).json()
+        resp = await async_client.get(f"/api/v1/slicer-pipelines/{created['id']}")
+        assert resp.status_code == 200
+        fetched = resp.json()
+        assert fetched["printer_preset"] == _preset_ref("local", "42")
+        assert fetched["filament_presets"] == [
+            _preset_ref("local", "11"),
+            _preset_ref("standard", "PLA Basic"),
+        ]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_not_found(self, async_client: AsyncClient):
+        resp = await async_client.get("/api/v1/slicer-pipelines/99999")
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_partial(self, async_client: AsyncClient):
+        """PUT writes only fields that are present; others stay unchanged."""
+        created = (await async_client.post("/api/v1/slicer-pipelines/", json=_payload())).json()
+        resp = await async_client.put(
+            f"/api/v1/slicer-pipelines/{created['id']}",
+            json={"name": "Renamed", "bed_type": "Cool Plate"},
+        )
+        assert resp.status_code == 200
+        updated = resp.json()
+        assert updated["name"] == "Renamed"
+        assert updated["bed_type"] == "Cool Plate"
+        # Untouched fields preserved
+        assert updated["printer_preset"] == _preset_ref("local", "42")
+        assert updated["filament_presets"] == created["filament_presets"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_filament_list_replaces_wholesale(self, async_client: AsyncClient):
+        """Setting filament_presets replaces the entire list."""
+        created = (await async_client.post("/api/v1/slicer-pipelines/", json=_payload())).json()
+        new_filaments = [_preset_ref("cloud", "PFUS1"), _preset_ref("cloud", "PFUS2"), _preset_ref("cloud", "PFUS3")]
+        resp = await async_client.put(
+            f"/api/v1/slicer-pipelines/{created['id']}",
+            json={"filament_presets": new_filaments},
+        )
+        assert resp.status_code == 200
+        assert resp.json()["filament_presets"] == new_filaments
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_is_soft(self, async_client: AsyncClient):
+        """DELETE hides from list + GET-by-id but doesn't drop the row (PR B+
+        run history must still resolve pipeline metadata)."""
+        created = (await async_client.post("/api/v1/slicer-pipelines/", json=_payload())).json()
+        resp = await async_client.delete(f"/api/v1/slicer-pipelines/{created['id']}")
+        assert resp.status_code == 204
+        # Hidden from list
+        list_resp = await async_client.get("/api/v1/slicer-pipelines/")
+        assert created["id"] not in [p["id"] for p in list_resp.json()["pipelines"]]
+        # Hidden from GET
+        get_resp = await async_client.get(f"/api/v1/slicer-pipelines/{created['id']}")
+        assert get_resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_not_found(self, async_client: AsyncClient):
+        resp = await async_client.delete("/api/v1/slicer-pipelines/99999")
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_rejects_empty_filament_list(self, async_client: AsyncClient):
+        """The schema requires at least one filament slot."""
+        payload = _payload(filament_presets=[])
+        resp = await async_client.post("/api/v1/slicer-pipelines/", json=payload)
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_rejects_invalid_preset_source(self, async_client: AsyncClient):
+        """PresetRef.source is constrained to the four known tiers."""
+        bad = _preset_ref("bogus_source", "1")
+        payload = _payload(printer_preset=bad)
+        resp = await async_client.post("/api/v1/slicer-pipelines/", json=payload)
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_orders_newest_first(self, async_client: AsyncClient):
+        first = (await async_client.post("/api/v1/slicer-pipelines/", json=_payload(name="First"))).json()
+        second = (await async_client.post("/api/v1/slicer-pipelines/", json=_payload(name="Second"))).json()
+        third = (await async_client.post("/api/v1/slicer-pipelines/", json=_payload(name="Third"))).json()
+        listing = (await async_client.get("/api/v1/slicer-pipelines/")).json()["pipelines"]
+        # Filter to the three we just made (DB may have other rows from other tests)
+        ours = [p for p in listing if p["id"] in {first["id"], second["id"], third["id"]}]
+        assert [p["name"] for p in ours] == ["Third", "Second", "First"]

+ 73 - 0
backend/tests/unit/services/test_archive_service.py

@@ -938,3 +938,76 @@ class TestMultiPlateSliceInfoSum:
         assert meta["print_time_seconds"] == 300
         # Only the second plate's weight contributed.
         assert meta["filament_used_grams"] == 5.0
+
+
+class TestThreeMFParserSupportMaterial:
+    """#1881: `_extract_filament_info` used to filter out support materials
+    (any slot where `filament_is_support == "1"`). That hid PVA / BVOH from
+    the archive card of unsliced source 3MFs — a PLA-model + PVA-support
+    project looked single-material until the print completed. This class
+    covers the follow-up: support materials must be included in
+    `filament_type` / `filament_color`.
+    """
+
+    @staticmethod
+    def _make_3mf_with_project_settings(project_settings: dict) -> str:
+        import json
+        import os
+        import tempfile
+        import zipfile
+
+        fd, path = tempfile.mkstemp(suffix=".3mf")
+        os.close(fd)
+        with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("3D/3dmodel.model", "<model/>")
+            zf.writestr("Metadata/project_settings.config", json.dumps(project_settings))
+        return path
+
+    def test_support_filament_included_on_source_3mf(self):
+        # Reporter's exact config: PLA model + PVA support. Both must show
+        # on the archive card badge, in slot order.
+        from backend.app.services.archive import ThreeMFParser
+
+        path = self._make_3mf_with_project_settings(
+            {
+                "filament_type": ["PLA", "PVA"],
+                "filament_colour": ["#FFFFFF", "#00AA00"],
+                "filament_is_support": ["0", "1"],
+            }
+        )
+        meta = ThreeMFParser(path).parse()
+        assert meta["filament_type"] == "PLA, PVA"
+        assert meta["filament_color"] == "#FFFFFF,#00AA00"
+
+    def test_single_support_material_still_populated(self):
+        # Degenerate case: only material configured happens to be marked
+        # support. Old fallback picked the first entry; new logic keeps
+        # the same shape.
+        from backend.app.services.archive import ThreeMFParser
+
+        path = self._make_3mf_with_project_settings(
+            {
+                "filament_type": ["PVA"],
+                "filament_colour": ["#FFFFFF"],
+                "filament_is_support": ["1"],
+            }
+        )
+        meta = ThreeMFParser(path).parse()
+        assert meta["filament_type"] == "PVA"
+        assert meta["filament_color"] == "#FFFFFF"
+
+    def test_duplicate_material_types_deduped(self):
+        # Two AMS slots both PLA of different colours: type list dedupes
+        # but colour list keeps both (multi-colour print).
+        from backend.app.services.archive import ThreeMFParser
+
+        path = self._make_3mf_with_project_settings(
+            {
+                "filament_type": ["PLA", "PLA"],
+                "filament_colour": ["#FFFFFF", "#000000"],
+                "filament_is_support": ["0", "0"],
+            }
+        )
+        meta = ThreeMFParser(path).parse()
+        assert meta["filament_type"] == "PLA"
+        assert meta["filament_color"] == "#FFFFFF,#000000"

+ 88 - 0
backend/tests/unit/services/test_bambu_cloud.py

@@ -437,3 +437,91 @@ class TestCloudflareChallengeDetection:
             assert result["success"] is False
             assert result["needs_verification"] is False
             assert "Cloudflare" in result["message"]
+
+
+# ===========================================================================
+# Issue #1815: PFUS cloud user preset lookup silently 400s in resolver
+# ===========================================================================
+
+
+class TestSlicerSettingVersionParam:
+    """`/v1/iot-service/api/slicer/setting` endpoints require ?version=XX.YY.ZZ.WW.
+
+    The plural GET (`get_slicer_settings`) has always sent it. The singular
+    GET (`get_setting_detail`) and DELETE (`delete_setting`) hit the same
+    subtree and were silently omitting it since #1013's compliance rework
+    (2026-05-12), which surfaced as #1815: every PFUS-prefix cloud user preset
+    lookup in the slicer_filament_resolver 400'd, so BambuStudio saw the
+    generic-material fallback instead of the user's actual custom profile
+    (rescued in most cases by slot-tray_info_idx reuse or K-profile realign,
+    Bgabor997's spool 54 had neither).
+    """
+
+    def _auth(self) -> BambuCloudService:
+        cloud = BambuCloudService()
+        cloud.access_token = "test-token"
+        return cloud
+
+    @pytest.mark.asyncio
+    async def test_get_setting_detail_sends_version_param(self):
+        """`get_setting_detail` must include the version query param — without
+        it Bambu Cloud returns HTTP 400 'field version is not set'."""
+        cloud = self._auth()
+
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+        mock_response.json.return_value = {"filament_id": "P4d64437", "name": "Overture Matte PLA"}
+
+        with patch.object(cloud._client, "get", new_callable=AsyncMock) as mock_get:
+            mock_get.return_value = mock_response
+
+            result = await cloud.get_setting_detail("PFUS992454068158eb")
+
+            assert result["filament_id"] == "P4d64437"
+            url = mock_get.call_args[0][0]
+            assert url.endswith("/v1/iot-service/api/slicer/setting/PFUS992454068158eb")
+            params = mock_get.call_args.kwargs.get("params") or {}
+            assert params.get("version"), "get_setting_detail must send ?version=… to avoid 400"
+
+    @pytest.mark.asyncio
+    async def test_get_setting_detail_error_includes_response_body(self):
+        """The 400 body identifies the exact contract violation. Callers include
+        it in log warnings so a next contract change is self-diagnostic instead
+        of surfacing an opaque status code (which cost 50 days on #1815)."""
+        cloud = self._auth()
+
+        from backend.app.services.bambu_cloud import BambuCloudError
+
+        mock_response = MagicMock()
+        mock_response.status_code = 400
+        mock_response.text = "field 'version' is not set"
+
+        with patch.object(cloud._client, "get", new_callable=AsyncMock) as mock_get:
+            mock_get.return_value = mock_response
+
+            with pytest.raises(BambuCloudError) as exc:
+                await cloud.get_setting_detail("PFUS992454068158eb")
+
+            assert "400" in str(exc.value)
+            assert "field 'version'" in str(exc.value)
+
+    @pytest.mark.asyncio
+    async def test_delete_setting_sends_version_param(self):
+        """`delete_setting` hits the same subtree; same requirement applies."""
+        cloud = self._auth()
+
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+        mock_response.content = b"{}"
+        mock_response.json.return_value = {}
+
+        with patch.object(cloud._client, "delete", new_callable=AsyncMock) as mock_delete:
+            mock_delete.return_value = mock_response
+
+            result = await cloud.delete_setting("PFUS992454068158eb")
+
+            assert result["success"] is True
+            url = mock_delete.call_args[0][0]
+            assert url.endswith("/v1/iot-service/api/slicer/setting/PFUS992454068158eb")
+            params = mock_delete.call_args.kwargs.get("params") or {}
+            assert params.get("version"), "delete_setting must send ?version=… to avoid 400"

+ 112 - 0
backend/tests/unit/services/test_bambu_mqtt.py

@@ -6070,3 +6070,115 @@ class TestTrayNowH2SExternalSpoolOverride:
 
         mqtt_client._process_message(_ams_payload(255))
         assert mqtt_client.state.tray_now == 255
+
+
+class TestLastLayerFinishPhotoTrigger:
+    """Tests for #1867: layer_num→total_layer_num edge fires the finish-photo
+    moment before user End G-code (e.g. SwapMod) executes.
+
+    A1 Mini firmware skips stg_cur=22 entirely, so the FINISH-state fallback
+    fires after end G-code has already moved the plate. The last-layer edge
+    is the earliest reliable "print finished" signal available across all
+    Bambu printer variants.
+    """
+
+    @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._was_running = True
+        client.state.total_layers = 100
+        client.state.layer_num = 99
+        return client
+
+    def test_fires_when_layer_reaches_total(self, mqtt_client):
+        events = []
+        mqtt_client.on_finish_photo_moment = lambda data: events.append(data)
+
+        mqtt_client._process_message({"print": {"layer_num": 100}})
+
+        assert len(events) == 1
+        assert events[0]["trigger"] == "last_layer"
+        assert mqtt_client._finish_photo_captured is True
+
+    def test_does_not_fire_when_layer_still_below_total(self, mqtt_client):
+        events = []
+        mqtt_client.on_finish_photo_moment = lambda data: events.append(data)
+
+        mqtt_client._process_message({"print": {"layer_num": 99}})
+
+        assert events == []
+        assert mqtt_client._finish_photo_captured is False
+
+    def test_edge_only_no_double_fire(self, mqtt_client):
+        """Once fired, subsequent messages at layer_num == total must not
+        re-fire (the guard flips _finish_photo_captured to True)."""
+        events = []
+        mqtt_client.on_finish_photo_moment = lambda data: events.append(data)
+
+        mqtt_client._process_message({"print": {"layer_num": 100}})
+        mqtt_client._process_message({"print": {"layer_num": 100}})
+        mqtt_client._process_message({"print": {"layer_num": 100}})
+
+        assert len(events) == 1
+
+    def test_does_not_fire_when_not_running(self, mqtt_client):
+        """If the print never went through RUNNING (Bambuddy restart mid-print,
+        firmware replay), _was_running is False and no photo trigger fires."""
+        mqtt_client._was_running = False
+        events = []
+        mqtt_client.on_finish_photo_moment = lambda data: events.append(data)
+
+        mqtt_client._process_message({"print": {"layer_num": 100}})
+
+        assert events == []
+
+    def test_does_not_fire_when_total_layers_unknown(self, mqtt_client):
+        """total=0 (before slicer metadata arrives) must never satisfy the
+        `new_layer >= total` condition."""
+        mqtt_client.state.total_layers = 0
+        mqtt_client.state.layer_num = 0
+        events = []
+        mqtt_client.on_finish_photo_moment = lambda data: events.append(data)
+
+        mqtt_client._process_message({"print": {"layer_num": 0}})
+
+        assert events == []
+
+    def test_stage_22_skipped_after_last_layer_already_fired(self, mqtt_client):
+        """Once the last-layer trigger has set _finish_photo_captured, the
+        stage-22 hook that runs later on AMS printers must be a no-op."""
+        events = []
+        mqtt_client.on_finish_photo_moment = lambda data: events.append(data)
+
+        mqtt_client._process_message({"print": {"layer_num": 100}})
+        assert len(events) == 1
+
+        mqtt_client.state.progress = 100
+        mqtt_client._process_message({"print": {"stg_cur": 22}})
+
+        assert len(events) == 1
+
+    def test_finish_state_fallback_skipped_after_last_layer_fired(self, mqtt_client):
+        """The gcode_state=FINISH fallback (which fires after end G-code on
+        every printer) must be suppressed once the last-layer edge fired.
+        This is the #1867 regression check — SwapMod plate must be captured
+        by last_layer, NOT by the post-End-G-code FINISH fallback."""
+        events = []
+        completion_events = []
+        mqtt_client.on_finish_photo_moment = lambda data: events.append(data)
+        mqtt_client.on_print_complete = lambda data: completion_events.append(data)
+        mqtt_client._previous_gcode_state = "RUNNING"
+
+        mqtt_client._process_message({"print": {"layer_num": 100}})
+        assert events[0]["trigger"] == "last_layer"
+
+        mqtt_client._process_message({"print": {"gcode_state": "FINISH"}})
+
+        assert len(events) == 1
+        assert len(completion_events) == 1

+ 114 - 0
backend/tests/unit/services/test_external_camera.py

@@ -591,3 +591,117 @@ class TestUsbCameraHandling:
 
         result = await capture_frame("http://example.com", "usb", timeout=1)
         assert result is None
+
+
+def _encode_image(ext: str) -> bytes:
+    """Encode a small solid test image to the given container (.png/.webp/.jpg)."""
+    import cv2
+    import numpy as np
+
+    img = np.zeros((16, 24, 3), dtype=np.uint8)
+    img[:, :12] = (0, 0, 255)  # half red so the frame isn't uniformly black
+    ok, buf = cv2.imencode(ext, img)
+    assert ok, f"failed to encode {ext}"
+    return buf.tobytes()
+
+
+def _fake_snapshot_session(body: bytes, status: int = 200):
+    """Build an aiohttp.ClientSession stand-in whose GET yields `body`.
+
+    Matches the `async with ClientSession(...) as session, session.get(url) as
+    response` usage inside `_capture_snapshot`.
+    """
+
+    class _Resp:
+        def __init__(self):
+            self.status = status
+
+        async def __aenter__(self):
+            return self
+
+        async def __aexit__(self, *a):
+            return False
+
+        async def read(self):
+            return body
+
+    class _Session:
+        def __init__(self, *a, **k):
+            pass
+
+        async def __aenter__(self):
+            return self
+
+        async def __aexit__(self, *a):
+            return False
+
+        def get(self, _url):
+            return _Resp()
+
+    return _Session
+
+
+class TestSnapshotTranscode:
+    """Regression for #1902. Snapshot endpoints that serve PNG/WebP (not JPEG)
+    broke the browser MJPEG stream, because every multipart part is hard-labelled
+    ``Content-Type: image/jpeg`` — the browser rejected the non-JPEG payload and
+    dropped the whole stream ("connection lost"). ``_capture_snapshot`` now
+    transcodes non-JPEG stills to JPEG; only genuinely undecodable payloads fall
+    through to the raw bytes (unchanged last-resort behaviour)."""
+
+    def test_transcode_png_to_jpeg(self):
+        from backend.app.services.external_camera import _transcode_to_jpeg
+
+        png = _encode_image(".png")
+        assert not png.startswith(JPEG_START)  # sanity: input really is PNG
+        out = _transcode_to_jpeg(png)
+        assert out is not None and out.startswith(JPEG_START)
+
+    def test_transcode_webp_to_jpeg(self):
+        from backend.app.services.external_camera import _transcode_to_jpeg
+
+        webp = _encode_image(".webp")
+        assert not webp.startswith(JPEG_START)
+        out = _transcode_to_jpeg(webp)
+        assert out is not None and out.startswith(JPEG_START)
+
+    def test_transcode_returns_none_for_non_image(self):
+        """HTML error pages / auth redirects / empty bodies aren't images —
+        transcode returns None so the caller can log and fall back."""
+        from backend.app.services.external_camera import _transcode_to_jpeg
+
+        assert _transcode_to_jpeg(b"<html><body>404 Not Found</body></html>") is None
+        assert _transcode_to_jpeg(b"") is None
+
+    @pytest.mark.asyncio
+    async def test_capture_snapshot_transcodes_png_response(self):
+        """The reported case: a snapshot URL returning PNG yields JPEG bytes."""
+        from backend.app.services import external_camera as ec
+
+        png = _encode_image(".png")
+        with patch.object(ec.aiohttp, "ClientSession", _fake_snapshot_session(png)):
+            out = await ec._capture_snapshot("http://192.168.50.50/snapshot.png", 10)
+        assert out is not None and out.startswith(JPEG_START)
+
+    @pytest.mark.asyncio
+    async def test_capture_snapshot_jpeg_passthrough_unchanged(self):
+        """A JPEG snapshot must be returned byte-for-byte (fast path, no
+        re-encode) so we don't degrade quality or waste CPU on JPEG cameras."""
+        from backend.app.services import external_camera as ec
+
+        jpeg = _encode_image(".jpg")
+        assert jpeg.startswith(JPEG_START)
+        with patch.object(ec.aiohttp, "ClientSession", _fake_snapshot_session(jpeg)):
+            out = await ec._capture_snapshot("http://192.168.50.50/snapshot.jpg", 10)
+        assert out == jpeg  # identical object bytes — proves no transcode ran
+
+    @pytest.mark.asyncio
+    async def test_capture_snapshot_non_image_falls_back_to_raw(self):
+        """Undecodable (non-image) responses return the raw bytes unchanged, so
+        behaviour is never worse than before the fix."""
+        from backend.app.services import external_camera as ec
+
+        html = b"<html><body>unauthorized</body></html>"
+        with patch.object(ec.aiohttp, "ClientSession", _fake_snapshot_session(html)):
+            out = await ec._capture_snapshot("http://192.168.50.50/snapshot", 10)
+        assert out == html

+ 64 - 33
backend/tests/unit/services/test_hms_actions.py

@@ -131,61 +131,92 @@ class TestExecuteHmsActionDispatch:
         assert "err" not in cmds[0]["print"]
         assert "job_id" not in cmds[0]["print"]
 
-    def test_ignore_resume_dispatches_resume_when_print_paused(self, client):
-        # Verified on H2D: idle_ignore is silently rejected while gcode_state
-        # is PAUSE. The user's intent on a paused HMS modal is to continue,
-        # so IGNORE_RESUME dispatches a plain resume instead. See #1830 §(2).
+    def test_ignore_resume_sends_bambustudio_ignore_command_paused(self, client):
+        # IGNORE_RESUME dispatches BambuStudio's `command_hms_ignore`
+        # (DeviceManager.cpp:1450) — `command: "ignore"`, not `resume` and
+        # not `idle_ignore`. The firmware handles both "skip this check on the
+        # next attempt" and "resume the paused print" in one operation.
+        # The previous Bambuddy code redirected to plain resume, which caused
+        # wrong-plate to re-pause 1-2 s after the user clicked Ignore (#1869).
+        # `err` is the DECIMAL int representation of the hex error code.
         client.state.state = "PAUSE"
-        client.execute_hms_action("03008070", HMSAction.IGNORE_RESUME)
+        client.execute_hms_action("05008051", HMSAction.IGNORE_RESUME, job_id="task-7")
         cmds = self._published_commands(client)
         assert cmds[0] == {
             "print": {
-                "command": "resume",
-                "param": "",
+                "command": "ignore",
+                "err": str(0x05008051),  # "83918929"
+                "param": "reserve",
+                "job_id": "task-7",
                 "sequence_id": "0",
             }
         }
 
-    def test_ignore_resume_uses_idle_ignore_when_not_paused(self, client):
-        # For non-pause warnings (e.g. AMS-side prompts during printing),
-        # idle_ignore IS the correct command and the firmware honours it.
+    def test_ignore_resume_state_independent(self, client):
+        # BambuStudio's DeviceErrorDialog dispatches IGNORE_RESUME via
+        # `command_hms_ignore` unconditionally — there's no PAUSE-vs-RUNNING
+        # branch. Bambuddy's previous code special-cased PAUSE to a plain
+        # resume; the BambuStudio shape works in both states.
         client.state.state = "RUNNING"
-        client.execute_hms_action("03008070", HMSAction.IGNORE_RESUME)
+        client.execute_hms_action("05008051", HMSAction.IGNORE_RESUME)
+        cmds = self._published_commands(client)
+        assert cmds[0]["print"]["command"] == "ignore"
+        assert cmds[0]["print"]["err"] == str(0x05008051)
+
+    def test_ignore_no_reminder_uses_ignore_command_not_idle_ignore(self, client):
+        # BambuStudio routes IGNORE_NO_REMINDER_NEXT_TIME and
+        # DONT_REMIND_NEXT_TIME to the same `command_hms_ignore` as
+        # IGNORE_RESUME (DeviceErrorDialog.cpp:596-602) — the "don't remind"
+        # half is the firmware's responsibility, the wire shape is identical.
+        client.state.state = "PAUSE"
+        client.execute_hms_action("03008070", HMSAction.DONT_REMIND_NEXT_TIME, job_id="task-1")
+        cmds = self._published_commands(client)
+        assert cmds[0] == {
+            "print": {
+                "command": "ignore",
+                "err": str(0x03008070),
+                "param": "reserve",
+                "job_id": "task-1",
+                "sequence_id": "0",
+            }
+        }
+
+    def test_no_reminder_next_time_uses_idle_ignore_type_zero(self, client):
+        # NO_REMINDER_NEXT_TIME (distinct from IGNORE_NO_REMINDER_NEXT_TIME)
+        # is BambuStudio's `command_hms_idle_ignore` with type=0
+        # (DeviceErrorDialog.cpp:588-590). Dismisses the dialog without
+        # resuming. The `err` is the same decimal-int format as the ignore
+        # command — same `m_error_code` is passed in BambuStudio.
+        client.state.state = "RUNNING"
+        client.execute_hms_action("03008070", HMSAction.NO_REMINDER_NEXT_TIME)
         cmds = self._published_commands(client)
         assert cmds[0] == {
             "print": {
                 "command": "idle_ignore",
-                "err": "03008070",
+                "err": str(0x03008070),
                 "type": 0,
                 "sequence_id": "0",
             }
         }
 
-    def test_dont_remind_dispatches_resume_when_paused(self, client):
-        # The persistent variant still degrades to resume on a paused print —
-        # the "don't remind" flag can't ride along on a resume, but the user
-        # clicked an action whose top-level intent is to continue, so we
-        # honour that. The behavioural contract is documented in hms_ignore.
+    def test_ignore_accepts_16_char_full_code_as_decimal(self, client):
+        # hms[]-array faults carry a 16-char full identifier. Bambu's firmware
+        # matches `err` as a numeric string, so the 16-char hex parses to a
+        # 64-bit int and serializes back as its decimal form.
         client.state.state = "PAUSE"
-        client.execute_hms_action("03008070", HMSAction.DONT_REMIND_NEXT_TIME)
-        cmds = self._published_commands(client)
-        assert cmds[0]["print"]["command"] == "resume"
-
-    def test_dont_remind_uses_idle_ignore_type_one_when_not_paused(self, client):
-        client.state.state = "RUNNING"
-        client.execute_hms_action("03008070", HMSAction.DONT_REMIND_NEXT_TIME)
+        client.execute_hms_action("0C00030000020010", HMSAction.IGNORE_RESUME)
         cmds = self._published_commands(client)
-        assert cmds[0]["print"]["command"] == "idle_ignore"
-        assert cmds[0]["print"]["type"] == 1
+        assert cmds[0]["print"]["command"] == "ignore"
+        assert cmds[0]["print"]["err"] == str(0x0C00030000020010)
 
-    def test_idle_ignore_accepts_16_char_full_code(self, client):
-        # hms[]-array faults carry a 16-char full identifier. The firmware
-        # matches against the full 64-bit code; the truncated 8-char form
-        # (used pre-#1830) was silently rejected on H2C.
-        client.state.state = "RUNNING"
-        client.execute_hms_action("0C00030000020010", HMSAction.IGNORE_RESUME)
+    def test_ignore_with_no_job_id_sends_empty_string(self, client):
+        # BambuStudio's `command_hms_ignore` always passes `m_obj->job_id_`
+        # (a `std::string` — empty when there's no active subtask). Match the
+        # shape: empty string, not None / missing key.
+        client.state.state = "PAUSE"
+        client.execute_hms_action("05008051", HMSAction.IGNORE_RESUME, job_id=None)
         cmds = self._published_commands(client)
-        assert cmds[0]["print"]["err"] == "0C00030000020010"
+        assert cmds[0]["print"]["job_id"] == ""
 
     def test_filament_extruded_sends_ams_done(self, client):
         client.execute_hms_action("07008029", HMSAction.FILAMENT_EXTRUDED)

+ 70 - 2
backend/tests/unit/services/test_label_renderer.py

@@ -112,7 +112,7 @@ def test_qr_payload_is_present_in_pdf_stream():
 # ── Regression tests for the two render bugs found in the first cut ──
 
 
-def _render_uncompressed(template, data):
+def _render_uncompressed(template, data, monochrome=False):
     """Render with pageCompression=0 so the resulting PDF contains text as
     ASCII bytes. Lets tests assert "X is on the label" by grepping the PDF.
 
@@ -140,7 +140,7 @@ def _render_uncompressed(template, data):
         buf = _io.BytesIO()
         c = _rl_canvas.Canvas(buf, pagesize=(page_w, page_h), pageCompression=0)
         for d in data:
-            _draw_label(c, 0, 0, page_w, page_h, d)
+            _draw_label(c, 0, 0, page_w, page_h, d, monochrome)
             c.showPage()
         c.save()
         return buf.getvalue()
@@ -303,3 +303,71 @@ def test_box_template_does_not_truncate_normal_brand_or_name():
     assert b"Shelf 3, slot B" in pdf, "box template must render the storage location"
     # Big spool ID at bottom.
     assert b"#7" in pdf or (b"7" in pdf and b"#" in pdf), "box template must render the spool ID"
+
+
+# ── #1870: low-res thermal-printer optimisations ──
+
+
+@pytest.mark.parametrize("template", ALL_TEMPLATES)
+def test_monochrome_renders_valid_pdf_for_each_template(template):
+    """Monochrome mode must render a valid PDF for every template (#1870)."""
+    pdf = render_labels(template, [_sample(7), _sample(8)], monochrome=True)
+    assert pdf.startswith(b"%PDF"), f"{template} monochrome did not produce a PDF header"
+
+
+def test_monochrome_omits_colour_swatch():
+    """Monochrome drops the colour swatch (useless grey block on a B&W printer)
+    while the default keeps it (#1870, requested by @Geoff-S)."""
+    from unittest.mock import patch
+
+    import backend.app.services.label_renderer as lr
+
+    with patch.object(lr, "_draw_swatch") as mock_swatch:
+        render_labels("box_40x30", [_sample(1)], monochrome=True)
+        assert mock_swatch.call_count == 0, "monochrome must not draw the colour swatch"
+
+    with patch.object(lr, "_draw_swatch") as mock_swatch:
+        render_labels("box_40x30", [_sample(1)], monochrome=False)
+        assert mock_swatch.call_count == 1, "colour mode must draw the swatch"
+
+
+def test_monochrome_still_renders_text_and_hex():
+    """Dropping the swatch must not lose the colour info — the hex code line and
+    the text fields still render (the hex is how colour is conveyed in B&W)."""
+    data = [
+        LabelData(
+            spool_id=42,
+            name="Polymaker Ivory",
+            material="PLA",
+            brand="Polymaker",
+            subtype="Matte",
+            rgba="F5E6D3FF",
+            deeplink_url="https://example.test/inventory?spool=42",
+        )
+    ]
+    pdf = _render_uncompressed("box_40x30", data, monochrome=True)
+    assert b"Polymaker" in pdf, "monochrome label must still render the brand"
+    assert b"#F5E6D3" in pdf, "monochrome label must still render the hex colour code"
+    assert b"#42" in pdf or (b"42" in pdf and b"#" in pdf), "monochrome label must render the spool ID"
+
+
+def test_roomy_qr_size_has_floor_for_narrow_labels():
+    """#1870 regression: box_40x30's QR must not shrink below a scannable size.
+    The pre-fix ``inner_w * 0.20`` gave ~7.5 mm on that label; the floor keeps
+    it at 12 mm so each module clears ~3 dots on a 203 dpi thermal head.
+    """
+    from reportlab.lib.units import mm
+
+    from backend.app.services.label_renderer import _roomy_qr_size
+
+    pad = 1.2 * mm
+    # box_40x30 inner dimensions.
+    inner_w = 40 * mm - 2 * pad
+    inner_h = 30 * mm - 2 * pad
+    assert _roomy_qr_size(inner_w, inner_h) >= 12 * mm - 0.01
+
+    # Larger templates are unaffected (already above the floor) and still capped.
+    inner_w_big = 75 * mm - 2 * pad
+    inner_h_big = 55 * mm - 2 * pad
+    size_big = _roomy_qr_size(inner_w_big, inner_h_big)
+    assert 12 * mm <= size_big <= 18 * mm

+ 83 - 0
backend/tests/unit/services/test_network_utils.py

@@ -0,0 +1,83 @@
+"""Tests for network interface enumeration.
+
+Focus: the platform routing in get_network_interfaces(). macOS/BSD have fcntl
+but not the Linux SIOCGIFADDR/SIOCGIFNETMASK ioctls, so the ioctl path there
+silently returns nothing and the VP bind-interface dropdown comes up empty.
+Everything that isn't Linux must go through the cross-platform psutil path.
+"""
+
+import socket
+from collections import namedtuple
+from unittest.mock import patch
+
+from backend.app.services import network_utils
+
+# Mimic the shape of psutil.net_if_addrs() / net_if_stats() entries we read.
+_Addr = namedtuple("snicaddr", ["family", "address", "netmask", "broadcast", "ptp"])
+_Stats = namedtuple("snicstats", ["isup", "duplex", "speed", "mtu", "flags"])
+
+
+def _fake_psutil():
+    addrs = {
+        "en0": [_Addr(socket.AF_INET, "192.168.1.50", "255.255.255.0", None, None)],
+        "lo0": [_Addr(socket.AF_INET, "127.0.0.1", "255.0.0.0", None, None)],
+        "awdl0": [_Addr(socket.AF_INET, "169.254.10.20", "255.255.0.0", None, None)],
+        "utun3": [_Addr(socket.AF_INET, "100.64.0.7", "255.255.255.255", None, None)],
+        "en5": [_Addr(socket.AF_INET, "10.0.0.9", "255.255.255.0", None, None)],
+    }
+    stats = {
+        "en0": _Stats(True, 0, 0, 1500, 0),
+        "lo0": _Stats(True, 0, 0, 16384, 0),
+        "awdl0": _Stats(True, 0, 0, 1500, 0),
+        "utun3": _Stats(True, 0, 0, 1500, 0),
+        "en5": _Stats(False, 0, 0, 1500, 0),  # down → skipped
+    }
+    return addrs, stats
+
+
+@patch("backend.app.services.network_utils.sys")
+def test_macos_routes_to_psutil(mock_sys):
+    """On darwin, get_network_interfaces() must use psutil, not the ioctl path."""
+    mock_sys.platform = "darwin"
+    with patch.object(network_utils, "_get_network_interfaces_psutil", return_value=[{"name": "en0"}]) as psutil_path:
+        result = network_utils.get_network_interfaces()
+    psutil_path.assert_called_once()
+    assert result == [{"name": "en0"}]
+
+
+@patch("backend.app.services.network_utils.sys")
+def test_windows_routes_to_psutil(mock_sys):
+    mock_sys.platform = "win32"
+    with patch.object(network_utils, "_get_network_interfaces_psutil", return_value=[]) as psutil_path:
+        network_utils.get_network_interfaces()
+    psutil_path.assert_called_once()
+
+
+@patch("backend.app.services.network_utils.sys")
+def test_linux_does_not_use_psutil(mock_sys):
+    """Linux keeps the ioctl path — psutil helper must not be invoked."""
+    mock_sys.platform = "linux"
+    with patch.object(network_utils, "_get_network_interfaces_psutil") as psutil_path:
+        # The ioctl path runs for real here; we only assert it wasn't short-circuited
+        # to psutil. Its actual return depends on the host, so we don't assert on it.
+        network_utils.get_network_interfaces()
+    psutil_path.assert_not_called()
+
+
+def test_psutil_path_filters_and_returns_bindable_ips():
+    """The psutil path drops loopback/link-local/down ifaces, keeps real + VPN ones."""
+    addrs, stats = _fake_psutil()
+    with (
+        patch("psutil.net_if_addrs", return_value=addrs),
+        patch("psutil.net_if_stats", return_value=stats),
+    ):
+        result = network_utils._get_network_interfaces_psutil()
+
+    by_name = {i["name"]: i for i in result}
+    assert "en0" in by_name  # normal LAN interface
+    assert by_name["en0"]["ip"] == "192.168.1.50"
+    assert by_name["en0"]["subnet"] == "192.168.1.0/24"
+    assert "utun3" in by_name  # Tailscale/VPN — legitimately bindable
+    assert "lo0" not in by_name  # loopback filtered
+    assert "awdl0" not in by_name  # link-local (169.254) filtered
+    assert "en5" not in by_name  # interface down, skipped

+ 40 - 0
backend/tests/unit/services/test_printer_manager.py

@@ -476,6 +476,46 @@ class TestPrinterManager:
 
         assert result is True
 
+    # ========================================================================
+    # Tests for is_print_active (#1890)
+    # ========================================================================
+
+    @pytest.mark.parametrize(
+        "state,expected",
+        [
+            ("RUNNING", True),
+            ("PAUSE", True),
+            ("PREPARE", True),
+            ("SLICING", True),
+            ("FINISH", False),
+            ("IDLE", False),
+            ("FAILED", False),
+            ("unknown", False),
+        ],
+    )
+    def test_is_print_active_state_matrix(self, manager, mock_client, state, expected):
+        """A job-loaded state is 'active'; idle/terminal states are not."""
+        mock_client.state.connected = True
+        mock_client.state.state = state
+        mock_client.check_staleness.return_value = True
+        manager._clients[1] = mock_client
+
+        assert manager.is_print_active(1) is expected
+
+    def test_is_print_active_false_when_disconnected(self, manager, mock_client):
+        """Even in RUNNING, a disconnected printer is not treated as active —
+        we fail safe (no active print) only for the 'nothing printing' cases."""
+        mock_client.state.connected = False
+        mock_client.state.state = "RUNNING"
+        mock_client.check_staleness.return_value = False
+        manager._clients[1] = mock_client
+
+        assert manager.is_print_active(1) is False
+
+    def test_is_print_active_false_for_unknown_printer(self, manager):
+        """Unknown printer id → not active (no client)."""
+        assert manager.is_print_active(999) is False
+
     # ========================================================================
     # Tests for logging methods
     # ========================================================================

+ 45 - 0
backend/tests/unit/services/test_slicer_3mf_convert.py

@@ -326,3 +326,48 @@ class TestSubstituteUnusedPlateFilaments:
         items = ["a.json", "b.json", "c.json"]
         result = substitute_unused_plate_filaments(zip_bytes, plate_id=1, items=items)
         assert result == items
+
+    def test_support_material_slot_preserved(self):
+        # #1881 regression: object geometry references only slot 1 (PLA),
+        # but slot 2 (PVA) is configured as the support material in
+        # project_settings.config. Without the support-slot union, slot 2's
+        # user-picked PVA profile would be overwritten with slot 1's PLA
+        # and the print would come out single-material with PLA supports.
+        model_settings = self._model_settings_xml([(1, [1])])
+        project_settings = json.dumps(
+            {
+                "enable_support": "1",
+                "support_filament": "2",
+                "support_interface_filament": "2",
+                "filament_type": ["PLA", "PVA"],
+            }
+        ).encode()
+        zip_bytes = _make_3mf(
+            {
+                "Metadata/model_settings.config": model_settings,
+                "Metadata/project_settings.config": project_settings,
+            }
+        )
+        items = ["pla.json", "pva_support.json"]
+        result = substitute_unused_plate_filaments(zip_bytes, plate_id=1, items=items)
+        assert result == ["pla.json", "pva_support.json"]
+
+    def test_support_disabled_still_substitutes_unused(self):
+        # When supports are off, slot 2 is genuinely unused — the temp-spread
+        # validator still needs the substitution to succeed.
+        model_settings = self._model_settings_xml([(1, [1])])
+        project_settings = json.dumps(
+            {
+                "enable_support": "0",
+                "support_filament": "2",
+            }
+        ).encode()
+        zip_bytes = _make_3mf(
+            {
+                "Metadata/model_settings.config": model_settings,
+                "Metadata/project_settings.config": project_settings,
+            }
+        )
+        items = ["pla.json", "abs_never_used.json"]
+        result = substitute_unused_plate_filaments(zip_bytes, plate_id=1, items=items)
+        assert result == ["pla.json", "pla.json"]

+ 221 - 1
backend/tests/unit/services/test_smart_plug_manager.py

@@ -4,6 +4,7 @@ These tests specifically target the auto-off behavior and toggle functionality
 that were identified as common regression points.
 """
 
+import asyncio
 from datetime import datetime, timezone
 from unittest.mock import AsyncMock, MagicMock, patch
 
@@ -815,7 +816,7 @@ class TestPendingAutoOffPersistence:
             patch("backend.app.core.database.async_session") as mock_session_ctx,
             patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
             patch.object(manager, "_mark_auto_off_executed", new_callable=AsyncMock) as mock_mark,
-            patch("backend.app.services.smart_plug_manager.printer_manager"),
+            patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
         ):
             mock_db = AsyncMock()
             mock_result = MagicMock()
@@ -826,8 +827,227 @@ class TestPendingAutoOffPersistence:
             mock_session_ctx.return_value.__aexit__ = AsyncMock()
 
             mock_tasmota.turn_off = AsyncMock(return_value=True)
+            mock_pm.is_print_active.return_value = False  # printer idle on restart
 
             await manager.resume_pending_auto_offs()
 
             mock_tasmota.turn_off.assert_called_once()
             mock_mark.assert_called_once_with(1)
+
+    @pytest.mark.asyncio
+    async def test_resume_pending_auto_off_skipped_when_printing(self, manager):
+        """#1890: on restart, a stale pending off must NOT power off a live print;
+        the pending flag is cleared instead."""
+        mock_plug = MagicMock()
+        mock_plug.id = 1
+        mock_plug.name = "Test Plug"
+        mock_plug.printer_id = 1
+        mock_plug.auto_off_pending = True
+        mock_plug.auto_off_pending_since = datetime.now(timezone.utc)
+        mock_plug.off_delay_mode = "time"
+
+        with (
+            patch("backend.app.core.database.async_session") as mock_session_ctx,
+            patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
+            patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
+            patch.object(manager, "_schedule_temp_based_off") as mock_temp,
+        ):
+            mock_db = AsyncMock()
+            mock_result = MagicMock()
+            mock_result.scalars.return_value.all.return_value = [mock_plug]
+            mock_db.execute = AsyncMock(return_value=mock_result)
+            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session_ctx.return_value.__aexit__ = AsyncMock()
+
+            mock_tasmota.turn_off = AsyncMock(return_value=True)
+            mock_pm.is_print_active.return_value = True  # printer printing again on restart
+            mock_pm.get_status.return_value = MagicMock(state="RUNNING")
+
+            await manager.resume_pending_auto_offs()
+
+            mock_tasmota.turn_off.assert_not_called()  # never cut power on the live print
+            mock_temp.assert_not_called()
+            assert mock_plug.auto_off_pending is False  # stale pending cleared
+
+
+class TestActivePrintGuard:
+    """#1890 — auto-off must never cut power while a print is loaded/running.
+
+    Covers the two off-executors (`_delayed_off`, `_temp_based_off`), the new
+    queue-override scheduler that honours per-plug settings, and the
+    on_print_start cancellation gap.
+    """
+
+    @pytest.fixture
+    def manager(self):
+        return SmartPlugManager()
+
+    @pytest.fixture
+    def mock_plug(self):
+        plug = MagicMock()
+        plug.id = 1
+        plug.name = "Test Plug"
+        plug.ip_address = "192.168.1.100"
+        plug.username = None
+        plug.password = None
+        plug.enabled = True
+        plug.auto_on = True
+        plug.auto_off = True
+        plug.off_delay_mode = "time"
+        plug.off_delay_minutes = 5
+        plug.off_temp_threshold = 70
+        plug.printer_id = 1
+        plug.plug_type = "tasmota"
+        plug.ha_entity_id = None
+        return plug
+
+    # ---- _delayed_off (time mode) ----------------------------------------
+
+    @pytest.mark.asyncio
+    async def test_delayed_off_skips_when_printer_printing_again(self, manager):
+        """Time-delay fires after N min; if a reprint is running, skip the off."""
+        with (
+            patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
+            patch.object(manager, "get_service_for_plug", new_callable=AsyncMock) as mock_get_svc,
+            patch.object(manager, "_mark_auto_off_pending", new_callable=AsyncMock) as mock_mark_pending,
+            patch.object(manager, "_mark_auto_off_executed", new_callable=AsyncMock) as mock_mark_exec,
+        ):
+            mock_pm.is_print_active.return_value = True
+            mock_pm.get_status.return_value = MagicMock(state="RUNNING")
+
+            await manager._delayed_off(1, "tasmota", "1.2.3.4", None, None, None, printer_id=1, delay_seconds=0)
+
+            mock_get_svc.assert_not_called()  # never even resolved a service to turn off
+            mock_mark_exec.assert_not_called()
+            mock_mark_pending.assert_awaited_with(1, False)  # pending flag cleared
+
+    @pytest.mark.asyncio
+    async def test_delayed_off_powers_off_when_idle(self, manager):
+        """When the printer is genuinely idle, the delayed off still fires."""
+        mock_service = AsyncMock()
+        mock_service.turn_off = AsyncMock(return_value=True)
+        with (
+            patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
+            patch.object(manager, "get_service_for_plug", new_callable=AsyncMock, return_value=mock_service),
+            patch.object(manager, "_mark_auto_off_executed", new_callable=AsyncMock),
+        ):
+            mock_pm.is_print_active.return_value = False
+
+            await manager._delayed_off(1, "tasmota", "1.2.3.4", None, None, None, printer_id=1, delay_seconds=0)
+
+            mock_service.turn_off.assert_awaited_once()
+            mock_pm.mark_printer_offline.assert_called_once_with(1)
+
+    # ---- _temp_based_off (temperature mode) ------------------------------
+
+    @pytest.mark.asyncio
+    async def test_temp_based_off_defers_while_printing_even_if_cool(self, manager):
+        """Nozzle can dip below threshold during a reprint's PREPARE/heat phase;
+        the guard must defer rather than cut power on the loaded print."""
+        with (
+            patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
+            patch("backend.app.services.smart_plug_manager.asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
+            patch.object(manager, "get_service_for_plug", new_callable=AsyncMock) as mock_get_svc,
+        ):
+            # Cool enough to trip the threshold, but a print is active.
+            mock_pm.get_status.return_value = MagicMock(state="PREPARE", temperatures={"nozzle": 30})
+            mock_pm.is_print_active.return_value = True
+            # Break the poll loop after the first deferral so the test terminates.
+            mock_sleep.side_effect = asyncio.CancelledError()
+
+            await manager._temp_based_off(1, "tasmota", "1.2.3.4", None, None, None, printer_id=1, temp_threshold=70)
+
+            mock_get_svc.assert_not_called()  # never turned off despite temp < threshold
+
+    @pytest.mark.asyncio
+    async def test_temp_based_off_powers_off_when_cool_and_idle(self, manager):
+        """Cool nozzle + idle printer → turn off using the plug's threshold."""
+        mock_service = AsyncMock()
+        mock_service.turn_off = AsyncMock(return_value=True)
+        with (
+            patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
+            patch("backend.app.services.smart_plug_manager.asyncio.sleep", new_callable=AsyncMock),
+            patch.object(manager, "get_service_for_plug", new_callable=AsyncMock, return_value=mock_service),
+            patch.object(manager, "_mark_auto_off_executed", new_callable=AsyncMock),
+        ):
+            mock_pm.get_status.return_value = MagicMock(state="FINISH", temperatures={"nozzle": 40})
+            mock_pm.is_print_active.return_value = False
+
+            await manager._temp_based_off(1, "tasmota", "1.2.3.4", None, None, None, printer_id=1, temp_threshold=55)
+
+            mock_service.turn_off.assert_awaited_once()
+
+    # ---- schedule_off_after_queue_job (uses plug settings, not hardcoded 50/600)
+
+    @pytest.mark.asyncio
+    async def test_queue_off_uses_time_mode_regardless_of_global_auto_off(self, manager, mock_plug):
+        """Queue 'auto off after this job' is a per-job override — it schedules
+        even when the plug's global auto_off is disabled, and honours the plug's
+        configured time-delay mode."""
+        mock_plug.auto_off = False
+        mock_plug.off_delay_mode = "time"
+        mock_plug.off_delay_minutes = 8
+        with (
+            patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock, return_value=[mock_plug]),
+            patch.object(manager, "_schedule_delayed_off") as mock_delayed,
+            patch.object(manager, "_schedule_temp_based_off") as mock_temp,
+        ):
+            await manager.schedule_off_after_queue_job(printer_id=1, db=AsyncMock())
+
+            mock_delayed.assert_called_once_with(mock_plug, 1, 8 * 60)  # plug's minutes, not hardcoded
+            mock_temp.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_queue_off_uses_configured_temp_threshold(self, manager, mock_plug):
+        """Temperature mode passes the plug's off_temp_threshold, not a hardcoded 50."""
+        mock_plug.off_delay_mode = "temperature"
+        mock_plug.off_temp_threshold = 65
+        with (
+            patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock, return_value=[mock_plug]),
+            patch.object(manager, "_schedule_delayed_off") as mock_delayed,
+            patch.object(manager, "_schedule_temp_based_off") as mock_temp,
+        ):
+            await manager.schedule_off_after_queue_job(printer_id=1, db=AsyncMock())
+
+            mock_temp.assert_called_once_with(mock_plug, 1, 65)
+            mock_delayed.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_queue_off_skips_disabled_and_ha_script_plugs(self, manager, mock_plug):
+        """Disabled plugs and HA-script entities are never scheduled."""
+        disabled = MagicMock(id=2, name="disabled", enabled=False, plug_type="tasmota", ha_entity_id=None)
+        ha_script = MagicMock(
+            id=3, name="ha", enabled=True, plug_type="homeassistant", ha_entity_id="script.printer_off"
+        )
+        with (
+            patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock, return_value=[disabled, ha_script]),
+            patch.object(manager, "_schedule_off_per_mode") as mock_sched,
+        ):
+            await manager.schedule_off_after_queue_job(printer_id=1, db=AsyncMock())
+
+            mock_sched.assert_not_called()
+
+    # ---- on_print_start cancellation gap ---------------------------------
+
+    @pytest.mark.asyncio
+    async def test_reprint_cancels_pending_off_even_when_auto_on_disabled(self, manager, mock_plug):
+        """A reprint must abort a scheduled auto-off regardless of auto_on (#1890).
+
+        Previously the cancel lived behind the auto_on gate, so a plug with
+        auto_on disabled kept its pending off and cut power mid-reprint.
+        """
+        mock_plug.auto_on = False
+        mock_task = MagicMock()
+        manager._pending_off[mock_plug.id] = mock_task
+        with (
+            patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock, return_value=[mock_plug]),
+            patch.object(manager, "_mark_auto_off_pending", new_callable=AsyncMock),
+            patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
+        ):
+            mock_tasmota.turn_on = AsyncMock()
+
+            await manager.on_print_start(printer_id=1, db=AsyncMock())
+
+            mock_task.cancel.assert_called_once()  # cancelled despite auto_on=False
+            assert mock_plug.id not in manager._pending_off
+            mock_tasmota.turn_on.assert_not_called()  # but not powered on

+ 350 - 0
backend/tests/unit/services/test_spoolman_tray_split.py

@@ -0,0 +1,350 @@
+"""Spoolman-side mid-print tray-split accounting (#1793).
+
+Reporter (@ojimpo) shipped the OP shape:
+- H2S, AMS filament backup ON, two same-material spools loaded
+- Single-slot print (72.56g on slot 1)
+- Origin ran dry at layer 37, AMS auto-switched to backup, print finished
+- Pre-fix: whole 72.56g charged to origin (via tag path) + separate 30g to
+  backup (via remain-delta) — origin exceeded initial_weight, backup double-count
+
+The fix ports usage_tracker's split path to spoolman_tracking so both
+inventory backends attribute segments identically. These tests pin the
+OP's shape plus the Path 2 (remain-delta) skip guarantee so it can't
+double-charge tray IDs the split path already covered.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+
+class _AsyncCtx:
+    """async_session() shim — same shape as test_spoolman_no3mf_remain_fallback."""
+
+    def __init__(self, db):
+        self._db = db
+
+    async def __aenter__(self):
+        return self._db
+
+    async def __aexit__(self, *_):
+        return False
+
+
+def _make_db(tracking):
+    db = AsyncMock()
+    select_result = MagicMock()
+    select_result.scalar_one_or_none.return_value = tracking
+    db.execute = AsyncMock(return_value=select_result)
+    db.delete = AsyncMock()
+    db.commit = AsyncMock()
+    return db
+
+
+class TestReportUsageTraySplit:
+    """report_usage must consult state.tray_change_log and split per-segment."""
+
+    @pytest.mark.asyncio
+    async def test_op_sample_a_seamless_switch_splits_origin_to_backup(self):
+        """Sample A from the reporter, verbatim: 72.56g single-slot print,
+        AMS runout switch tray 0 → tray 1 at layer 37 of ~100 total.
+
+        No gcode layer_usage is provided → linear-by-layer-ratio fallback:
+        - seg 0 (tray 0, layers 0-37) = 72.56 * 37/100 = 26.85g → spool 8
+        - seg 1 (tray 1, layers 37-end) = 72.56 - 26.85 = 45.71g → spool 7
+        Path 2 (remain-delta) must NOT run against either tray — the split
+        path already covered them.
+        """
+        from backend.app.services.spoolman_tracking import report_usage
+
+        tracking = SimpleNamespace(
+            filament_usage=[{"slot_id": 1, "used_g": 72.56}],
+            ams_trays={
+                0: {"tray_uuid": "AAAA", "tag_uid": "T1TAG", "tray_type": "PLA"},
+                1: {"tray_uuid": "BBBB", "tag_uid": "T2TAG", "tray_type": "PLA"},
+            },
+            slot_to_tray=[0],
+            tray_remain_start={
+                "0-0": {"remain": 3, "tray_uuid": "AAAA"},  # origin near-empty at start-of-completion snapshot
+                "0-1": {"remain": 73, "tray_uuid": "BBBB"},
+            },
+            layer_usage={},
+            filament_properties={},
+        )
+
+        db = _make_db(tracking)
+
+        client = AsyncMock()
+
+        async def _find_spool_by_tag(tag):
+            return (
+                {"id": 8, "filament": {"color_hex": "000000"}}
+                if tag == "AAAA"
+                else {"id": 7, "filament": {"color_hex": "000000"}}
+                if tag == "BBBB"
+                else None
+            )
+
+        client.find_spool_by_tag = AsyncMock(side_effect=_find_spool_by_tag)
+        client.use_spool = AsyncMock()
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            tray_change_log=[(0, 0), (1, 37)],
+            total_layers=100,
+            layer_num=100,
+            raw_data={
+                "ams": [
+                    {
+                        "id": 0,
+                        "tray": [
+                            {"id": 0, "tray_uuid": "AAAA", "remain": 0},
+                            {"id": 1, "tray_uuid": "BBBB", "remain": 70},
+                        ],
+                    }
+                ]
+            },
+        )
+
+        with (
+            patch("backend.app.services.spoolman_tracking.async_session", lambda: _AsyncCtx(db)),
+            patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
+            patch(
+                "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
+                AsyncMock(return_value=client),
+            ),
+            patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="SERIAL")),
+            patch(
+                "backend.app.services.spoolman_tracking._apply_spool_colors_to_archive",
+                AsyncMock(),
+            ),
+            patch("backend.app.services.printer_manager.printer_manager", printer_manager),
+        ):
+            await report_usage(printer_id=1, archive_id=143)
+
+        # Exactly two use_spool calls — one per segment. Origin (spool 8)
+        # gets the layers-0-37 slice, backup (spool 7) gets the remainder.
+        calls = client.use_spool.await_args_list
+        assert len(calls) == 2, f"expected 2 use_spool calls, got {len(calls)}: {calls}"
+
+        by_spool = {c.args[0]: c.args[1] for c in calls}
+        assert set(by_spool.keys()) == {8, 7}
+
+        # Sum must equal the OP's total — no phantom grams created or lost.
+        assert round(sum(by_spool.values()), 2) == 72.56
+
+        # Origin (spool 8) should carry roughly the layers-0-37 fraction.
+        # Linear: 72.56 * 37/100 = 26.85g. Allow small rounding wiggle.
+        assert 26.0 < by_spool[8] < 28.0
+        # Backup (spool 7) carries the remainder.
+        assert 44.0 < by_spool[7] < 46.6
+
+    @pytest.mark.asyncio
+    async def test_path_2_remain_delta_skips_tray_handled_by_split(self):
+        """After the split path attributes segments to tray 0 AND tray 1,
+        the Path 2 remain-delta iterator must skip BOTH — otherwise backup
+        would get charged twice (~30g double-count in the OP's Sample A).
+        """
+        from backend.app.services.spoolman_tracking import report_usage
+
+        tracking = SimpleNamespace(
+            filament_usage=[{"slot_id": 1, "used_g": 100.0}],
+            ams_trays={
+                0: {"tray_uuid": "AAAA", "tag_uid": "T1TAG", "tray_type": "PLA"},
+                1: {"tray_uuid": "BBBB", "tag_uid": "T2TAG", "tray_type": "PLA"},
+            },
+            slot_to_tray=[0],
+            tray_remain_start={
+                "0-0": {"remain": 20, "tray_uuid": "AAAA"},
+                "0-1": {"remain": 80, "tray_uuid": "BBBB"},
+            },
+            layer_usage={},
+            filament_properties={},
+        )
+
+        db = _make_db(tracking)
+        client = AsyncMock()
+
+        async def _find_spool_by_tag(tag):
+            return {"id": 8, "filament": {}} if tag == "AAAA" else {"id": 7, "filament": {}}
+
+        client.find_spool_by_tag = AsyncMock(side_effect=_find_spool_by_tag)
+        client.use_spool = AsyncMock()
+        # If Path 2 ever runs, it needs a filament.weight to compute grams.
+        # Making it valid means a failure to guard = extra use_spool calls,
+        # not a silent skip. Combined with a truthy slot-assignment result
+        # below, this is what actually proves the double-count guard works.
+        client.get_spool = AsyncMock(return_value={"filament": {"weight": 1000.0}})
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            tray_change_log=[(0, 0), (1, 50)],
+            total_layers=100,
+            layer_num=100,
+            raw_data={
+                "ams": [
+                    {
+                        "id": 0,
+                        "tray": [
+                            {"id": 0, "tray_uuid": "AAAA", "remain": 0},
+                            {"id": 1, "tray_uuid": "BBBB", "remain": 60},
+                        ],
+                    }
+                ]
+            },
+        )
+
+        with (
+            patch("backend.app.services.spoolman_tracking.async_session", lambda: _AsyncCtx(db)),
+            patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
+            patch(
+                "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
+                AsyncMock(return_value=client),
+            ),
+            patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="SERIAL")),
+            patch(
+                # Path 2 uses this to resolve trays. Return valid IDs so
+                # the ONLY thing stopping Path 2 from double-charging is
+                # ``handled_global_tray_ids``. If the guard is broken,
+                # Path 2 would successfully call ``use_spool`` two more
+                # times and this test would fail with 4 calls, not 2.
+                "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
+                AsyncMock(side_effect=lambda pid, ams, tray: 999 if (ams, tray) == (0, 0) else 888),
+            ),
+            patch("backend.app.services.printer_manager.printer_manager", printer_manager),
+        ):
+            await report_usage(printer_id=1, archive_id=200)
+
+        # EXACTLY 2 — one per segment; Path 2 must not add a third.
+        assert client.use_spool.await_count == 2, (
+            f"Path 2 leaked past the split — expected 2 use_spool calls, got "
+            f"{client.use_spool.await_count}: {client.use_spool.await_args_list}"
+        )
+
+    @pytest.mark.asyncio
+    async def test_multi_slot_print_does_not_activate_split_even_with_tray_changes(self):
+        """Multi-colour prints normally cycle trays every colour change, so
+        ``tray_change_log`` has many entries — but splitting each slot's
+        grams across all of them would attribute slot 1's usage to segments
+        where slot 2's tray was loaded (and vice versa).
+
+        Mirrors ``usage_tracker.py:1002``'s gate: split only when there's
+        exactly one nonzero slot. Multi-slot prints fall through to the
+        existing single-tray path with its stable ``slot_to_tray`` mapping.
+        """
+        from backend.app.services.spoolman_tracking import report_usage
+
+        # Two nonzero slots — regular multi-colour print
+        tracking = SimpleNamespace(
+            filament_usage=[
+                {"slot_id": 1, "used_g": 30.0},
+                {"slot_id": 2, "used_g": 20.0},
+            ],
+            ams_trays={
+                0: {"tray_uuid": "AAAA", "tag_uid": "T1TAG", "tray_type": "PLA"},
+                1: {"tray_uuid": "BBBB", "tag_uid": "T2TAG", "tray_type": "PLA"},
+            },
+            slot_to_tray=[0, 1],
+            tray_remain_start={
+                "0-0": {"remain": 90, "tray_uuid": "AAAA"},
+                "0-1": {"remain": 80, "tray_uuid": "BBBB"},
+            },
+            layer_usage={},
+            filament_properties={},
+        )
+
+        db = _make_db(tracking)
+        client = AsyncMock()
+
+        async def _find_spool_by_tag(tag):
+            return {"id": 100, "filament": {}} if tag == "AAAA" else {"id": 200, "filament": {}}
+
+        client.find_spool_by_tag = AsyncMock(side_effect=_find_spool_by_tag)
+        client.use_spool = AsyncMock()
+
+        printer_manager = MagicMock()
+        # Multi-colour print naturally cycles between trays many times.
+        # If we don't gate on single-slot, my split would attribute slot 1's
+        # grams to every segment — including segments where tray 1 was loaded.
+        printer_manager.get_status.return_value = SimpleNamespace(
+            tray_change_log=[(0, 0), (1, 10), (0, 20), (1, 30), (0, 40)],
+            total_layers=50,
+            layer_num=50,
+            raw_data={
+                "ams": [
+                    {
+                        "id": 0,
+                        "tray": [
+                            {"id": 0, "tray_uuid": "AAAA", "remain": 87},
+                            {"id": 1, "tray_uuid": "BBBB", "remain": 78},
+                        ],
+                    }
+                ]
+            },
+        )
+
+        with (
+            patch("backend.app.services.spoolman_tracking.async_session", lambda: _AsyncCtx(db)),
+            patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
+            patch(
+                "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
+                AsyncMock(return_value=client),
+            ),
+            patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="SERIAL")),
+            patch("backend.app.services.printer_manager.printer_manager", printer_manager),
+        ):
+            await report_usage(printer_id=1, archive_id=42)
+
+        # Split path must NOT engage. The single-tray path charges each
+        # slot to its stable slot_to_tray mapping: slot 1 → tray 0 → spool
+        # 100 (30g), slot 2 → tray 1 → spool 200 (20g). Two calls, exact
+        # weights from the 3MF (not split).
+        assert client.use_spool.await_count == 2
+        by_spool = {c.args[0]: c.args[1] for c in client.use_spool.await_args_list}
+        assert by_spool == {100: 30.0, 200: 20.0}
+
+    @pytest.mark.asyncio
+    async def test_single_tray_change_entry_uses_normal_path(self):
+        """Only ONE entry in tray_change_log (start-of-print seed, no
+        switch) must fall through to the existing single-tray charging
+        path — not accidentally split when there's nothing to split.
+        """
+        from backend.app.services.spoolman_tracking import report_usage
+
+        tracking = SimpleNamespace(
+            filament_usage=[{"slot_id": 1, "used_g": 50.0}],
+            ams_trays={0: {"tray_uuid": "AAAA", "tag_uid": "T1TAG", "tray_type": "PLA"}},
+            slot_to_tray=[0],
+            tray_remain_start={"0-0": {"remain": 80, "tray_uuid": "AAAA"}},
+            layer_usage={},
+            filament_properties={},
+        )
+
+        db = _make_db(tracking)
+        client = AsyncMock()
+        client.find_spool_by_tag = AsyncMock(return_value={"id": 8, "filament": {}})
+        client.use_spool = AsyncMock()
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            tray_change_log=[(0, 0)],  # just the start-of-print seed
+            total_layers=100,
+            layer_num=100,
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "tray_uuid": "AAAA", "remain": 75}]}]},
+        )
+
+        with (
+            patch("backend.app.services.spoolman_tracking.async_session", lambda: _AsyncCtx(db)),
+            patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
+            patch(
+                "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
+                AsyncMock(return_value=client),
+            ),
+            patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="SERIAL")),
+            patch("backend.app.services.printer_manager.printer_manager", printer_manager),
+        ):
+            await report_usage(printer_id=1, archive_id=42)
+
+        # Single-tray path: exactly one use_spool call, all 50g to spool 8.
+        client.use_spool.assert_awaited_once_with(8, 50.0)

+ 140 - 0
backend/tests/unit/services/test_virtual_printer.py

@@ -3175,6 +3175,146 @@ class TestVirtualPrinterManagerDirectories:
         assert (tmp_path / "certs" / "42").exists()
 
 
+class TestVirtualPrinterCameraPassthrough:
+    """Tests for the non-proxy VP camera pass-through port selection (#1868).
+
+    The camera port must follow the TARGET printer's model, not the VP's
+    spoofed model: RTSP models (X1/X2/H2/P2S) use 322, chamber-image models
+    (A1/P1) use 6000. Before the fix, the manager hardcoded 322, so P1S /
+    A1 targets got a 322 listener with no upstream and BambuStudio /
+    OrcaSlicer Liveview failed with error `[2:-10061]` (the reporter's
+    symptom in #1868).
+    """
+
+    @staticmethod
+    def _patch_start_server(monkeypatch):
+        """Patch every non-camera constructor start_server touches so the
+        test only exercises the camera-port branch. Returns the list that
+        ``TCPProxy`` calls get captured into."""
+        from backend.app.services.virtual_printer import manager as vp_manager
+
+        tcp_calls: list[dict] = []
+
+        class FakeTCPProxy:
+            def __init__(self, **kwargs):
+                tcp_calls.append(kwargs)
+                self.kwargs = kwargs
+
+            async def start(self):
+                return None
+
+            async def stop(self):
+                return None
+
+        # Camera pass-through is the only TCPProxy start_server constructs.
+        monkeypatch.setattr(vp_manager, "TCPProxy", FakeTCPProxy)
+
+        # No-op every other service constructor + resolve_cert_and_advertise.
+        # start_server() awaits `.ready.wait()` on FTP/MQTT/Bind/SSDP after
+        # spawning them, so each fake instance must carry an already-set
+        # asyncio.Event as `.ready` — a plain MagicMock returns another
+        # MagicMock for `.wait()`, which `asyncio.gather` then rejects with
+        # `TypeError: An asyncio.Future, a coroutine or an awaitable is
+        # required`.
+        def _service_factory():
+            def make(*args, **kwargs):
+                inst = MagicMock()
+                inst.start = AsyncMock(return_value=None)
+                inst.stop = AsyncMock(return_value=None)
+                ready = asyncio.Event()
+                ready.set()
+                inst.ready = ready
+                return inst
+
+            return make
+
+        for name in (
+            "VirtualPrinterFTPServer",
+            "SimpleMQTTServer",
+            "MQTTBridge",
+            "BindServer",
+            "VirtualPrinterSSDPServer",
+            "SSDPProxy",
+        ):
+            monkeypatch.setattr(vp_manager, name, _service_factory())
+
+        return tcp_calls
+
+    async def _run_start_server(
+        self,
+        tmp_path,
+        monkeypatch,
+        *,
+        target_model: str,
+        target_ip: str = "192.168.1.100",
+    ) -> list[dict]:
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        tcp_calls = self._patch_start_server(monkeypatch)
+
+        instance = VirtualPrinterInstance(
+            vp_id=99,
+            name="CamTest",
+            mode="archive",
+            model="BL-P001",  # VP's spoofed identity — irrelevant here
+            access_code="12345678",
+            serial_suffix="391800099",
+            target_printer_id=7,
+            base_dir=tmp_path,
+        )
+
+        # printer_manager stub returns a client with the model + ip we want.
+        client = MagicMock()
+        client.ip_address = target_ip
+        client.model = target_model
+        printer_manager = MagicMock()
+        printer_manager.get_client.return_value = client
+        instance._printer_manager = printer_manager
+
+        # Cert / advertise resolution — start_server calls this early. Patch
+        # to a fixed tuple so no filesystem I/O is required.
+        monkeypatch.setattr(
+            instance,
+            "_resolve_cert_and_advertise",
+            lambda: (Path("/tmp/cert.pem"), Path("/tmp/key.pem"), "192.168.1.1"),  # nosec B108
+        )
+
+        try:
+            await instance.start_server()
+        finally:
+            for task in instance._tasks:
+                task.cancel()
+            await asyncio.gather(*instance._tasks, return_exceptions=True)
+
+        return tcp_calls
+
+    @pytest.mark.asyncio
+    async def test_rtsp_model_p2s_opens_port_322(self, tmp_path, monkeypatch):
+        calls = await self._run_start_server(tmp_path, monkeypatch, target_model="P2S")
+        assert any(c["listen_port"] == 322 and c["target_port"] == 322 for c in calls), (
+            f"Expected 322 pass-through for RTSP model P2S, got {calls}"
+        )
+
+    @pytest.mark.asyncio
+    async def test_chamber_image_model_p1s_opens_port_6000(self, tmp_path, monkeypatch):
+        """#1868 regression guard: P1S target must expose 6000, not 322."""
+        calls = await self._run_start_server(tmp_path, monkeypatch, target_model="P1S")
+        assert any(c["listen_port"] == 6000 and c["target_port"] == 6000 for c in calls), (
+            f"Expected 6000 pass-through for chamber-image model P1S (#1868), got {calls}"
+        )
+        assert not any(c["listen_port"] == 322 for c in calls), f"P1S should NOT get a 322 listener, got {calls}"
+
+    @pytest.mark.asyncio
+    async def test_chamber_image_model_a1_opens_port_6000(self, tmp_path, monkeypatch):
+        calls = await self._run_start_server(tmp_path, monkeypatch, target_model="A1")
+        assert any(c["listen_port"] == 6000 and c["target_port"] == 6000 for c in calls)
+
+    @pytest.mark.asyncio
+    async def test_rtsp_model_x1c_opens_port_322(self, tmp_path, monkeypatch):
+        calls = await self._run_start_server(tmp_path, monkeypatch, target_model="X1C")
+        assert any(c["listen_port"] == 322 and c["target_port"] == 322 for c in calls)
+
+
 class TestVirtualPrinterInstanceProxyMode:
     """Tests for VirtualPrinterInstance proxy mode."""
 

+ 102 - 0
backend/tests/unit/test_scheduler_ams_mapping.py

@@ -1029,6 +1029,61 @@ class TestExtractNozzleMappingFrom3mf:
         assert result is None
         zf.close()
 
+    def test_single_active_under_report_with_multi_group_falls_through(self):
+        """#1825: extruder_nozzle_stats under-reports the second nozzle, but the
+        slice genuinely uses two extruders (group_id 0 and 1). The shortcut must
+        NOT fire — the parser has to honour the per-filament group_id assignment.
+
+        H2D HT-AMS scenario from the report: physical_extruder_map ['1','0'],
+        extruder_nozzle_stats ['Standard#1','Standard#0'] (sum==1), but ASA is
+        group_id 0 (→ LEFT) and PETG is group_id 1 (→ RIGHT). Before the fix
+        the shortcut collapsed both onto LEFT; now group_id wins.
+        """
+        slice_info = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+          <plate>
+            <filament id="1" type="ASA"  color="#FF0000" used_g="5.0" group_id="0"/>
+            <filament id="2" type="PETG" color="#00FF00" used_g="3.0" group_id="1"/>
+          </plate>
+        </config>"""
+        zf = _make_3mf_zip(
+            {
+                "physical_extruder_map": ["1", "0"],
+                "extruder_nozzle_stats": ["Standard#1", "Standard#0"],
+            },
+            slice_info_xml=slice_info,
+        )
+        result = extract_nozzle_mapping_from_3mf(zf)
+        # group_id 0 → physical_extruder_map[0] = 1 (LEFT)
+        # group_id 1 → physical_extruder_map[1] = 0 (RIGHT)
+        assert result == {1: 1, 2: 0}
+        zf.close()
+
+    def test_single_active_with_single_group_still_uses_shortcut(self):
+        """When stats report one active extruder AND the slice is truly
+        single-group, the shortcut is still correct and must continue to fire
+        (preserves the #851 behaviour for genuine single-nozzle prints made on
+        a multi-nozzle printer where only one nozzle is installed).
+        """
+        slice_info = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+          <plate>
+            <filament id="1" type="PLA" color="#FF0000" used_g="5.0" group_id="0"/>
+            <filament id="2" type="PLA" color="#00FF00" used_g="3.0" group_id="0"/>
+          </plate>
+        </config>"""
+        zf = _make_3mf_zip(
+            {
+                "physical_extruder_map": ["1", "0"],
+                "extruder_nozzle_stats": ["Standard#1", "Standard#0"],
+            },
+            slice_info_xml=slice_info,
+        )
+        result = extract_nozzle_mapping_from_3mf(zf)
+        # Only extruder index 0 is active → physical_extruder_map[0] = 1 (LEFT)
+        assert result == {1: 1, 2: 1}
+        zf.close()
+
 
 class TestNozzleAwareMapping:
     """Test nozzle-aware filament matching in the print scheduler."""
@@ -1446,3 +1501,50 @@ class TestX1CModel:
         ]
         result = scheduler._match_filaments_to_slots(required, loaded)
         assert result == [5, 7]
+
+
+class TestFtsNozzleBypass:
+    """#2186: with a Filament Track Switch (FTS) installed, the per-nozzle hard
+    filter in the dispatch mapping must be skipped so a spool loaded in the OTHER
+    nozzle's AMS can be routed to the requested nozzle. Without an FTS the filter
+    still applies (cross-nozzle assignment fails on the printer).
+    """
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    def _repro_loaded(self):
+        # Mirrors the reporter's H2C: AMS-A (id 0) on the LEFT extruder (1) holds
+        # the black PLA the model wants; AMS-B (id 1) on the RIGHT extruder (0)
+        # holds a red PLA.
+        return [
+            {"type": "PLA", "color": "#000000", "global_tray_id": 1, "extruder_id": 1},  # AMS-A slot2 black, LEFT
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 6, "extruder_id": 0},  # AMS-B slot3 red, RIGHT
+        ]
+
+    def test_fts_routes_correct_colour_from_other_nozzle(self, scheduler):
+        """Model wants black PLA on the RIGHT nozzle; the black spool sits on the
+        LEFT AMS. With FTS the nozzle filter is skipped, so it picks the black spool
+        (gtid 1) — not the wrong-colour red already on the right (gtid 6).
+        Reproduces #2186 (b3 fed red instead of black)."""
+        required = [{"slot_id": 1, "type": "PLA", "color": "#000000", "nozzle_id": 0}]
+        result = scheduler._match_filaments_to_slots(required, self._repro_loaded(), fts_installed=True)
+        assert result == [1]  # black PLA from AMS-A, routed to the right nozzle by the FTS
+
+    def test_without_fts_nozzle_filter_still_applies(self, scheduler):
+        """No FTS: cross-nozzle assignment isn't possible, so a RIGHT-nozzle request
+        is restricted to right-side trays and matches the red PLA by type. Correct
+        non-FTS behaviour — and exactly why #2186 needed the FTS bypass."""
+        required = [{"slot_id": 1, "type": "PLA", "color": "#000000", "nozzle_id": 0}]
+        result = scheduler._match_filaments_to_slots(required, self._repro_loaded(), fts_installed=False)
+        assert result == [6]  # only the right-side PLA is eligible
+
+    def test_fts_flag_is_noop_without_nozzle_id(self, scheduler):
+        """Single-nozzle 3MFs carry no nozzle_id, so the per-nozzle filter never
+        runs and the FTS flag changes nothing — the exact-colour match wins either
+        way. (Single-nozzle printers also never have an FTS.)"""
+        required = [{"slot_id": 1, "type": "PLA", "color": "#000000"}]
+        for fts in (True, False):
+            result = scheduler._match_filaments_to_slots(required, self._repro_loaded(), fts_installed=fts)
+            assert result == [1]  # exact black match, no nozzle involved

+ 27 - 18
backend/tests/unit/test_scheduler_auto_drying.py

@@ -238,19 +238,24 @@ class TestStopDrying:
 
 
 class TestMinimumDryingTime:
-    """Regression: drying should not stop/restart rapidly when humidity oscillates near threshold."""
+    """Regression #1892: a running drying cycle must never be stopped by a humidity re-check.
+
+    Relative humidity reads low in heated air (the AMS sensor sees ~15-20% within
+    minutes of the dryer starting even while the filament is still saturated), so a
+    humidity-based auto-stop would truncate every cycle — manual or Bambuddy-started —
+    to the old minimum-time floor. Drying is now left to run to its configured
+    duration; the firmware stops it when the duration elapses.
+    """
 
     @pytest.fixture
     def scheduler(self):
-        s = PrintScheduler()
-        s._min_drying_seconds = 1800  # 30 minutes
-        return s
+        return PrintScheduler()
 
     @pytest.mark.asyncio
     @patch("backend.app.services.print_scheduler.printer_manager")
     @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
     async def test_no_stop_before_minimum_time(self, mock_sd, mock_pm, scheduler):
-        """Drying should NOT stop when humidity drops below threshold before 30 min."""
+        """Drying should NOT stop when humidity drops below threshold shortly after start."""
         # Simulate: drying started 5 minutes ago
         scheduler._drying_in_progress = {1: time.monotonic() - 300}
 
@@ -305,9 +310,9 @@ class TestMinimumDryingTime:
     @pytest.mark.asyncio
     @patch("backend.app.services.print_scheduler.printer_manager")
     @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
-    async def test_stops_after_minimum_time(self, mock_sd, mock_pm, scheduler):
-        """Drying SHOULD stop when humidity below threshold AND 30 min elapsed."""
-        # Simulate: drying started 35 minutes ago
+    async def test_no_stop_after_long_elapsed_time(self, mock_sd, mock_pm, scheduler):
+        """#1892: drying must NOT stop even long after start with low humidity — let it run."""
+        # Simulate: drying started 35 minutes ago, humidity reads low (heated air)
         scheduler._drying_in_progress = {1: time.monotonic() - 2100}
 
         state = MagicMock()
@@ -346,8 +351,11 @@ class TestMinimumDryingTime:
 
         await scheduler._check_auto_drying(db, [item], set())
 
-        # Should have sent stop command (humidity-based stop after minimum time)
-        mock_pm.send_drying_command.assert_any_call(1, 0, temp=0, duration=0, mode=0)
+        # Must NOT send a humidity-based stop — drying is left to run to its duration
+        for call in mock_pm.send_drying_command.call_args_list:
+            assert call != ((1, 0), {"temp": 0, "duration": 0, "mode": 0}), (
+                "Humidity re-check must never stop a running drying cycle (#1892)"
+            )
 
     @staticmethod
     def _make_setting(value):
@@ -783,19 +791,17 @@ class TestAmbientDrying(_DryingTestBase):
 
 
 class TestBlockForDryingBugFix(_DryingTestBase):
-    """Regression: block mode should not skip humidity auto-stop for already-drying printers."""
+    """Regression: block mode gates NEW drying starts but must leave running dries alone (#1892)."""
 
     @pytest.fixture
     def scheduler(self):
-        s = PrintScheduler()
-        s._min_drying_seconds = 1800
-        return s
+        return PrintScheduler()
 
     @pytest.mark.asyncio
     @patch("backend.app.services.print_scheduler.printer_manager")
     @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
-    async def test_block_mode_allows_humidity_stop_for_active_drying(self, mock_sd, mock_pm, scheduler):
-        """Bug fix: printer already drying in block mode should still check humidity to auto-stop."""
+    async def test_block_mode_leaves_active_drying_running(self, mock_sd, mock_pm, scheduler):
+        """#1892: a printer already drying in block mode must not be stopped by a humidity re-check."""
         # Drying started 35 minutes ago
         scheduler._drying_in_progress = {1: time.monotonic() - 2100}
 
@@ -837,8 +843,11 @@ class TestBlockForDryingBugFix(_DryingTestBase):
 
         await scheduler._check_auto_drying(db, [item], set())
 
-        # Should have sent stop command — humidity dropped below threshold after 30+ min
-        mock_pm.send_drying_command.assert_any_call(1, 0, temp=0, duration=0, mode=0)
+        # Must NOT stop the running dry — block mode gates new starts, not active cycles
+        for call in mock_pm.send_drying_command.call_args_list:
+            assert call != ((1, 0), {"temp": 0, "duration": 0, "mode": 0}), (
+                "Block mode must not stop an already-running drying cycle (#1892)"
+            )
 
     @pytest.mark.asyncio
     @patch("backend.app.services.print_scheduler.printer_manager")

+ 1 - 1
backend/tests/unit/test_scheduler_backup_gate.py

@@ -50,7 +50,7 @@ async def _run_with_backup(scheduler, backup_state, prefer_lowest_setting):
 
     captured: dict = {}
 
-    def _capture_match(reqs, loaded_, prefer, overrides):
+    def _capture_match(reqs, loaded_, prefer, overrides, fts_installed=False):
         captured["prefer_lowest"] = prefer
         return [0]
 

+ 237 - 0
backend/tests/unit/test_scheduler_cancel_race.py

@@ -0,0 +1,237 @@
+"""Cancel-during-dispatch race regression (#1853).
+
+The user reported: queued a batch of 10 prints, pressed Cancel on a pending
+item, the print started anyway. Root cause is a check-then-act race in
+``_start_print``: the snapshot of pending items is taken at the top of
+``check_queue``, then ``_start_print`` does FTP delete + FTP upload (5-30 s)
+before flipping the row to ``"printing"`` and sending MQTT. If the user wins
+the race and ``/cancel`` lands during that window, the scheduler's stale
+in-memory write of ``status="printing"`` silently overwrites the cancellation.
+
+Three guards exercised here:
+
+* Early refresh after the connectivity check — bails before FTP I/O if the
+  row is already cancelled.
+* Atomic CAS at the pending→printing transition — UPDATE WHERE
+  status='pending'; rowcount==0 means user won, do NOT send MQTT.
+* Best-effort delete of the file we just FTP'd up when the CAS aborts.
+"""
+
+from contextlib import ExitStack
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+import backend.app.models  # noqa: F401 - populate Base.metadata
+import backend.app.services.print_scheduler as scheduler_module
+from backend.app.core.database import Base
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+from backend.app.services.print_scheduler import PrintScheduler
+
+
+@pytest.fixture
+async def queue_factory(tmp_path):
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+
+    session_maker = async_sessionmaker(engine, expire_on_commit=False)
+    case_counter = 0
+
+    async def make_case(*, status="pending"):
+        nonlocal case_counter
+        case_counter += 1
+
+        base_dir = tmp_path / f"case-{case_counter}"
+        base_dir.mkdir()
+        archive_rel = Path("archives") / f"job-{case_counter}.3mf"
+        archive_abs = base_dir / archive_rel
+        archive_abs.parent.mkdir(parents=True, exist_ok=True)
+        archive_abs.write_bytes(b"archive payload")
+
+        async with session_maker() as db:
+            printer = Printer(
+                name=f"Printer {case_counter}",
+                serial_number=f"SERIAL-{case_counter}",
+                ip_address="127.0.0.1",
+                access_code="access-code",
+                model="X1C",
+            )
+            db.add(printer)
+            await db.flush()
+
+            archive = PrintArchive(
+                printer_id=printer.id,
+                filename=f"job-{case_counter}.3mf",
+                file_path=str(archive_rel),
+                file_size=archive_abs.stat().st_size,
+                content_hash=None,
+                thumbnail_path=None,
+                timelapse_path=None,
+                print_time_seconds=120,
+                status="completed",
+            )
+            db.add(archive)
+            await db.flush()
+
+            item = PrintQueueItem(
+                printer_id=printer.id,
+                archive_id=archive.id,
+                status=status,
+                bed_levelling=True,
+                flow_cali=False,
+                vibration_cali=True,
+                layer_inspect=False,
+                timelapse=False,
+                use_ams=True,
+                nozzle_offset_cali=True,
+            )
+            db.add(item)
+            await db.commit()
+
+            return SimpleNamespace(
+                session_maker=session_maker,
+                base_dir=base_dir,
+                archive_path=archive_abs,
+                printer_id=printer.id,
+                archive_id=archive.id,
+                queue_item_id=item.id,
+                upload=AsyncMock(return_value=True),
+                start_print=MagicMock(return_value=True),
+                delete_file=AsyncMock(return_value=True),
+            )
+
+    try:
+        yield make_case
+    finally:
+        await engine.dispose()
+
+
+async def _dispatch(ctx, *, upload_side_effect=None):
+    scheduler = PrintScheduler()
+
+    if upload_side_effect is not None:
+        ctx.upload.side_effect = upload_side_effect
+
+    patches = [
+        patch.object(scheduler_module.settings, "base_dir", ctx.base_dir),
+        patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
+        patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
+        patch("backend.app.services.print_scheduler.printer_manager.start_print", ctx.start_print),
+        patch("backend.app.services.print_scheduler.printer_manager.set_awaiting_plate_clear", MagicMock()),
+        patch(
+            "backend.app.services.print_scheduler.get_ftp_retry_settings",
+            AsyncMock(return_value=(False, 0, 0, 1.0)),
+        ),
+        patch("backend.app.services.print_scheduler.delete_file_async", ctx.delete_file),
+        patch("backend.app.services.print_scheduler.upload_file_async", ctx.upload),
+        patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
+        patch("backend.app.services.print_scheduler.spawn_background_task", MagicMock()),
+        patch(
+            "backend.app.services.notification_service.notification_service.on_queue_job_started",
+            AsyncMock(),
+        ),
+        patch(
+            "backend.app.services.notification_service.notification_service.on_queue_job_failed",
+            AsyncMock(),
+        ),
+        patch("backend.app.services.mqtt_relay.mqtt_relay.on_queue_job_started", AsyncMock()),
+        patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
+        patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
+        patch.object(scheduler, "_preheat_and_soak", AsyncMock()),
+    ]
+
+    with ExitStack() as stack:
+        for patcher in patches:
+            stack.enter_context(patcher)
+
+        async with ctx.session_maker() as db:
+            item = await db.get(PrintQueueItem, ctx.queue_item_id)
+            await scheduler._start_print(db, item)
+
+
+async def _final_status(ctx):
+    async with ctx.session_maker() as db:
+        item = await db.get(PrintQueueItem, ctx.queue_item_id)
+        return item.status, item.started_at
+
+
+@pytest.mark.asyncio
+async def test_cancel_during_ftp_upload_aborts_before_mqtt(queue_factory):
+    """User wins the race during the FTP upload — CAS must detect & bail.
+
+    This is the headline #1853 scenario: snapshot saw pending, FTP upload
+    starts, user clicks Cancel, /cancel commits ``cancelled`` to the row,
+    FTP finishes successfully, scheduler reaches the CAS. CAS rowcount must
+    be 0; ``printer_manager.start_print`` must NOT be called; row must stay
+    ``cancelled``; uploaded file must be deleted from the printer's SD.
+    """
+    ctx = await queue_factory()
+
+    async def cancel_mid_upload(*args, **kwargs):
+        # Simulate /cancel landing in a separate session while FTP is in
+        # flight. The endpoint commits status='cancelled' then returns 200.
+        async with ctx.session_maker() as other_db:
+            other_item = await other_db.get(PrintQueueItem, ctx.queue_item_id)
+            other_item.status = "cancelled"
+            await other_db.commit()
+        return True
+
+    await _dispatch(ctx, upload_side_effect=cancel_mid_upload)
+
+    status, started_at = await _final_status(ctx)
+    assert status == "cancelled", "CAS overwrote the user's cancellation"
+    assert started_at is None, "started_at must not be stamped on a cancelled row"
+    ctx.start_print.assert_not_called()
+    # Two delete calls — the pre-upload sweep and the post-CAS cleanup.
+    assert ctx.delete_file.await_count == 2
+
+
+@pytest.mark.asyncio
+async def test_cancel_before_ftp_upload_skips_dispatch(queue_factory):
+    """Early-refresh path: row was cancelled before _start_print resumed.
+
+    Mirrors the case where ``/cancel`` lands between the ``check_queue``
+    snapshot and the time ``_start_print`` runs. The early ``db.refresh``
+    after the connectivity check sees ``cancelled`` and returns immediately
+    — no FTP upload, no MQTT send, row unchanged.
+    """
+    ctx = await queue_factory()
+
+    # Flip to cancelled before _start_print runs; the in-memory snapshot
+    # the scheduler holds still reads 'pending', exactly the bug shape.
+    async with ctx.session_maker() as other_db:
+        item = await other_db.get(PrintQueueItem, ctx.queue_item_id)
+        item.status = "cancelled"
+        await other_db.commit()
+
+    await _dispatch(ctx)
+
+    status, started_at = await _final_status(ctx)
+    assert status == "cancelled"
+    assert started_at is None
+    ctx.upload.assert_not_awaited()
+    ctx.start_print.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_happy_path_still_dispatches(queue_factory):
+    """Sanity: no cancel, no race — pending row flips to printing, MQTT fires.
+
+    Regression guard so the CAS doesn't accidentally block normal dispatch
+    on a row that was always pending.
+    """
+    ctx = await queue_factory()
+
+    await _dispatch(ctx)
+
+    status, started_at = await _final_status(ctx)
+    assert status == "printing"
+    assert started_at is not None
+    ctx.upload.assert_awaited_once()
+    ctx.start_print.assert_called_once()

+ 152 - 0
backend/tests/unit/test_scheduler_clear_plate.py

@@ -280,6 +280,158 @@ class TestSchedulerIdleCheckWithPlateCleared:
         assert scheduler._is_printer_idle(1, require_plate_clear=False) is True
 
 
+class TestPlateGateDefaultsOffWhenUnset:
+    """#1865: with no require_plate_clear row in the settings table, the plate-clear
+    gate must default OFF — matching SettingsSchema.require_plate_clear (default False)
+    and the frontend (toggle + card badge both treat a missing value as off). The
+    scheduler previously read this setting with default=True, so on installs that had
+    never saved the setting the gate stayed enforced while the UI showed it disabled —
+    FINISH-state printers never dispatched and there was no UI control to clear the flag.
+    """
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    @pytest.mark.asyncio
+    async def test_get_bool_setting_honors_false_default_when_row_absent(self, scheduler):
+        """_get_bool_setting must return the caller's default when the key has no row."""
+        from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+
+        import backend.app.models  # noqa: F401
+        from backend.app.core.database import Base
+
+        engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+        async with engine.begin() as conn:
+            await conn.run_sync(Base.metadata.create_all)
+        session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+        async with session_maker() as db:
+            # No Settings row seeded → the caller's default decides the value.
+            assert await scheduler._get_bool_setting(db, "require_plate_clear", default=False) is False
+        await engine.dispose()
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_check_queue_reads_plate_clear_setting_with_default_false(self, mock_pm, scheduler):
+        """The per-check read of require_plate_clear must pass default=False (#1865).
+
+        Guards the exact regression: a True default here re-enabled the gate the
+        schema/UI treat as off when no settings row exists.
+        """
+        scheduler._get_bool_setting = AsyncMock(return_value=False)
+        scheduler._check_auto_drying = AsyncMock()
+
+        mock_result = MagicMock()
+        mock_result.scalars.return_value.all.return_value = []  # empty queue → early return
+
+        with patch("backend.app.services.print_scheduler.async_session") as mock_session_ctx:
+            mock_db = AsyncMock()
+            mock_db.execute = AsyncMock(return_value=mock_result)
+            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
+            await scheduler.check_queue()
+
+        plate_calls = [
+            c
+            for c in scheduler._get_bool_setting.call_args_list
+            if len(c.args) >= 2 and c.args[1] == "require_plate_clear"
+        ]
+        assert plate_calls, "check_queue did not read the require_plate_clear setting"
+        assert plate_calls[0].kwargs.get("default") is False, (
+            "require_plate_clear must be read with default=False to match the schema/UI (#1865)"
+        )
+
+
+class TestPlateGateEndToEnd:
+    """#1865 end-to-end: chain the REAL settings DB read (_get_bool_setting) into the
+    REAL idle/dispatch gate (_is_printer_idle) for every setting state, so the wiring
+    the bug lived in (settings row/absence -> require_plate_clear -> dispatch gate) is
+    verified without mocking the value under test.
+    """
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    async def _read_setting(self, scheduler, row_value):
+        """Build a real in-memory settings DB (optionally with a require_plate_clear
+        row) and return what the scheduler's call site actually reads."""
+        from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+
+        import backend.app.models  # noqa: F401
+        from backend.app.core.database import Base
+        from backend.app.models.settings import Settings
+
+        engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+        async with engine.begin() as conn:
+            await conn.run_sync(Base.metadata.create_all)
+        session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+        async with session_maker() as db:
+            if row_value is not None:
+                db.add(Settings(key="require_plate_clear", value=row_value))
+                await db.commit()
+            # Mirror the exact call site in check_queue (print_scheduler.py).
+            value = await scheduler._get_bool_setting(db, "require_plate_clear", default=False)
+        await engine.dispose()
+        return value
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        "row_value, expected_gate",
+        [
+            (None, False),  # fresh install / never saved -> #1865 case -> gate OFF
+            ("false", False),  # explicitly disabled -> gate OFF
+            ("true", True),  # explicitly enabled -> gate ON
+            ("True", True),  # case-insensitive parse
+        ],
+    )
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_finish_awaiting_dispatch_eligibility_matches_setting(
+        self, mock_pm, scheduler, row_value, expected_gate
+    ):
+        """A FINISH printer with the awaiting flag raised is dispatch-eligible IFF the
+        gate is off. Reads the setting from a real DB, then feeds it to the real gate."""
+        require_plate_clear = await self._read_setting(scheduler, row_value)
+        assert require_plate_clear is expected_gate
+
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_status.return_value = MagicMock(state="FINISH")
+        mock_pm.is_awaiting_plate_clear.return_value = True  # bed potentially fouled
+
+        is_idle = scheduler._is_printer_idle(1, require_plate_clear)
+        # Gate OFF -> idle (dispatches). Gate ON -> not idle (waits for ack).
+        assert is_idle is (not expected_gate)
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_enabled_gate_releases_after_plate_cleared(self, mock_pm, scheduler):
+        """With the setting explicitly ON, clearing the plate (awaiting -> False) must
+        flip the FINISH printer to dispatch-eligible — the full block-then-release cycle."""
+        require_plate_clear = await self._read_setting(scheduler, "true")
+        assert require_plate_clear is True
+
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_status.return_value = MagicMock(state="FINISH")
+
+        # Before ack: awaiting -> blocked.
+        mock_pm.is_awaiting_plate_clear.return_value = True
+        assert scheduler._is_printer_idle(1, require_plate_clear) is False
+
+        # After "Mark plate as cleared" (route sets flag False): dispatch-eligible.
+        mock_pm.is_awaiting_plate_clear.return_value = False
+        assert scheduler._is_printer_idle(1, require_plate_clear) is True
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_running_never_idle_regardless_of_gate(self, mock_pm, scheduler):
+        """Sanity: a RUNNING printer is never dispatch-eligible, gate on or off."""
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_status.return_value = MagicMock(state="RUNNING")
+        mock_pm.is_awaiting_plate_clear.return_value = False
+        for gate in (True, False):
+            assert scheduler._is_printer_idle(1, gate) is False
+
+
 class TestSchedulerQueueCheckLogging:
     """Test queue check logging when pending items are found (#374)."""
 

+ 568 - 0
backend/tests/unit/test_scheduler_preheat.py

@@ -0,0 +1,568 @@
+"""Tests for the preheat & heat-soak scheduler stage (#1468).
+
+Three layered concerns the stage has to get right:
+
+1. **Override resolution** (per-item beats global beats default).
+2. **Chamber-target derivation** (item-override > filament-map max > 0).
+3. **Hardware-tier branching** (chamber heater vs sensor-only vs no sensor).
+
+The fixtures construct a queue item with `preheat_override` + the override
+target both unset; tests flip those per case. `asyncio.sleep` is patched to
+AsyncMock so the soak phase doesn't actually wait — assertions are on what
+got scheduled, not wall-clock.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.print_scheduler import PrintScheduler
+
+
+@pytest.fixture
+def scheduler():
+    return PrintScheduler()
+
+
+@pytest.fixture
+def item():
+    return SimpleNamespace(
+        id=42,
+        preheat_override="inherit",
+        preheat_chamber_target_override=None,
+    )
+
+
+@pytest.fixture
+def archive():
+    return SimpleNamespace(bed_temperature=60)
+
+
+def _make_printer(model: str, printer_id: int = 7):
+    return SimpleNamespace(id=printer_id, model=model)
+
+
+def _make_client():
+    client = MagicMock()
+    client.set_bed_temperature = MagicMock(return_value=True)
+    client.set_chamber_temperature = MagicMock(return_value=True)
+    client.set_airduct_mode = MagicMock(return_value=True)
+    return client
+
+
+def _make_state(
+    bed_temp: float = 0.0,
+    chamber_temp: float = 0.0,
+    trays: list[str] | None = None,
+    airduct_mode: int = 0,
+):
+    """Build a PrinterState-shaped namespace with optional AMS tray types.
+
+    `trays` is a list of tray_type strings (each becomes one loaded slot in
+    AMS unit 0). Empty / None gives an empty AMS — the derivation falls
+    through to 0. `airduct_mode` is 0 (cooling, default) or 1 (heating);
+    matches the field on PrinterState that the preheat stage reads to
+    decide whether to fire a redundant `set_airduct_mode` call."""
+    raw_data: dict = {}
+    if trays is not None:
+        raw_data["ams"] = [{"tray": [{"tray_type": t} for t in trays]}]
+    return SimpleNamespace(
+        temperatures={"bed": bed_temp, "chamber": chamber_temp},
+        raw_data=raw_data,
+        airduct_mode=airduct_mode,
+    )
+
+
+def _ints(**values):
+    """Mock side_effect for _get_int_setting that returns the kwarg value
+    when the key matches, else the helper's `default` argument."""
+    return AsyncMock(side_effect=lambda _db, key, default: values.get(key, default))
+
+
+# ----------------------------------------------------------------------------
+# Override resolution
+# ----------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_global_disabled_inherit_skips(scheduler, item, archive):
+    """preheat_enabled=False + item.preheat_override='inherit' → no heater dispatch."""
+    db = MagicMock()
+    client = _make_client()
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=False)),
+        patch.object(scheduler, "_get_int_setting", _ints()),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+    ):
+        pm.get_client.return_value = client
+        await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
+
+    client.set_bed_temperature.assert_not_called()
+    client.set_chamber_temperature.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_item_override_off_bypasses_global_on(scheduler, item, archive):
+    """preheat_enabled=True + item.preheat_override='off' → preheat suppressed."""
+    db = MagicMock()
+    client = _make_client()
+    item.preheat_override = "off"
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_get_int_setting", _ints()),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+    ):
+        pm.get_client.return_value = client
+        await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
+
+    client.set_bed_temperature.assert_not_called()
+    client.set_chamber_temperature.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_item_override_on_runs_despite_global_off(scheduler, item, archive):
+    """preheat_enabled=False + item.preheat_override='on' → preheat runs (bed
+    fires, chamber depends on the resolved target)."""
+    db = MagicMock()
+    client = _make_client()
+    item.preheat_override = "on"
+    item.preheat_chamber_target_override = 0  # explicit no-chamber so the assertion is sharp
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=False)),
+        patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+        patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
+    ):
+        pm.get_client.return_value = client
+        pm.get_status.return_value = _make_state(60.0, 0.0)
+        await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
+
+    client.set_bed_temperature.assert_called_once_with(60)
+    client.set_chamber_temperature.assert_not_called()
+
+
+# ----------------------------------------------------------------------------
+# Chamber-target derivation
+# ----------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_chamber_target_override_beats_filament_map(scheduler, item, archive):
+    """item.preheat_chamber_target_override is the highest-priority source —
+    a PLA-only print with an explicit 50°C override still heats the chamber."""
+    db = MagicMock()
+    client = _make_client()
+    item.preheat_chamber_target_override = 50
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+        patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
+    ):
+        pm.get_client.return_value = client
+        # Only PLA loaded — filament map would derive 0; override forces 50.
+        pm.get_status.return_value = _make_state(60.0, 52.0, trays=["PLA Basic"])
+        await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
+
+    client.set_bed_temperature.assert_called_once_with(60)
+    client.set_chamber_temperature.assert_called_once_with(50)
+
+
+@pytest.mark.asyncio
+async def test_filament_map_picks_max_across_loaded_slots(scheduler, item, archive):
+    """Mixed PA + PLA load: PA=50 + PLA=0 → chamber target 50 (the max).
+    The "lowest common denominator" model is wrong here; PA's requirement
+    is the binding constraint."""
+    db = MagicMock()
+    client = _make_client()
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
+        patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+        patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
+    ):
+        pm.get_client.return_value = client
+        # `PA Basic` (note the space) normalises to `PA` which the bundled
+        # map keys against; a hyphenated `PA-Generic` would normalise to
+        # `PA-GENERIC` and fall through to `default` (0) — that's a separate
+        # behaviour the user editor handles by adding a custom key.
+        pm.get_status.return_value = _make_state(60.0, 52.0, trays=["PLA Basic", "PA Basic"])
+        await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
+
+    client.set_chamber_temperature.assert_called_once_with(50)  # PA's recommendation, not PLA's
+
+
+@pytest.mark.asyncio
+async def test_pla_only_derives_zero_chamber_skips(scheduler, item, archive):
+    """PLA-only print: filament-map lookup returns 0 → chamber phase skips
+    automatically without the user touching anything."""
+    db = MagicMock()
+    client = _make_client()
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
+        patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+        patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
+    ):
+        pm.get_client.return_value = client
+        pm.get_status.return_value = _make_state(60.0, 0.0, trays=["PLA", "PLA"])
+        await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
+
+    client.set_bed_temperature.assert_called_once_with(60)
+    client.set_chamber_temperature.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_unknown_filament_type_falls_to_default(scheduler, item, archive):
+    """A loaded tray with a type not in the map uses the `default` entry —
+    keeps users with custom filament names safe (they get 0 by default,
+    can be tuned via the per-filament editor)."""
+    db = MagicMock()
+    client = _make_client()
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
+        patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+        patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
+    ):
+        pm.get_client.return_value = client
+        pm.get_status.return_value = _make_state(60.0, 0.0, trays=["MyCustomFilament"])
+        await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
+
+    client.set_bed_temperature.assert_called_once_with(60)
+    client.set_chamber_temperature.assert_not_called()  # default = 0
+
+
+@pytest.mark.asyncio
+async def test_custom_filament_targets_json_parses(scheduler, item, archive):
+    """User-customised filament-target JSON overrides the bundled defaults —
+    raising PLA to 30°C makes a PLA-only print actually heat the chamber."""
+    db = MagicMock()
+    client = _make_client()
+    custom_map = '{"PLA": 30, "default": 0}'
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
+        patch.object(scheduler, "_get_setting", AsyncMock(return_value=custom_map)),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+        patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
+    ):
+        pm.get_client.return_value = client
+        pm.get_status.return_value = _make_state(60.0, 31.0, trays=["PLA Basic"])
+        await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
+
+    client.set_chamber_temperature.assert_called_once_with(30)
+
+
+@pytest.mark.asyncio
+async def test_malformed_filament_targets_falls_back_to_defaults(scheduler, item, archive):
+    """A corrupted JSON in the setting must not break the scheduler — log
+    and use bundled defaults."""
+    db = MagicMock()
+    client = _make_client()
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
+        patch.object(scheduler, "_get_setting", AsyncMock(return_value="not-json{{{")),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+        patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
+    ):
+        pm.get_client.return_value = client
+        # Bundled default for ABS = 45 → chamber should fire.
+        pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"])
+        await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
+
+    client.set_chamber_temperature.assert_called_once_with(45)
+
+
+# ----------------------------------------------------------------------------
+# Hardware-tier branching (unchanged from the first cut but updated for new
+# fixtures that include AMS data so the derivation lands at a non-zero target).
+# ----------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_no_bed_temperature_in_archive_skips(scheduler, item):
+    """Archive without bed_temperature metadata skips entirely rather than
+    guessing a default that might wreck a non-PLA print."""
+    db = MagicMock()
+    client = _make_client()
+    bare_archive = SimpleNamespace(bed_temperature=None)
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_get_int_setting", _ints()),
+        patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+    ):
+        pm.get_client.return_value = client
+        pm.get_status.return_value = _make_state(trays=["ABS"])
+        await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), bare_archive)
+
+    client.set_bed_temperature.assert_not_called()
+    client.set_chamber_temperature.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_x1c_skips_m141_but_waits_passively(scheduler, item, archive):
+    """X1C has a chamber sensor but no active heater — M141 must NOT fire even
+    when the filament map derives a non-zero target."""
+    db = MagicMock()
+    client = _make_client()
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
+        patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+        patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
+    ):
+        pm.get_client.return_value = client
+        # ABS loaded → derived target 45; sensor reads 46 (already there).
+        pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"])
+        await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive)
+
+    client.set_bed_temperature.assert_called_once_with(60)
+    client.set_chamber_temperature.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_p1s_no_chamber_sensor_uses_soak_timer_only(scheduler, item, archive):
+    """P1S has no chamber sensor — derived target is ignored for the wait
+    loop, only the soak timer applies."""
+    db = MagicMock()
+    client = _make_client()
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=600)),
+        patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+        patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()) as sleep_mock,
+    ):
+        pm.get_client.return_value = client
+        pm.get_status.return_value = _make_state(60.0, 0.0, trays=["ABS"])
+        await scheduler._preheat_and_soak(db, item, _make_printer("P1S"), archive)
+
+    client.set_bed_temperature.assert_called_once_with(60)
+    client.set_chamber_temperature.assert_not_called()
+    assert 600 in [call.args[0] for call in sleep_mock.call_args_list]
+
+
+@pytest.mark.asyncio
+async def test_lost_client_skips_silently(scheduler, item, archive):
+    """If the MQTT client drops, the helper returns without raising."""
+    db = MagicMock()
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_get_int_setting", _ints()),
+        patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+    ):
+        pm.get_client.return_value = None
+        await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
+    # No exception escaping — the disable path is silent.
+
+
+@pytest.mark.asyncio
+async def test_h2d_flips_airduct_to_heating_before_m141(scheduler, item, archive):
+    """H-series + X2D have a cooling/heating airduct flap that DEFAULTS to
+    cooling. If we energise M141 without first flipping to heating, the
+    chamber fan actively extracts the heat we're trying to put in and the
+    chamber never converges. Verify airduct=heating fires AND lands before
+    the chamber-target call so the heater starts in the right airflow regime."""
+    db = MagicMock()
+    client = _make_client()
+    call_order = []
+    client.set_airduct_mode.side_effect = lambda mode: call_order.append(("airduct", mode)) or True
+    client.set_chamber_temperature.side_effect = lambda t: call_order.append(("chamber", t)) or True
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
+        patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+        patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
+    ):
+        pm.get_client.return_value = client
+        pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"])
+        await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
+
+    client.set_airduct_mode.assert_called_once_with("heating")
+    client.set_chamber_temperature.assert_called_once_with(45)
+    # Airduct heating must precede M141 — the heater enabling first while the
+    # flap is still in cooling mode wastes minutes of fan-vs-heater tug-of-war.
+    assert call_order == [("airduct", "heating"), ("chamber", 45)]
+
+
+@pytest.mark.asyncio
+async def test_x1c_skips_airduct_no_heater_no_call(scheduler, item, archive):
+    """X1C has neither an active chamber heater nor an airduct flap (the
+    frontend's airduct whitelist is P2S/X2D/H2D/H2C/H2S/H2D Pro — no X1
+    series). The preheat stage's airduct call is gated on supports_airduct
+    AND has_heater, so X1C gets neither call regardless. Important: a
+    spurious set_airduct on X1C wouldn't just be wasted MQTT — there's no
+    flap to set, so the firmware response would be undefined behaviour."""
+    db = MagicMock()
+    client = _make_client()
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
+        patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+        patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
+    ):
+        pm.get_client.return_value = client
+        pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"])
+        await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive)
+
+    client.set_chamber_temperature.assert_not_called()
+    client.set_airduct_mode.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_normalize_filament_type_strips_at_space():
+    """`PLA Basic` and `ABS Premium` should normalise to `PLA` and `ABS` so
+    they match the map keys. `PA-CF` has no space and stays verbatim."""
+    s = PrintScheduler
+    assert s._normalize_filament_type("PLA Basic") == "PLA"
+    assert s._normalize_filament_type("ABS Premium") == "ABS"
+    assert s._normalize_filament_type("PA-CF") == "PA-CF"
+    assert s._normalize_filament_type("") == ""
+    assert s._normalize_filament_type("petg") == "PETG"  # case-folded
+
+
+@pytest.mark.asyncio
+async def test_get_preheat_filament_targets_defaults_when_missing(scheduler):
+    """Empty / null setting → bundled defaults are used. _get_preheat_filament_targets
+    upper-cases the keys, so the bundled `default` becomes `DEFAULT` on the
+    returned dict — keep both spellings synced."""
+    db = MagicMock()
+    with patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)):
+        targets = await scheduler._get_preheat_filament_targets(db)
+    # The bundled defaults dict is kept as-is on the "no setting" path, so
+    # `default` (lowercase) is what callers see for that fallback.
+    assert targets["PLA"] == 0
+    assert targets["ABS"] == 45
+    assert targets["PA-CF"] == 55
+    # Either casing must resolve to the fallback 0.
+    assert targets.get("default", targets.get("DEFAULT")) == 0
+
+
+# ----------------------------------------------------------------------------
+# Airduct mode switch (#1468 follow-up)
+# ----------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_h2d_chamber_heat_switches_airduct_to_heating(scheduler, item, archive):
+    """H2D in cooling mode (the default; what you get after a PLA print)
+    with chamber_target > 0 must switch the airduct to heating BEFORE the
+    M141 dispatch — otherwise the open exhaust flap actively fights the
+    chamber heater and the chamber never converges."""
+    db = MagicMock()
+    client = _make_client()
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
+        patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+        patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
+    ):
+        pm.get_client.return_value = client
+        # Currently in cooling (mode 0). ABS loaded → derived target 45.
+        pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"], airduct_mode=0)
+        await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
+
+    client.set_airduct_mode.assert_called_once_with("heating")
+    client.set_chamber_temperature.assert_called_once_with(45)
+
+
+@pytest.mark.asyncio
+async def test_h2d_chamber_zero_switches_airduct_to_cooling(scheduler, item, archive):
+    """H2D running a PLA print (chamber_target derives 0) on an airduct
+    previously left in heating mode (from a prior ABS run) must switch
+    back to cooling. Otherwise PLA prints inherit ABS's closed-flap recirc
+    and run hot."""
+    db = MagicMock()
+    client = _make_client()
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
+        patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+        patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
+    ):
+        pm.get_client.return_value = client
+        # Currently in heating (mode 1). PLA loaded → derived target 0.
+        pm.get_status.return_value = _make_state(60.0, 30.0, trays=["PLA"], airduct_mode=1)
+        await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
+
+    client.set_airduct_mode.assert_called_once_with("cooling")
+    client.set_chamber_temperature.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_h2d_airduct_already_correct_idempotent(scheduler, item, archive):
+    """If the airduct is already in the desired mode, don't re-send
+    `set_airduct` — the firmware accepts it but it generates needless MQTT
+    chatter and could thrash the flap motor on rapid repeats."""
+    db = MagicMock()
+    client = _make_client()
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
+        patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+        patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
+    ):
+        pm.get_client.return_value = client
+        # Already in heating (mode 1) and ABS → derived 45 wants heating.
+        pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"], airduct_mode=1)
+        await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
+
+    client.set_airduct_mode.assert_not_called()
+    # But M141 still fires — the airduct decision is independent.
+    client.set_chamber_temperature.assert_called_once_with(45)
+
+
+@pytest.mark.asyncio
+async def test_x1c_no_airduct_flap_never_fires_set_airduct(scheduler, item, archive):
+    """X1C has a chamber sensor but no airduct flap — the firmware ignores
+    `set_airduct`. We gate on `supports_airduct(model)` to avoid sending the
+    no-op. Regression guard: wiring this to `supports_chamber_temp` or
+    `supports_chamber_heater` alone would have leaked the command to
+    X1C/X1E or P2S inappropriately."""
+    db = MagicMock()
+    client = _make_client()
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
+        patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+        patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
+    ):
+        pm.get_client.return_value = client
+        pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"], airduct_mode=0)
+        await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive)
+
+    client.set_airduct_mode.assert_not_called()

+ 153 - 0
backend/tests/unit/test_slice_process_support_patch.py

@@ -0,0 +1,153 @@
+"""Regression tests for the #1881 support-settings patch on slice requests.
+
+BambuStudio's shipped process presets ("0.20mm Standard @BBL H2D" etc.)
+define `enable_support: 0` because supports are a per-print decision, not
+a per-quality one. Bambuddy passes the picked process preset via
+`--load-settings`, which is authoritative — every field in the loaded
+JSON overrides the source 3MF's embedded `project_settings.config`. So
+without patching, a user who exported a source 3MF with supports
+configured (PLA in slot 1 + PVA in slot 2 for support_interface,
+enable_support on) got a single-material output with the PVA slot loaded
+but never used.
+
+The patch reads support-related fields from the source's
+project_settings.config and overlays them onto the process preset JSON,
+so the source's per-project support intent survives `--load-settings`.
+"""
+
+import io
+import json
+import zipfile
+
+from backend.app.api.routes.library import _patch_process_support_settings
+
+
+def _make_3mf(project_settings: dict | None) -> bytes:
+    buf = io.BytesIO()
+    with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+        zf.writestr("3D/3dmodel.model", "<model/>")
+        if project_settings is not None:
+            zf.writestr("Metadata/project_settings.config", json.dumps(project_settings))
+    return buf.getvalue()
+
+
+class TestPatchProcessSupportSettings:
+    def test_preserves_source_enable_support_and_interface_slot(self):
+        # Reporter's exact #1881 config: source has supports on with PVA
+        # in slot 2 for the interface. Shipped process preset has all four
+        # fields off. Post-patch, the source wins for the support keys and
+        # the process preset's own layer_height stays untouched.
+        source = _make_3mf(
+            {
+                "enable_support": "1",
+                "support_filament": "0",
+                "support_interface_filament": "2",
+                "support_type": "normal(manual)",
+                "filament_type": ["PLA", "PVA"],
+            }
+        )
+        preset = json.dumps(
+            {
+                "name": "0.20mm Standard @BBL H2D",
+                "enable_support": "0",
+                "support_filament": "0",
+                "support_interface_filament": "0",
+                "support_type": "default",
+                "layer_height": "0.20",
+            }
+        )
+        result = json.loads(_patch_process_support_settings(preset, source))
+        assert result["enable_support"] == "1"
+        assert result["support_filament"] == "0"
+        assert result["support_interface_filament"] == "2"
+        assert result["support_type"] == "normal(manual)"
+        # Non-support fields survive.
+        assert result["layer_height"] == "0.20"
+        assert result["name"] == "0.20mm Standard @BBL H2D"
+
+    def test_source_supports_off_beats_preset_supports_on(self):
+        # Symmetric: a source with supports explicitly disabled must win
+        # over a process preset that happens to have supports on. Rare in
+        # practice (Bambu's presets ship off) but the semantic is "source
+        # wins" regardless of direction — a user who exported without
+        # supports doesn't want a preset accidentally re-enabling them.
+        source = _make_3mf(
+            {
+                "enable_support": "0",
+                "support_filament": "0",
+                "support_interface_filament": "0",
+            }
+        )
+        preset = json.dumps({"enable_support": "1", "support_filament": "2", "support_interface_filament": "2"})
+        result = json.loads(_patch_process_support_settings(preset, source))
+        assert result["enable_support"] == "0"
+        assert result["support_filament"] == "0"
+        assert result["support_interface_filament"] == "0"
+
+    def test_only_patches_keys_present_in_source(self):
+        # Source with a partial support config (e.g. legacy 3MFs from an
+        # older BambuStudio) only overrides the keys it defines. Preset's
+        # values for the other support keys survive.
+        source = _make_3mf({"enable_support": "1"})
+        preset = json.dumps(
+            {
+                "enable_support": "0",
+                "support_filament": "2",
+                "support_interface_filament": "3",
+                "support_type": "tree(auto)",
+            }
+        )
+        result = json.loads(_patch_process_support_settings(preset, source))
+        assert result["enable_support"] == "1"
+        # Preset's values kept for keys the source didn't define.
+        assert result["support_filament"] == "2"
+        assert result["support_interface_filament"] == "3"
+        assert result["support_type"] == "tree(auto)"
+
+    def test_no_project_settings_in_source_returns_preset_unchanged(self):
+        # STL / STEP / a stripped-down 3MF has no project_settings.config;
+        # nothing to overlay, preset must pass through untouched.
+        source = _make_3mf(None)
+        preset = json.dumps({"enable_support": "0", "layer_height": "0.20"})
+        result = _patch_process_support_settings(preset, source)
+        # Same JSON round-trips.
+        assert json.loads(result) == {"enable_support": "0", "layer_height": "0.20"}
+
+    def test_malformed_source_returns_preset_unchanged(self):
+        # A malformed source 3MF (or a random blob) can't yield support
+        # info; the slice then runs with the preset's own defaults, which
+        # is the safe fall-back matching pre-fix behaviour.
+        preset = json.dumps({"enable_support": "0"})
+        assert json.loads(_patch_process_support_settings(preset, b"not a zip")) == {"enable_support": "0"}
+
+    def test_malformed_project_settings_json_returns_preset_unchanged(self):
+        buf = io.BytesIO()
+        with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("Metadata/project_settings.config", "{not json")
+        source = buf.getvalue()
+        preset = json.dumps({"enable_support": "0"})
+        assert json.loads(_patch_process_support_settings(preset, source)) == {"enable_support": "0"}
+
+    def test_source_project_settings_not_dict_returns_preset_unchanged(self):
+        # Defensive: spec says it's a dict, but a source that ships a
+        # top-level list (or anything non-dict) shouldn't crash the slice.
+        buf = io.BytesIO()
+        with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("Metadata/project_settings.config", json.dumps([]))
+        source = buf.getvalue()
+        preset = json.dumps({"enable_support": "0"})
+        assert json.loads(_patch_process_support_settings(preset, source)) == {"enable_support": "0"}
+
+    def test_malformed_preset_json_returns_input_unchanged(self):
+        # Symmetric to test_returns_input_unchanged_when_json_is_invalid
+        # in the bed-type patch's test suite. The slicer would error on
+        # the preset anyway; the patch is a straight passthrough so
+        # failure attributes to the original input.
+        source = _make_3mf({"enable_support": "1"})
+        bogus = "not a json document"
+        assert _patch_process_support_settings(bogus, source) is bogus
+
+    def test_preset_json_not_a_dict_returns_input_unchanged(self):
+        source = _make_3mf({"enable_support": "1"})
+        not_a_dict = json.dumps(["this", "is", "an", "array"])
+        assert _patch_process_support_settings(not_a_dict, source) is not_a_dict

+ 93 - 0
backend/tests/unit/test_threemf_tools.py

@@ -16,6 +16,7 @@ from backend.app.utils.threemf_tools import (
     extract_plate_extruder_set_from_3mf,
     extract_print_time_from_3mf,
     extract_project_filaments_from_3mf,
+    extract_support_filament_slots_from_3mf,
     get_cumulative_usage_at_layer,
     mm_to_grams,
     parse_gcode_layer_filament_usage,
@@ -941,3 +942,95 @@ class TestExtractPrintTimeFrom3mf:
 
         assert extract_print_time_from_3mf(file_path) is None
         assert extract_print_time_from_3mf(file_path, plate_id=2) is None
+
+
+# ---------------------------------------------------------------------------
+# Tests for extract_support_filament_slots_from_3mf — #1881: a plate that uses
+# PVA (or any material) exclusively for supports doesn't reference the support
+# slot from object geometry, so without this helper substitute_unused_plate_
+# filaments overwrites the user's support-material profile with slot 1's.
+# ---------------------------------------------------------------------------
+
+
+class TestExtractSupportFilamentSlotsFrom3mf:
+    def test_pla_object_plus_pva_support_returns_support_slot(self):
+        # The reporter's exact scenario (#1881): slot 1 = PLA (model),
+        # slot 2 = PVA (support). enable_support on. Without this the
+        # substitute logic replaces slot 2's PVA profile with PLA and
+        # the printed supports come out in PLA.
+        cfg = json.dumps(
+            {
+                "enable_support": "1",
+                "support_filament": "2",
+                "support_interface_filament": "2",
+                "filament_type": ["PLA", "PVA"],
+            }
+        )
+        with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf:
+            assert extract_support_filament_slots_from_3mf(zf) == {2}
+
+    def test_distinct_support_body_and_interface_slots(self):
+        cfg = json.dumps(
+            {
+                "enable_support": "1",
+                "support_filament": "2",
+                "support_interface_filament": "3",
+            }
+        )
+        with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf:
+            assert extract_support_filament_slots_from_3mf(zf) == {2, 3}
+
+    def test_supports_disabled_returns_empty(self):
+        # enable_support off — supports won't be printed even if a slot is
+        # configured. Don't force it into the "used" set; substitution
+        # should still homogenise the loaded-filament array.
+        cfg = json.dumps(
+            {
+                "enable_support": "0",
+                "support_filament": "2",
+                "support_interface_filament": "2",
+            }
+        )
+        with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf:
+            assert extract_support_filament_slots_from_3mf(zf) == set()
+
+    def test_slot_zero_treated_as_same_as_model(self):
+        # BambuStudio's `0` for support_filament means "same as model" —
+        # no dedicated slot to preserve.
+        cfg = json.dumps(
+            {
+                "enable_support": "1",
+                "support_filament": "0",
+                "support_interface_filament": "0",
+            }
+        )
+        with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf:
+            assert extract_support_filament_slots_from_3mf(zf) == set()
+
+    def test_boolean_enable_support_accepted(self):
+        # Some forks / older versions write a real JSON bool instead of "1"/"0".
+        cfg = json.dumps({"enable_support": True, "support_filament": "2"})
+        with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf:
+            assert extract_support_filament_slots_from_3mf(zf) == {2}
+
+    def test_integer_slot_value_accepted(self):
+        cfg = json.dumps({"enable_support": "1", "support_filament": 3})
+        with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf:
+            assert extract_support_filament_slots_from_3mf(zf) == {3}
+
+    def test_missing_project_settings_returns_empty(self):
+        with _make_3mf_with({"placeholder.txt": "hi"}) as zf:
+            assert extract_support_filament_slots_from_3mf(zf) == set()
+
+    def test_malformed_json_returns_empty(self):
+        with _make_3mf_with({"Metadata/project_settings.config": b"{not json"}) as zf:
+            assert extract_support_filament_slots_from_3mf(zf) == set()
+
+    def test_root_is_list_returns_empty(self):
+        with _make_3mf_with({"Metadata/project_settings.config": json.dumps([])}) as zf:
+            assert extract_support_filament_slots_from_3mf(zf) == set()
+
+    def test_non_numeric_slot_value_skipped(self):
+        cfg = json.dumps({"enable_support": "1", "support_filament": "not-a-number"})
+        with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf:
+            assert extract_support_filament_slots_from_3mf(zf) == set()

+ 113 - 0
backend/tests/unit/test_upload_progress_bridge.py

@@ -0,0 +1,113 @@
+"""Throttle contract for the scheduler's upload-progress bridge.
+
+The legacy bg-dispatch path (last seen in
+backend/app/services/background_dispatch.py before commit 61c8898b) used:
+    - 200 ms time gate
+    - 256 KB byte gate
+    - always emit on first call and at uploaded >= total
+
+The scheduler-driven dispatch must feel identical to the pre-#1625 path,
+so the throttle here mirrors that 1:1.
+"""
+
+from __future__ import annotations
+
+import asyncio
+
+import pytest
+
+from backend.app.services.print_scheduler import _UploadProgressBridge
+
+
+@pytest.mark.asyncio
+async def test_first_call_always_emits(monkeypatch):
+    bridge = _UploadProgressBridge(user_id=1, queue_item_id=1)
+    bridge._loop = asyncio.get_running_loop()
+    calls: list[tuple[int, int]] = []
+
+    def fake_run(coro, loop):  # noqa: ARG001
+        coro.close()
+        calls.append((1, 1))
+
+    monkeypatch.setattr("backend.app.services.print_scheduler.asyncio.run_coroutine_threadsafe", fake_run)
+
+    # First chunk, tiny payload — must emit so the user sees something
+    # even for sub-chunk-size files where the upload finishes inside the
+    # very first FTP callback.
+    bridge(8192, 16384)
+    assert len(calls) == 1
+
+
+@pytest.mark.asyncio
+async def test_emit_at_completion_even_under_throttle_gates(monkeypatch):
+    bridge = _UploadProgressBridge(user_id=1, queue_item_id=2)
+    bridge._loop = asyncio.get_running_loop()
+    calls: list[int] = []
+
+    def fake_run(coro, loop):  # noqa: ARG001
+        coro.close()
+        calls.append(1)
+
+    monkeypatch.setattr("backend.app.services.print_scheduler.asyncio.run_coroutine_threadsafe", fake_run)
+
+    # First call, force pretend-recent emit so neither time nor byte gate fires.
+    bridge(50_000, 1_000_000)
+    bridge._last_emit_monotonic = float("inf") * 0 + 1e18  # implausibly recent
+    bridge._last_emit_bytes = 50_000
+
+    # Mid-upload chunk well under both gates — would normally skip.
+    bridge(60_000, 1_000_000)
+
+    # Completion — must always emit so the bar locks at 100%.
+    bridge(1_000_000, 1_000_000)
+
+    # First + completion. Mid-upload chunk skipped (last_emit_monotonic is
+    # in the future, byte step is only 10 KB).
+    assert len(calls) == 2
+
+
+@pytest.mark.asyncio
+async def test_emit_after_256kb_step_even_under_time_gate(monkeypatch):
+    bridge = _UploadProgressBridge(user_id=1, queue_item_id=3)
+    bridge._loop = asyncio.get_running_loop()
+    calls: list[int] = []
+
+    def fake_run(coro, loop):  # noqa: ARG001
+        coro.close()
+        calls.append(1)
+
+    monkeypatch.setattr("backend.app.services.print_scheduler.asyncio.run_coroutine_threadsafe", fake_run)
+
+    bridge(8192, 10_000_000)  # first emit
+    # Pretend time gate not met but byte gate IS met (256 KB further).
+    bridge._last_emit_monotonic = 1e18
+    bridge._last_emit_bytes = 8192
+
+    bridge(8192 + 256 * 1024 + 1, 10_000_000)
+    assert len(calls) == 2
+
+
+@pytest.mark.asyncio
+async def test_no_emit_when_total_bytes_zero(monkeypatch):
+    bridge = _UploadProgressBridge(user_id=1, queue_item_id=4)
+    bridge._loop = asyncio.get_running_loop()
+    calls: list[int] = []
+
+    def fake_run(coro, loop):  # noqa: ARG001
+        coro.close()
+        calls.append(1)
+
+    monkeypatch.setattr("backend.app.services.print_scheduler.asyncio.run_coroutine_threadsafe", fake_run)
+
+    bridge(0, 0)
+    bridge(100, 0)
+
+    assert calls == []
+
+
+def test_silent_when_no_running_loop_captured():
+    """Constructed outside an asyncio loop — the bridge captures None and
+    every call is a no-op."""
+    bridge = _UploadProgressBridge(user_id=1, queue_item_id=5)
+    assert bridge._loop is None
+    bridge(1, 100)  # must not raise

+ 68 - 1
backend/tests/unit/test_vp_ftp_stor.py

@@ -10,7 +10,9 @@ up a real TLS/FTP server.
 """
 
 import asyncio
+import io
 import ssl
+import zipfile
 from unittest.mock import AsyncMock, MagicMock
 
 import pytest
@@ -18,6 +20,22 @@ import pytest
 from backend.app.services.virtual_printer.ftp_server import MAX_UPLOAD_BYTES, FTPSession
 
 
+def _valid_3mf_bytes() -> bytes:
+    """A minimal but structurally valid ZIP (stands in for a .gcode.3mf).
+
+    Bambu 3MF files are ZIP containers; the streaming STOR path validates the
+    received file opens as a ZIP before acking 226 (#1896), so happy-path
+    tests must feed real ZIP bytes rather than arbitrary filler.
+    """
+    buf = io.BytesIO()
+    with zipfile.ZipFile(buf, "w") as zf:
+        zf.writestr("Metadata/slice_info.config", "<config/>")
+        zf.writestr("3D/3dmodel.model", "<model/>")
+        # Pad an entry so the archive spans several 64 KiB read chunks.
+        zf.writestr("plate_1.gcode", b"G1 X0 Y0\n" * 40000)
+    return buf.getvalue()
+
+
 def _make_session(tmp_path, *, data_chunks: list[bytes]) -> FTPSession:
     """Build an FTPSession primed with a pre-fed StreamReader so cmd_STOR
     can iterate through the chunks without a real TCP connection.
@@ -63,8 +81,9 @@ def _make_session(tmp_path, *, data_chunks: list[bytes]) -> FTPSession:
 async def test_stor_writes_payload_to_disk(tmp_path):
     """Happy path: chunks fed to the data reader land in the upload_dir
     with the right content + the slicer gets 226."""
-    payload = b"X" * (3 * 64 * 1024 + 123)  # 3 chunks + a partial one
+    payload = _valid_3mf_bytes()  # spans several 64 KiB chunks, opens as ZIP
     chunks = [payload[i : i + 65536] for i in range(0, len(payload), 65536)]
+    assert len(chunks) > 3  # exercise the multi-chunk read loop
     session = _make_session(tmp_path, data_chunks=chunks)
     session.send = AsyncMock()
 
@@ -78,6 +97,54 @@ async def test_stor_writes_payload_to_disk(tmp_path):
     sent_codes = [args[0][0] for args in session.send.call_args_list]
     assert 150 in sent_codes  # "Opening data connection"
     assert 226 in sent_codes  # "Transfer complete"
+    assert 426 not in sent_codes
+
+
+@pytest.mark.asyncio
+async def test_stor_rejects_truncated_3mf(tmp_path):
+    """#1896: a .3mf whose tail was lost (uvloop ragged-EOF data loss, or any
+    other silent truncation) must NOT be acked with 226 — the read loop sees a
+    clean EOF and no write error, so only a ZIP-integrity check catches it.
+    Reject with 426, drop the file, and never fire the on_file_received
+    callback that would archive/queue/forward the corrupt job."""
+    payload = _valid_3mf_bytes()
+    truncated = payload[: len(payload) - 4096]  # drop the EOCD-bearing tail
+    chunks = [truncated[i : i + 65536] for i in range(0, len(truncated), 65536)]
+
+    callback = AsyncMock()
+    session = _make_session(tmp_path, data_chunks=chunks)
+    session.on_file_received = callback
+    session.send = AsyncMock()
+
+    await session.cmd_STOR("truncated.gcode.3mf")
+
+    # Corrupt file dropped, not left in the upload dir.
+    assert not (session.upload_dir / "truncated.gcode.3mf").exists()
+    sent_codes = [args[0][0] for args in session.send.call_args_list]
+    assert 426 in sent_codes
+    assert 226 not in sent_codes
+    # The archive/queue/forward callback must never run for a corrupt upload.
+    callback.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_stor_skips_zip_validation_for_non_3mf(tmp_path):
+    """The ZIP-integrity gate is scoped to .3mf uploads. A non-3MF file (e.g.
+    a plain .gcode some slicers still send) is not a ZIP and must keep the
+    prior pass-through behaviour — 226, not a false-positive 426."""
+    payload = b"G1 X0 Y0\n" * 5000  # plain text, deliberately not a ZIP
+    chunks = [payload[i : i + 65536] for i in range(0, len(payload), 65536)]
+    session = _make_session(tmp_path, data_chunks=chunks)
+    session.send = AsyncMock()
+
+    await session.cmd_STOR("plain.gcode")
+
+    saved = session.upload_dir / "plain.gcode"
+    assert saved.exists()
+    assert saved.read_bytes() == payload
+    sent_codes = [args[0][0] for args in session.send.call_args_list]
+    assert 226 in sent_codes
+    assert 426 not in sent_codes
 
 
 @pytest.mark.asyncio

+ 93 - 0
backend/tests/unit/test_vp_mqtt_server.py

@@ -621,3 +621,96 @@ class TestPendingRequestRouting:
         return None so the response broadcasts."""
         assert server._lookup_pending_request_client(b"not valid json") is None
         assert server._lookup_pending_request_client(b'"a string, not a dict"') is None
+
+
+class TestSendPublishDrainTimeoutEviction:
+    """#1872: a slicer client that stops draining (e.g. macOS sleeps the
+    machine mid-session) used to keep its writer in `self._clients` for
+    hours — drain timed out at DEBUG, returned silently, and the push loop
+    kept spending 5 s per iteration on the zombie until SO_KEEPALIVE
+    detected the dead peer.
+
+    `_publish_to_report` now closes the writer and raises
+    `BrokenPipeError` on drain timeout so the push loop's existing
+    `except OSError` branch evicts the client on the same tick.
+    """
+
+    @pytest.mark.asyncio
+    async def test_drain_timeout_raises_broken_pipe_and_closes_writer(self):
+        """The core contract — drain > 5 s must raise BrokenPipeError AND
+        close the writer, not swallow the timeout."""
+        server = _make_server()
+
+        # Writer whose drain never completes — asyncio.wait_for should
+        # hit its 5 s ceiling. Use an unresolved future so the coroutine
+        # returned by drain() blocks indefinitely.
+        writer = MagicMock()
+        writer.write = MagicMock(return_value=None)
+        never = asyncio.Future()  # deliberately never resolved
+        writer.drain = MagicMock(return_value=never)
+        writer.close = MagicMock()
+
+        # Patch wait_for to raise TimeoutError immediately instead of
+        # actually waiting 5 s — we're testing our handler, not asyncio.
+        with pytest.MonkeyPatch.context() as mp:
+
+            async def fake_wait_for(coro, timeout):
+                # Cancel the pending drain future so it doesn't leak.
+                if not never.done():
+                    never.cancel()
+                raise TimeoutError()
+
+            mp.setattr(asyncio, "wait_for", fake_wait_for)
+
+            with pytest.raises(BrokenPipeError, match="drain timeout"):
+                await server._publish_to_report(writer, {"x": 1}, serial="01P00A391800001")
+
+        writer.close.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_drain_timeout_still_closes_writer_when_close_fails(self):
+        """Best-effort close: if the writer is already broken and
+        `.close()` raises, `_send_publish` must still raise
+        BrokenPipeError so the push loop evicts the client. Silent
+        swallowing here would put us right back to the #1872 zombie."""
+        server = _make_server()
+
+        writer = MagicMock()
+        writer.write = MagicMock(return_value=None)
+        never = asyncio.Future()
+        writer.drain = MagicMock(return_value=never)
+        writer.close = MagicMock(side_effect=OSError("already broken"))
+
+        with pytest.MonkeyPatch.context() as mp:
+
+            async def fake_wait_for(coro, timeout):
+                if not never.done():
+                    never.cancel()
+                raise TimeoutError()
+
+            mp.setattr(asyncio, "wait_for", fake_wait_for)
+
+            with pytest.raises(BrokenPipeError):
+                await server._publish_to_report(writer, {"x": 1}, serial="01P00A391800001")
+
+
+class TestHandleClientTCPKeepaliveTuning:
+    """#1872: without tightening Linux TCP keepalive knobs, dead-peer
+    detection defaults to ~2 h. A macOS sleep leaves the pre-sleep socket
+    in `self._clients` until then. Tighten to detect within ~2 min.
+    """
+
+    def test_handle_client_source_names_the_tuning_constants(self):
+        """The tuning code needs the three TCP_KEEP* constants to be
+        referenced by name so a socket-module regression / a stripped-down
+        platform can be diagnosed from a support bundle. Inspecting the
+        source keeps this pinned without spinning up a real socket in
+        the unit test (that's covered separately by integration)."""
+        source = inspect.getsource(SimpleMQTTServer._handle_client)
+        for name in ("TCP_KEEPIDLE", "TCP_KEEPINTVL", "TCP_KEEPCNT"):
+            assert name in source, (
+                f"_handle_client must reference {name} so the Linux "
+                "keepalive schedule is tightened (#1872). Without this "
+                "a macOS sleep leaves the pre-sleep socket in _clients "
+                "for ~2 h until the default SO_KEEPALIVE probes fire."
+            )

+ 138 - 0
backend/tests/unit/test_ws_broadcast_to_user.py

@@ -0,0 +1,138 @@
+"""WebSocket dispatch-toast routing (#1625 follow-up).
+
+Two contracts pinned here:
+
+1. ``broadcast_to_user(uid, msg)`` only delivers to connections whose
+   ``websocket.state.bambuddy_principal_user_id`` matches the target,
+   and fans out to all when the target is None (auth-disabled path).
+2. The six ``send_queue_item_*`` helpers serialize the right payload
+   shape — the frontend toast reads exact field names + types.
+"""
+
+from __future__ import annotations
+
+import json
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+
+from backend.app.core.websocket import ConnectionManager
+
+
+def _mock_conn(user_id: int | None):
+    """Build a stand-in WebSocket-shaped object with the principal stamp."""
+    conn = SimpleNamespace()
+    conn.state = SimpleNamespace()
+    conn.state.bambuddy_principal_user_id = user_id
+    conn.send_text = AsyncMock()
+    return conn
+
+
+@pytest.mark.asyncio
+async def test_broadcast_to_user_filters_by_principal_user_id():
+    """A targeted broadcast only reaches the principal's connections."""
+    mgr = ConnectionManager()
+    alice = _mock_conn(7)
+    bob = _mock_conn(8)
+    anon = _mock_conn(None)  # auth-disabled session — skipped on targeted path
+    mgr.active_connections = [alice, bob, anon]
+
+    await mgr.broadcast_to_user(7, {"type": "queue_item_uploading", "queue_item_id": 1})
+
+    alice.send_text.assert_awaited_once()
+    bob.send_text.assert_not_awaited()
+    anon.send_text.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_broadcast_to_user_none_fans_out_to_all():
+    """Auth-disabled installs route ``user_id=None`` to every connection
+    via the regular broadcast — matches the legacy single-user toast
+    behaviour where there was no per-user routing at all."""
+    mgr = ConnectionManager()
+    a = _mock_conn(None)
+    b = _mock_conn(None)
+    mgr.active_connections = [a, b]
+
+    await mgr.broadcast_to_user(None, {"type": "queue_item_uploading", "queue_item_id": 1})
+
+    a.send_text.assert_awaited_once()
+    b.send_text.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_send_queue_item_uploading_carries_total_bytes():
+    mgr = ConnectionManager()
+    target = _mock_conn(42)
+    mgr.active_connections = [target]
+
+    await mgr.send_queue_item_uploading(
+        user_id=42,
+        queue_item_id=11,
+        printer_id=1,
+        printer_name="H2D-1",
+        file_name="cube.3mf",
+        total_bytes=12345,
+    )
+
+    payload = json.loads(target.send_text.await_args.args[0])
+    assert payload == {
+        "type": "queue_item_uploading",
+        "queue_item_id": 11,
+        "printer_id": 1,
+        "printer_name": "H2D-1",
+        "file_name": "cube.3mf",
+        "total_bytes": 12345,
+    }
+
+
+@pytest.mark.asyncio
+async def test_send_queue_item_upload_progress_computes_pct_server_side():
+    """The toast renders the pct field verbatim — the backend has to
+    compute it. Avoid divide-by-zero on a zero-byte upload."""
+    mgr = ConnectionManager()
+    target = _mock_conn(5)
+    mgr.active_connections = [target]
+
+    await mgr.send_queue_item_upload_progress(
+        user_id=5,
+        queue_item_id=3,
+        bytes_transferred=50,
+        total_bytes=200,
+    )
+    payload = json.loads(target.send_text.await_args.args[0])
+    assert payload["pct"] == 25
+
+    target.send_text.reset_mock()
+    await mgr.send_queue_item_upload_progress(
+        user_id=5,
+        queue_item_id=3,
+        bytes_transferred=0,
+        total_bytes=0,
+    )
+    payload = json.loads(target.send_text.await_args.args[0])
+    assert payload["pct"] == 0
+
+
+@pytest.mark.asyncio
+async def test_send_queue_item_failed_carries_reason_key():
+    """The frontend looks up ``dispatchToast.failed.{reason}`` — so the
+    backend must hand the toast a reason string the i18n can match."""
+    mgr = ConnectionManager()
+    target = _mock_conn(99)
+    mgr.active_connections = [target]
+
+    await mgr.send_queue_item_failed(
+        user_id=99,
+        queue_item_id=8,
+        printer_id=2,
+        reason="upload_failed",
+    )
+    payload = json.loads(target.send_text.await_args.args[0])
+    assert payload == {
+        "type": "queue_item_failed",
+        "queue_item_id": 8,
+        "printer_id": 2,
+        "reason": "upload_failed",
+    }

+ 179 - 0
backend/tests/unit/utils/test_tray_split.py

@@ -0,0 +1,179 @@
+"""Pure-logic tests for the mid-print tray-split math (#1793).
+
+The helper lives in ``backend/app/utils/tray_split.py`` and is exercised
+by both inventory backends (``usage_tracker`` and ``spoolman_tracking``).
+These tests pin the algorithm so a change in one caller can't silently
+break the other — cross-inventory parity is a HARD RULE for this project.
+"""
+
+from __future__ import annotations
+
+from backend.app.utils.tray_split import compute_tray_split_grams
+
+
+class TestComputeTraySplitGrams:
+    """Segment-attribution algorithm — gcode preferred, linear fallback, equal split."""
+
+    def test_empty_tray_changes_returns_empty(self):
+        assert (
+            compute_tray_split_grams(
+                tray_changes=[],
+                total_weight=100.0,
+                slot_id=1,
+                layer_usage=None,
+                density=1.24,
+                diameter=1.75,
+                total_layers=200,
+                last_layer_num=200,
+            )
+            == []
+        )
+
+    def test_single_segment_charges_everything_to_that_tray(self):
+        segments = compute_tray_split_grams(
+            tray_changes=[(0, 0)],
+            total_weight=72.56,
+            slot_id=1,
+            layer_usage=None,
+            density=1.24,
+            diameter=1.75,
+            total_layers=100,
+            last_layer_num=100,
+        )
+        assert segments == [(0, 0, 72.56)]
+
+    def test_two_segments_linear_split_by_layer_ratio(self):
+        # Runout at layer 37 of 100 total; no gcode available → linear.
+        # Segment 0 (tray 0, layers 0-37) = 100 * 37/100 = 37g
+        # Segment 1 (tray 1, layers 37-end) = 100 - 37 = 63g (remainder)
+        segments = compute_tray_split_grams(
+            tray_changes=[(0, 0), (1, 37)],
+            total_weight=100.0,
+            slot_id=1,
+            layer_usage=None,
+            density=1.24,
+            diameter=1.75,
+            total_layers=100,
+            last_layer_num=100,
+        )
+        assert segments == [(0, 0, 37.0), (1, 1, 63.0)]
+
+    def test_two_segments_gcode_preferred_over_linear(self):
+        # layer_usage stores mm of filament extruded per (layer, filament_id).
+        # Values are cumulative-per-key inside get_cumulative_usage_at_layer.
+        # 20 layers, filament_id=0 (slot_id=1 → filament_id 0):
+        #   layer 10 → 100mm cumulative
+        #   layer 20 → 300mm cumulative
+        # tray change at layer 10 → seg 0 spans layers 0-10 (mm 0 → 100),
+        #                            seg 1 spans layers 10-end.
+        # mm_to_grams(100, 1.75, 1.24) ≈ 0.298g; last segment absorbs the rest.
+        layer_usage = {
+            5: {0: 50.0},
+            10: {0: 100.0},
+            15: {0: 200.0},
+            20: {0: 300.0},
+        }
+        segments = compute_tray_split_grams(
+            tray_changes=[(0, 0), (1, 10)],
+            total_weight=1.0,  # sentinel — we assert the seg1 remainder
+            slot_id=1,
+            layer_usage=layer_usage,
+            density=1.24,
+            diameter=1.75,
+            total_layers=20,
+            last_layer_num=20,
+        )
+        # Seg 0 charged from gcode delta (mm 0 → 100).
+        # Seg 1 gets total_weight - seg0 as remainder.
+        assert segments[0][0] == 0
+        assert segments[0][1] == 0  # tray 0
+        assert segments[0][2] > 0  # non-zero gcode contribution
+        assert segments[1][0] == 1
+        assert segments[1][1] == 1  # tray 1
+        # Sum equals the input total by construction (last segment absorbs).
+        assert round(segments[0][2] + segments[1][2], 6) == 1.0
+
+    def test_three_segments_last_absorbs_rounding_drift(self):
+        # 100g over three segments at layers 30 and 60 of 90; linear fallback.
+        # Seg 0: 100 * 30/90 = 33.3333...
+        # Seg 1: 100 * 30/90 = 33.3333...
+        # Seg 2: remainder = 100 - 66.6666... = 33.3333... — exact by construction
+        segments = compute_tray_split_grams(
+            tray_changes=[(0, 0), (1, 30), (2, 60)],
+            total_weight=100.0,
+            slot_id=1,
+            layer_usage=None,
+            density=1.24,
+            diameter=1.75,
+            total_layers=90,
+            last_layer_num=90,
+        )
+        assert len(segments) == 3
+        assert round(sum(g for _, _, g in segments), 6) == 100.0
+        assert segments[0][1] == 0
+        assert segments[1][1] == 1
+        assert segments[2][1] == 2
+
+    def test_no_layer_info_at_all_falls_to_equal_split(self):
+        # Denominator 0 → last-resort equal-split; last segment absorbs remainder.
+        segments = compute_tray_split_grams(
+            tray_changes=[(0, 0), (1, 50)],
+            total_weight=90.0,
+            slot_id=1,
+            layer_usage=None,
+            density=1.24,
+            diameter=1.75,
+            total_layers=0,
+            last_layer_num=0,
+        )
+        # 90g / 2 = 45g each; sum still 90 by remainder mechanic.
+        assert segments == [(0, 0, 45.0), (1, 1, 45.0)]
+
+    def test_last_layer_num_used_when_total_layers_zero(self):
+        # P1S firmware-reset scenario: total_layers=0 at completion, but the
+        # captured last_layer_num survives. Should give the same linear split
+        # as if total_layers had held its value (#1771 cascade).
+        segments_captured = compute_tray_split_grams(
+            tray_changes=[(0, 0), (1, 30)],
+            total_weight=100.0,
+            slot_id=1,
+            layer_usage=None,
+            density=1.24,
+            diameter=1.75,
+            total_layers=0,
+            last_layer_num=100,
+        )
+        segments_normal = compute_tray_split_grams(
+            tray_changes=[(0, 0), (1, 30)],
+            total_weight=100.0,
+            slot_id=1,
+            layer_usage=None,
+            density=1.24,
+            diameter=1.75,
+            total_layers=100,
+            last_layer_num=100,
+        )
+        assert segments_captured == segments_normal
+
+    def test_slot_id_maps_to_zero_based_filament_id_in_gcode(self):
+        # slot_id 2 → filament_id 1 in layer_usage. If we mistakenly used
+        # slot_id as-is, we'd read filament_id 2 which is absent → 0mm delta
+        # → seg 0 gets 0, seg 1 (remainder) gets the whole total. Guard
+        # against that regression.
+        layer_usage = {
+            5: {0: 0.0, 1: 40.0},
+            10: {0: 0.0, 1: 80.0},
+            20: {0: 0.0, 1: 160.0},
+        }
+        segments = compute_tray_split_grams(
+            tray_changes=[(0, 0), (1, 10)],
+            total_weight=1.0,
+            slot_id=2,
+            layer_usage=layer_usage,
+            density=1.24,
+            diameter=1.75,
+            total_layers=20,
+            last_layer_num=20,
+        )
+        # Seg 0 gcode delta on filament_id=1 is non-zero → not 0g.
+        assert segments[0][2] > 0

+ 3 - 1
deploy/bambuddy.service

@@ -32,7 +32,9 @@ EnvironmentFile=-INSTALL_PATH/.env
 Environment="PATH=INSTALL_PATH/venv/bin:/usr/local/bin:/usr/bin:/bin"
 
 # Server configuration
-ExecStart=INSTALL_PATH/venv/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port ${PORT:-8000}
+# --loop asyncio is required: uvloop's SSL layer can silently truncate VP FTP
+# uploads on a ragged client close over slow storage (#1896). Do not remove.
+ExecStart=INSTALL_PATH/venv/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port ${PORT:-8000} --loop asyncio
 
 # Restart policy
 Restart=on-failure

+ 14 - 0
frontend/scripts/check-i18n-parity.mjs

@@ -119,6 +119,7 @@ function isAlwaysAllowedIdentical(value) {
   if (/^v?\d+(\.\d+)+/.test(value)) return true;        // version-like
   if (/^#[0-9a-fA-F]{3,8}$/.test(value)) return true;   // hex color
   if (/^\{\{[^}]+\}\}$/.test(value)) return true;       // pure placeholder
+  if (/^\{\{[^}]+\}\}([\s/\-–·,]+\{\{[^}]+\}\})+$/.test(value)) return true;  // placeholders joined by punctuation only ({{a}} / {{b}})
   if (/^[0-9a-fA-F]{6}$/.test(value)) return true;      // bare hex color
   if (/^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i.test(value)) return true;  // email
   if (/^https?:\/\//.test(value)) return true;          // URL
@@ -147,6 +148,8 @@ const DE_COGNATES = [
 
   'Pause', 'Power', 'System', 'Problem', 'Designer', 'Extruder', 'Firmware',
   'Material', 'Original', 'Position', 'Webhook', 'Workflow', 'Slicer',
+  'Pipeline', 'Pipelines', 'Filament {{n}}',  // #1425 — Slicer Pipelines (DE)
+  'parallel',  // #1425 PR C polish — "parallel" is the same word in German
   'Region', 'Normal', 'Orange', 'Branch', 'Budget', 'Commit', 'Global',
   'Version', 'Slot', 'Live', 'Rate', 'Host', 'Trend', 'Min', 'Admin', 'Cloud',
   'Filament', 'Filaments', 'Software', 'Hardware', 'Avatar', 'Pin', 'Modal',
@@ -179,6 +182,9 @@ const FR_COGNATES = [
   'Filaments', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code',
   'Token', 'Server', 'Port', 'Plate', 'Layer', 'Active', 'Total', 'Avatar',
   'Job', 'Modal', 'Pin', 'Pro', 'Mini', 'Studio', 'Excellent', 'Description',
+  'Pipeline', 'Pipelines', 'Filament {{n}}',  // #1425 — Slicer Pipelines (FR)
+  'Copies', '{{n}} copies', 'max {{n}}',  // #1425 PR C — French uses these forms verbatim
+  'round robin',  // borrowed English term used as-is in French tech contexts
   'Action', 'Actions', 'Date', 'Type', 'Cache', 'Service', 'Configuration',
   'Archives', 'Maintenance', 'Notifications', 'Notification', 'Position',
   'Pause', 'Solution', 'Source', 'Version', 'Format', 'Documentation',
@@ -217,6 +223,9 @@ const IT_COGNATES = [
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
   'Email',  // common loanword in Italian, used verbatim in UI labels
+  'Pipeline', 'slicing',  // #1425 — Slicer Pipelines (cognate in IT)
+  'max {{n}}',  // #1425 PR C — same form in Italian (max + number)
+  'round robin',  // borrowed English term used as-is in Italian tech contexts
   'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
   'Filaments', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code',
   'Token', 'Server', 'Port', 'Plate', 'Layer', 'Modal', 'Pin', 'Pro', 'Mini',
@@ -260,6 +269,8 @@ const JA_COGNATES = [
 const PT_BR_COGNATES = [
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
+  'Pipeline', 'Pipelines',  // #1425 — Slicer Pipelines (PT-BR)
+  'round robin',  // borrowed English term used as-is in Portuguese tech contexts
   'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
   'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code', 'Token', 'Server',
   'Port', 'Plate', 'Layer', 'Modal', 'Pin', 'Pro', 'Mini', 'Studio', 'Cache',
@@ -332,6 +343,8 @@ const KO_COGNATES = [
 const ES_COGNATES = [
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
+  'Pipeline', 'Pipelines',  // #1425 — Slicer Pipelines (ES)
+  'round robin',  // borrowed English term used as-is in Spanish tech contexts
   'Error', 'Firmware', 'General', 'Control', 'Total', 'total', 'Material',
   'Material:', 'Color', 'Hex', 'Local', 'Global', 'China', 'Editable',
   'Normal', 'Metal', 'Multicolor', 'Proxy', 'Host', 'Factor', 'Original',
@@ -353,6 +366,7 @@ const TR_COGNATES = [
   'Filament', 'Firmware', 'Disk', 'Hex', 'Test', 'Port', 'Model', 'Metal',
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
+  'Pipeline', 'Filament {{n}}',  // #1425 — Slicer Pipelines (TR)
   'Min', 'Normal', 'Platform', 'Net', 'Trend', 'Commit', 'Global', 'Proxy',
   'N/A', 'email',
   'STARTTLS (Port 587)', 'SSL/TLS (Port 465)',

+ 4 - 0
frontend/src/App.tsx

@@ -197,6 +197,10 @@ function App() {
                   <Route index element={<PrintersPage />} />
                   <Route path="archives" element={<ArchivesPage />} />
                   <Route path="queue" element={<QueuePage />} />
+                  {/* Slicer Pipelines (#1425) — Pipelines tab lives on the
+                      Print Queue page (Queue + History + Timeline +
+                      Pipelines). Old standalone URL redirects. */}
+                  <Route path="pipelines/runs" element={<Navigate to="/queue?tab=pipelines" replace />} />
                   <Route path="stats" element={<StatsPage />} />
                   <Route path="profiles" element={<ProfilesPage />} />
                   <Route path="maintenance" element={<MaintenancePage />} />

+ 28 - 0
frontend/src/__tests__/components/LabelTemplatePickerModal.test.tsx

@@ -207,6 +207,7 @@ describe('LabelTemplatePickerModal', () => {
       expect(api.printSpoolLabels).toHaveBeenCalledWith({
         spool_ids: [1, 3],
         template: 'box_62x29',
+        monochrome: false,
       });
     });
     await waitFor(() => expect(onClose).toHaveBeenCalled());
@@ -232,6 +233,7 @@ describe('LabelTemplatePickerModal', () => {
       expect(api.printSpoolmanSpoolLabels).toHaveBeenCalledWith({
         spool_ids: [1],
         template: 'ams_holder_75x55',
+        monochrome: false,
       });
     });
     expect(api.printSpoolLabels).not.toHaveBeenCalled();
@@ -361,6 +363,7 @@ describe('LabelTemplatePickerModal', () => {
       expect(api.printSpoolLabels).toHaveBeenCalledWith({
         spool_ids: [1, 4, 2, 3],
         template: 'box_62x29',
+        monochrome: false,
       });
     });
   });
@@ -385,6 +388,31 @@ describe('LabelTemplatePickerModal', () => {
       expect(api.printSpoolLabels).toHaveBeenCalledWith({
         spool_ids: [1, 2, 3, 4],
         template: 'box_40x30',
+        monochrome: false,
+      });
+    });
+  });
+
+  it('sends monochrome:true when the black & white checkbox is ticked (#1870)', async () => {
+    vi.mocked(api.printSpoolLabels).mockResolvedValue(PDF_BLOB);
+    render(
+      <LabelTemplatePickerModal
+        isOpen={true}
+        onClose={vi.fn()}
+        availableSpools={SPOOLS}
+        initialSelectedIds={[1]}
+        spoolmanMode={false}
+      />,
+    );
+
+    fireEvent.click(screen.getByText(/black & white printer/i));
+    fireEvent.click(screen.getByText(/Box label \(40 × 30 mm\)/i));
+
+    await waitFor(() => {
+      expect(api.printSpoolLabels).toHaveBeenCalledWith({
+        spool_ids: [1],
+        template: 'box_40x30',
+        monochrome: true,
       });
     });
   });

+ 46 - 0
frontend/src/__tests__/components/PrintModal.test.tsx

@@ -1197,6 +1197,52 @@ describe('PrintModal', () => {
       expect(queueCalls[0].quantity).toBe(3);
     });
 
+    it('quantity 1 + snippets configured: checkbox toggles cleanly (#1852)', async () => {
+      // The previous reset effect in PrintModal/index.tsx silently flipped
+      // gcodeInjection back to false whenever effectiveQuantity <= 1 in create
+      // mode, so a single print with snippets configured couldn't tick the
+      // checkbox — every click visibly un-ticked itself within the next render.
+      // The scheduler reads item.gcode_injection per queue item regardless of
+      // batch size, so quantity 1 must be allowed too.
+      const queueCalls: Record<string, unknown>[] = [];
+      server.use(
+        withSnippets(),
+        http.post('/api/v1/queue/', async ({ request }) => {
+          queueCalls.push((await request.json()) as Record<string, unknown>);
+          return HttpResponse.json({ id: queueCalls.length, status: 'pending' });
+        }),
+      );
+
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="create"
+          archiveId={1}
+          archiveName="Benchy"
+          initialSelectedPrinterIds={[1]}
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      // Quantity stays at default (1) — this is the OP's exact scenario.
+      const checkbox = (await screen.findByLabelText(/inject auto-print/i)) as HTMLInputElement;
+      expect(checkbox.checked).toBe(false);
+
+      await user.click(checkbox);
+      // CRITICAL: after the click the checkbox must stay checked. Under the
+      // pre-fix reset effect, the parent state would flip back to false within
+      // a render and the displayed `checked` attribute would track it.
+      await waitFor(() => expect(checkbox.checked).toBe(true));
+
+      await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
+
+      await waitFor(() => expect(queueCalls.length).toBe(1));
+      // The injection flag actually reaches the API — pre-fix the parent reset
+      // would have stripped it before submit.
+      expect(queueCalls[0].gcode_injection).toBe(true);
+    });
+
     it('injection OFF queues all copies through the scheduler path', async () => {
       const queueCalls: Record<string, unknown>[] = [];
       server.use(

+ 210 - 0
frontend/src/__tests__/components/RunWithPipelineModal.test.tsx

@@ -0,0 +1,210 @@
+/**
+ * Tests for RunWithPipelineModal (#1425 PR B).
+ *
+ * Pin the two-step flow: pick → optional confirmation → run. Verify the
+ * fast path skips the modal step and the slow path renders the eligibility
+ * issues with the "Run anyway" button.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { RunWithPipelineModal } from '../../components/RunWithPipelineModal';
+import { SliceJobTrackerProvider } from '../../contexts/SliceJobTrackerContext';
+import { api, type Printer, type SlicerPipeline } from '../../api/client';
+
+vi.mock('../../api/client', () => ({
+  api: {
+    listSlicerPipelines: vi.fn(),
+    getPrinters: vi.fn(),
+    checkPipelineEligibility: vi.fn(),
+    runPipeline: vi.fn(),
+    // ThemeContext / AuthContext bootstrap touches these on mount.
+    getSettings: vi.fn().mockResolvedValue({}),
+    updateSettings: vi.fn().mockResolvedValue({}),
+  },
+}));
+
+const mockApi = api as unknown as {
+  listSlicerPipelines: ReturnType<typeof vi.fn>;
+  getPrinters: ReturnType<typeof vi.fn>;
+  checkPipelineEligibility: ReturnType<typeof vi.fn>;
+  runPipeline: ReturnType<typeof vi.fn>;
+};
+
+function makePipeline(overrides: Partial<SlicerPipeline> = {}): SlicerPipeline {
+  return {
+    id: 1,
+    name: 'Production Batch',
+    description: null,
+    printer_preset: { source: 'local', id: '1' },
+    process_preset: { source: 'local', id: '2' },
+    filament_presets: [{ source: 'local', id: '3' }],
+    bed_type: null,
+    target_kind: 'specific_printer',
+    target_printer_id: 42,
+    target_model_class: null,
+    fanout_strategy: 'max_parallel',
+    created_by: null,
+    created_at: '2026-06-27T00:00:00Z',
+    updated_at: '2026-06-27T00:00:00Z',
+    ...overrides,
+  };
+}
+
+function makePrinter(overrides: Partial<Printer> = {}): Printer {
+  return {
+    id: 42,
+    name: 'X1C #2',
+    serial_number: 'TEST',
+    ip_address: '192.0.2.1',
+    access_code: '****',
+    model: 'X1C',
+    location: null,
+    is_active: true,
+    nozzle_count: 1,
+    ...overrides,
+  } as unknown as Printer;
+}
+
+describe('RunWithPipelineModal', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mockApi.getPrinters.mockResolvedValue([makePrinter()]);
+  });
+
+  it('renders the empty state when no pipelines exist', async () => {
+    mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
+    render(
+      <SliceJobTrackerProvider>
+        <RunWithPipelineModal source={{ kind: 'libraryFile', id: 99, filename: 'cube.3mf' }} onClose={vi.fn()} />
+      </SliceJobTrackerProvider>,
+    );
+    await waitFor(() => {
+      expect(screen.getByText(/No pipelines saved yet/i)).toBeInTheDocument();
+    });
+  });
+
+  it('disables pipelines with no target printer', async () => {
+    mockApi.listSlicerPipelines.mockResolvedValue({
+      pipelines: [makePipeline({ target_printer_id: null })],
+    });
+    render(
+      <SliceJobTrackerProvider>
+        <RunWithPipelineModal source={{ kind: 'libraryFile', id: 99, filename: 'cube.3mf' }} onClose={vi.fn()} />
+      </SliceJobTrackerProvider>,
+    );
+    await waitFor(() => {
+      expect(screen.getByText(/No target printer set/i)).toBeInTheDocument();
+    });
+    const button = screen.getByRole('button', { name: /Production Batch/i });
+    expect((button as HTMLButtonElement).disabled).toBe(true);
+  });
+
+  it('fires the run immediately when eligibility is ok (fast path)', async () => {
+    mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [makePipeline()] });
+    mockApi.checkPipelineEligibility.mockResolvedValue({
+      ok: true,
+      target_printer_id: 42,
+      target_printer_name: 'X1C #2',
+      issues: [],
+    });
+    mockApi.runPipeline.mockResolvedValue({});
+    const onClose = vi.fn();
+    render(
+      <SliceJobTrackerProvider>
+        <RunWithPipelineModal source={{ kind: 'libraryFile', id: 99, filename: 'cube.3mf' }} onClose={onClose} />
+      </SliceJobTrackerProvider>,
+    );
+    await waitFor(() => expect(screen.getByText('Production Batch')).toBeInTheDocument());
+
+    const user = userEvent.setup();
+    await user.click(screen.getByRole('button', { name: /Production Batch/i }));
+
+    await waitFor(() => {
+      expect(mockApi.checkPipelineEligibility).toHaveBeenCalledWith(1, {
+        kind: 'libraryFile',
+        id: 99,
+      });
+      expect(mockApi.runPipeline).toHaveBeenCalledWith(
+        1,
+        { kind: 'libraryFile', id: 99 },
+        false,
+        1,
+      );
+      expect(onClose).toHaveBeenCalled();
+    });
+  });
+
+  it('threads source kind="archive" through eligibility + run', async () => {
+    mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [makePipeline()] });
+    mockApi.checkPipelineEligibility.mockResolvedValue({
+      ok: true,
+      target_printer_id: 42,
+      target_printer_name: 'X1C #2',
+      issues: [],
+    });
+    mockApi.runPipeline.mockResolvedValue({ slice_job_id: 9001 });
+    render(
+      <SliceJobTrackerProvider>
+        <RunWithPipelineModal
+          source={{ kind: 'archive', id: 7, filename: 'archive.3mf' }}
+          onClose={vi.fn()}
+        />
+      </SliceJobTrackerProvider>,
+    );
+    await waitFor(() => expect(screen.getByText('Production Batch')).toBeInTheDocument());
+    const user = userEvent.setup();
+    await user.click(screen.getByRole('button', { name: /Production Batch/i }));
+    await waitFor(() => {
+      expect(mockApi.checkPipelineEligibility).toHaveBeenCalledWith(1, {
+        kind: 'archive',
+        id: 7,
+      });
+      expect(mockApi.runPipeline).toHaveBeenCalledWith(
+        1,
+        { kind: 'archive', id: 7 },
+        false,
+        1,
+      );
+    });
+  });
+
+  it('shows the eligibility report + Run-anyway button when issues exist', async () => {
+    mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [makePipeline()] });
+    mockApi.checkPipelineEligibility.mockResolvedValue({
+      ok: false,
+      target_printer_id: 42,
+      target_printer_name: 'X1C #2',
+      issues: [
+        {
+          kind: 'filament_type_mismatch',
+          slot_index: 0,
+          expected: 'PLA',
+          actual: 'PETG',
+        },
+      ],
+    });
+    mockApi.runPipeline.mockResolvedValue({});
+    render(
+      <SliceJobTrackerProvider>
+        <RunWithPipelineModal source={{ kind: 'libraryFile', id: 99, filename: 'cube.3mf' }} onClose={vi.fn()} />
+      </SliceJobTrackerProvider>,
+    );
+    await waitFor(() => expect(screen.getByText('Production Batch')).toBeInTheDocument());
+
+    const user = userEvent.setup();
+    await user.click(screen.getByRole('button', { name: /Production Batch/i }));
+
+    // Confirmation view appears with the issue listed.
+    await waitFor(() => {
+      expect(screen.getByText(/expected PLA/i)).toBeInTheDocument();
+    });
+    // Clicking Run anyway fires the run with force=true and the new source-kind shape.
+    await user.click(screen.getByRole('button', { name: /Run anyway/i }));
+    await waitFor(() => {
+      expect(mockApi.runPipeline).toHaveBeenCalledWith(1, { kind: 'libraryFile', id: 99 }, true, 1);
+    });
+  });
+});

+ 265 - 9
frontend/src/__tests__/components/SliceModal.test.tsx

@@ -13,6 +13,8 @@ import { screen, waitFor, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
 import { SliceModal } from '../../components/SliceModal';
+import { pickFilamentForSlot } from '../../utils/slicePresetPicker';
+import { buildCompatibilityIndex } from '../../utils/slicerPrinterMatch';
 import { SliceJobTrackerProvider } from '../../contexts/SliceJobTrackerContext';
 import { api, type UnifiedPresetsResponse } from '../../api/client';
 
@@ -28,6 +30,9 @@ vi.mock('../../api/client', () => ({
     getArchiveFilamentRequirements: vi.fn(),
     getSettings: vi.fn().mockResolvedValue({}),
     updateSettings: vi.fn().mockResolvedValue({}),
+    // Slicer Pipelines (#1425)
+    listSlicerPipelines: vi.fn(),
+    createSlicerPipeline: vi.fn(),
   },
 }));
 
@@ -40,6 +45,8 @@ const mockApi = api as unknown as {
   getArchivePlates: ReturnType<typeof vi.fn>;
   getLibraryFileFilamentRequirements: ReturnType<typeof vi.fn>;
   getArchiveFilamentRequirements: ReturnType<typeof vi.fn>;
+  listSlicerPipelines: ReturnType<typeof vi.fn>;
+  createSlicerPipeline: ReturnType<typeof vi.fn>;
 };
 
 function makeUnified(overrides: Partial<UnifiedPresetsResponse> = {}): UnifiedPresetsResponse {
@@ -80,6 +87,16 @@ function renderWithTracker(props: Parameters<typeof SliceModal>[0]) {
   );
 }
 
+// SliceModal renders one extra combobox for the Slicer Pipelines (#1425)
+// "Apply pipeline" dropdown above the preset slots. Tests written before
+// pipelines landed assume selects[0] = printer; this helper drops the
+// pipeline combobox so those indices stay stable.
+function presetSelects(): HTMLSelectElement[] {
+  return (screen.getAllByRole('combobox') as HTMLSelectElement[]).filter(
+    (el) => el.getAttribute('aria-label') !== 'Apply pipeline',
+  );
+}
+
 describe('SliceModal', () => {
   beforeEach(() => {
     vi.clearAllMocks();
@@ -121,6 +138,8 @@ describe('SliceModal', () => {
       plate_id: 1,
       filaments: [],
     });
+    // Default: no saved pipelines. Tests opt in by overriding this.
+    mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
   });
 
   it('auto-selects the highest-priority tier per slot on first load', async () => {
@@ -137,7 +156,7 @@ describe('SliceModal', () => {
     // 4 selects: printer, process, bed-type (#1337), filament. bed-type sits
     // between process and filament — it overrides curr_bed_type on the
     // process preset so the related controls cluster — and defaults to "".
-    const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
+    const selects = presetSelects();
     expect(selects).toHaveLength(4);
     expect(selects[0].value).toBe('local:1');
     expect(selects[1].value).toBe('local:2');
@@ -158,7 +177,7 @@ describe('SliceModal', () => {
 
     await waitFor(() => expect(screen.getByText('Imported X1C 0.4')).toBeDefined());
 
-    const printerSelect = screen.getAllByRole('combobox')[0];
+    const printerSelect = presetSelects()[0];
     const groups = printerSelect.querySelectorAll('optgroup');
     expect(Array.from(groups).map((g) => g.label)).toEqual([
       'Imported',
@@ -190,7 +209,7 @@ describe('SliceModal', () => {
     });
 
     await waitFor(() => expect(screen.getByText('Imported X1C 0.4')).toBeDefined());
-    const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
+    const selects = presetSelects();
     expect(selects[0].value).toBe('local:1');
   });
 
@@ -204,7 +223,7 @@ describe('SliceModal', () => {
     });
 
     await waitFor(() => expect(screen.getByText('Bambu Lab X1 Carbon 0.4 nozzle')).toBeDefined());
-    const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
+    const selects = presetSelects();
     expect(selects[0].value).toBe('standard:Bambu Lab X1 Carbon 0.4 nozzle');
   });
 
@@ -260,7 +279,7 @@ describe('SliceModal', () => {
     // printer (0), process (1), bed-type (2), filament (3+). Find the
     // bed-type select by name rather than positional index so this stays
     // green if the layout adds another control around it.
-    const bedSelect = screen.getAllByRole('combobox').find((el) =>
+    const bedSelect = presetSelects().find((el) =>
       (el as HTMLSelectElement).options[0]?.textContent?.toLowerCase().includes('auto'),
     ) as HTMLSelectElement;
     expect(bedSelect).toBeDefined();
@@ -315,7 +334,7 @@ describe('SliceModal', () => {
     await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
 
     const user = userEvent.setup();
-    const selects = screen.getAllByRole('combobox');
+    const selects = presetSelects();
     await user.selectOptions(selects[0], 'standard:Bambu Lab X1 Carbon 0.4 nozzle');
     await user.click(screen.getByRole('button', { name: /^Slice$/ }));
 
@@ -731,7 +750,7 @@ describe('SliceModal', () => {
 
     await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
     // 1 printer + 1 process + 2 filament + 1 bed-type (#1337) = 5 dropdowns.
-    expect(screen.getAllByRole('combobox')).toHaveLength(5);
+    expect(presetSelects()).toHaveLength(5);
   });
 
   it('pre-picks each filament slot by matching colour metadata', async () => {
@@ -812,7 +831,7 @@ describe('SliceModal', () => {
     await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
 
     const user = userEvent.setup();
-    const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
+    const selects = presetSelects();
     // Order: 0 printer, 1 process, 2 bed-type, 3 filament-1, 4 filament-2
     // (#1337). Auto-picks land on printer/process/filaments; bed-type
     // defaults to "". Swap filament-1 (index 3) from the auto-picked black
@@ -937,7 +956,7 @@ describe('SliceModal', () => {
     // Both filament rows render — 1 printer + 1 process + 1 bed-type +
     // 2 filament (#1337) = 5. bed-type sits at index 2, filament slots
     // follow at 3 and 4.
-    const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
+    const selects = presetSelects();
     expect(selects).toHaveLength(5);
     // Slot 1 (used) is editable, slot 2 (not used) is disabled.
     expect(selects[3].disabled).toBe(false);
@@ -1018,4 +1037,241 @@ describe('SliceModal', () => {
     });
   });
 
+  // ------------------------------------------------------------------
+  // Slicer Pipelines (#1425) — Apply / Save integration in SliceModal
+  // ------------------------------------------------------------------
+
+  it('Apply pipeline dropdown is disabled and shows empty hint when no pipelines exist', async () => {
+    mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
+      onClose: vi.fn(),
+    });
+    await waitFor(() => {
+      const select = screen.getByLabelText(/Apply pipeline/i) as HTMLSelectElement;
+      expect(select.disabled).toBe(true);
+      expect(select.querySelector('option')?.textContent).toMatch(/No saved pipelines/i);
+    });
+  });
+
+  it('applies a saved pipeline to printer, process, and bed_type slots on selection', async () => {
+    mockApi.listSlicerPipelines.mockResolvedValue({
+      pipelines: [
+        {
+          id: 7,
+          name: 'Production Batch',
+          description: null,
+          printer_preset: { source: 'local', id: '1' },
+          process_preset: { source: 'local', id: '2' },
+          filament_presets: [{ source: 'local', id: '3' }],
+          bed_type: 'Textured PEI Plate',
+          target_kind: 'printer_class',
+          target_printer_id: null,
+          target_model_class: null,
+          fanout_strategy: 'max_parallel',
+          created_by: null,
+          created_at: '2026-06-27T00:00:00Z',
+          updated_at: '2026-06-27T00:00:00Z',
+        },
+      ],
+    });
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
+      onClose: vi.fn(),
+    });
+
+    // Wait for presets + pipelines listing to populate the modal.
+    await waitFor(() => {
+      const select = screen.getByLabelText(/Apply pipeline/i) as HTMLSelectElement;
+      expect(select.disabled).toBe(false);
+      expect(within(select).getByText('Production Batch')).toBeDefined();
+    });
+
+    const user = userEvent.setup();
+    await user.selectOptions(screen.getByLabelText(/Apply pipeline/i), '7');
+
+    // After applying, submitting the slice request should carry the
+    // pipeline's preset refs end-to-end.
+    mockApi.sliceLibraryFile.mockResolvedValue({
+      job_id: 42,
+      status: 'queued',
+      status_url: '/api/v1/slice-jobs/42',
+    });
+
+    await user.click(screen.getByRole('button', { name: /^Slice$/ }));
+
+    await waitFor(() => {
+      expect(mockApi.sliceLibraryFile).toHaveBeenCalled();
+      const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
+      expect(body.printer_preset).toEqual({ source: 'local', id: '1' });
+      expect(body.process_preset).toEqual({ source: 'local', id: '2' });
+      expect(body.filament_presets[0]).toEqual({ source: 'local', id: '3' });
+      expect(body.bed_type).toBe('Textured PEI Plate');
+    });
+  });
+
+  it('saves the current four-slot selection as a new pipeline when the user clicks Save as pipeline', async () => {
+    mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
+    mockApi.createSlicerPipeline.mockResolvedValue({
+      id: 99,
+      name: 'My Default',
+      description: null,
+      printer_preset: { source: 'local', id: '1' },
+      process_preset: { source: 'local', id: '2' },
+      filament_presets: [{ source: 'local', id: '3' }],
+      bed_type: null,
+      target_kind: 'printer_class',
+      target_printer_id: null,
+      target_model_class: null,
+      fanout_strategy: 'max_parallel',
+      created_by: null,
+      created_at: '2026-06-27T00:00:00Z',
+      updated_at: '2026-06-27T00:00:00Z',
+    });
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
+      onClose: vi.fn(),
+    });
+
+    // Wait for auto-pick to populate all four slots from the fullThreeTier
+    // listing — then Save as pipeline becomes enabled.
+    const user = userEvent.setup();
+    let saveBtn: HTMLButtonElement;
+    await waitFor(() => {
+      saveBtn = screen.getByRole('button', { name: /^Save as pipeline$/ }) as HTMLButtonElement;
+      expect(saveBtn.disabled).toBe(false);
+    });
+    await user.click(saveBtn!);
+
+    const nameInput = screen.getByLabelText(/New pipeline name/i);
+    await user.type(nameInput, 'My Default');
+    await user.click(screen.getByRole('button', { name: /^Save$/ }));
+
+    await waitFor(() => {
+      expect(mockApi.createSlicerPipeline).toHaveBeenCalledTimes(1);
+      const body = mockApi.createSlicerPipeline.mock.calls[0][0];
+      expect(body.name).toBe('My Default');
+      // The four slots come from the auto-picked unified-presets listing —
+      // local tier wins per SLICE_MODAL_TIER_ORDER.
+      expect(body.printer_preset.source).toBe('local');
+      expect(body.process_preset.source).toBe('local');
+      expect(body.filament_presets[0].source).toBe('local');
+    });
+  });
+
+});
+
+// Pure-function tests for the filament slot picker. Pinned as a separate
+// describe so the contract is visible without needing the modal mount.
+describe('pickFilamentForSlot — printer-compat contract (#1851)', () => {
+  // Index that recognises @BBL H2C / @BBL A1 tokens via the canonical
+  // PRINTER_MODEL_MAP. Real production data comes through
+  // ``api.getSlicerPrinterModels`` — the H2C / A1 fragments are the ones
+  // the production registry ships.
+  const index = buildCompatibilityIndex({
+    'Bambu Lab A1': 'A1',
+    'Bambu Lab H2C': 'H2C',
+  });
+
+  it('prefers a printer-compatible preset over a printer-mismatched one even with better colour match', () => {
+    // The OP scenario for #1851: a Bambu Lab A1 is selected; the unused-slot
+    // requirement carries the original H2C plate's PLA colour. With the
+    // legacy soft-penalty scoring an H2C-bound preset whose colour matches
+    // exactly could still rise above the A1-compatible PLA Basic whose
+    // colour doesn't, and then the unused-slot substitution propagated the
+    // H2C-bound preset across every unused slot — the CLI rejected with
+    // ``filament preset Generic PLA @BBL H2C (slot 1) is not compatible
+    // with printer Bambu Lab A1 0.4 nozzle``. The hard-skip contract makes
+    // sure a mismatched preset is never chosen while any compatible
+    // alternative exists, irrespective of metadata-score arithmetic.
+    const presets = makeUnified({
+      standard: {
+        printer: [],
+        process: [],
+        filament: [
+          {
+            id: 'Generic PLA @BBL H2C',
+            name: 'Generic PLA @BBL H2C',
+            source: 'standard',
+            filament_type: 'PLA',
+            filament_colour: '#FF0000',
+          },
+          {
+            id: 'Bambu PLA Basic @BBL A1',
+            name: 'Bambu PLA Basic @BBL A1',
+            source: 'standard',
+            filament_type: 'PLA',
+            filament_colour: '#FFFFFF',
+          },
+        ],
+      },
+    });
+    const pick = pickFilamentForSlot(
+      presets,
+      { type: 'PLA', color: '#FF0000' },
+      'Bambu Lab A1 0.4 nozzle',
+      index,
+    );
+    expect(pick).toEqual({ source: 'standard', id: 'Bambu PLA Basic @BBL A1' });
+  });
+
+  it('falls back to a mismatched preset when no compatible alternative exists', () => {
+    // Graceful degrade: when every available preset is printer-mismatched,
+    // returning ``null`` would block the slice entirely. The picker keeps
+    // its old behaviour of returning the best-scoring mismatch so the user
+    // sees a populated dropdown they can correct, not an empty one.
+    const presets = makeUnified({
+      standard: {
+        printer: [],
+        process: [],
+        filament: [
+          {
+            id: 'Generic PLA @BBL H2C',
+            name: 'Generic PLA @BBL H2C',
+            source: 'standard',
+            filament_type: 'PLA',
+            filament_colour: '#FF0000',
+          },
+        ],
+      },
+    });
+    const pick = pickFilamentForSlot(
+      presets,
+      { type: 'PLA', color: '#FF0000' },
+      'Bambu Lab A1 0.4 nozzle',
+      index,
+    );
+    expect(pick).toEqual({ source: 'standard', id: 'Generic PLA @BBL H2C' });
+  });
+
+  it('treats a no-printer-context call as no-mismatch (every preset eligible)', () => {
+    // ``printerName === null`` happens transiently on first render before the
+    // printer pre-pick effect has run. ``presetCompatibility`` returns
+    // ``unknown`` for every preset in that case, so the picker should just
+    // pick by metadata score with no compatibility filter active.
+    const presets = makeUnified({
+      standard: {
+        printer: [],
+        process: [],
+        filament: [
+          {
+            id: 'Generic PLA @BBL H2C',
+            name: 'Generic PLA @BBL H2C',
+            source: 'standard',
+            filament_type: 'PLA',
+            filament_colour: '#FF0000',
+          },
+        ],
+      },
+    });
+    const pick = pickFilamentForSlot(
+      presets,
+      { type: 'PLA', color: '#FF0000' },
+      null,
+      index,
+    );
+    expect(pick).toEqual({ source: 'standard', id: 'Generic PLA @BBL H2C' });
+  });
 });

+ 74 - 2
frontend/src/__tests__/contexts/AuthContext.test.tsx

@@ -2,7 +2,7 @@
  * Tests for the AuthContext permission helpers.
  */
 
-import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
 import { act, renderHook, waitFor } from '@testing-library/react';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { BrowserRouter } from 'react-router-dom';
@@ -11,7 +11,7 @@ import { server } from '../mocks/server';
 import { AuthProvider, useAuth } from '../../contexts/AuthContext';
 import { ThemeProvider } from '../../contexts/ThemeContext';
 import { ToastProvider } from '../../contexts/ToastContext';
-import { setAuthToken, type Permission } from '../../api/client';
+import { getAuthToken, setAuthToken, type Permission } from '../../api/client';
 
 function createWrapper() {
   const queryClient = new QueryClient({
@@ -325,4 +325,76 @@ describe('AuthContext', () => {
       }).not.toThrow();
     });
   });
+
+  describe('token validation on mount (#1889)', () => {
+    beforeEach(() => {
+      // Persisted "Remember Me" token — lives in localStorage.
+      setAuthToken('valid-token', 'persistent');
+      server.use(
+        http.get('/api/v1/auth/status', () =>
+          HttpResponse.json({ auth_enabled: true, requires_setup: false })
+        )
+      );
+    });
+
+    afterEach(() => {
+      setAuthToken(null);
+      localStorage.removeItem('auth_token');
+    });
+
+    it('keeps the persisted token when /auth/me fails transiently (does not force re-login)', async () => {
+      // Backend not ready yet / brief blip → 500 on every attempt.
+      server.use(http.get('/api/v1/auth/me', () => new HttpResponse(null, { status: 500 })));
+      vi.mocked(window.localStorage.removeItem).mockClear();
+
+      const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
+
+      await waitFor(() => expect(result.current.loading).toBe(false), { timeout: 4000 });
+
+      // No user this load, but the token MUST survive so a reload can recover —
+      // the pre-#1889 blanket catch deleted it, making the session unrecoverable.
+      expect(result.current.user).toBeNull();
+      expect(getAuthToken()).toBe('valid-token');
+      // Persisted copy must not be wiped (setAuthToken(null) removes it).
+      expect(window.localStorage.removeItem).not.toHaveBeenCalledWith('auth_token');
+    });
+
+    it('clears the token on a definitive 401 invalid-token response', async () => {
+      server.use(
+        http.get('/api/v1/auth/me', () =>
+          HttpResponse.json({ detail: 'Could not validate credentials' }, { status: 401 })
+        )
+      );
+      vi.mocked(window.localStorage.removeItem).mockClear();
+
+      const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
+
+      await waitFor(() => expect(result.current.loading).toBe(false), { timeout: 4000 });
+
+      expect(result.current.user).toBeNull();
+      expect(getAuthToken()).toBeNull();
+      // Definitive invalid-token → persisted copy is removed.
+      expect(window.localStorage.removeItem).toHaveBeenCalledWith('auth_token');
+    });
+
+    it('loads the user when the persisted token is valid', async () => {
+      server.use(
+        http.get('/api/v1/auth/me', () =>
+          HttpResponse.json({
+            id: 1,
+            username: 'alice',
+            is_active: true,
+            permissions: [],
+            groups: [],
+          })
+        )
+      );
+
+      const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
+
+      await waitFor(() => expect(result.current.user).not.toBeNull());
+      expect(result.current.user?.username).toBe('alice');
+      expect(getAuthToken()).toBe('valid-token');
+    });
+  });
 });

+ 123 - 0
frontend/src/__tests__/contexts/DispatchToastContext.test.tsx

@@ -0,0 +1,123 @@
+/**
+ * Dispatch-toast tests against the legacy-port-verbatim implementation
+ * inside ToastContext.tsx (the standalone DispatchToastStack component
+ * was removed; the toast now lives in ToastContext, matching the
+ * pre-#1625 location at 0b43ac0d:frontend/src/contexts/ToastContext.tsx).
+ */
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { act, screen, fireEvent } from '@testing-library/react';
+import { render } from '../utils';
+
+function emit(detail: Record<string, unknown>) {
+  act(() => {
+    window.dispatchEvent(new CustomEvent('bambuddy:dispatch-toast', { detail }));
+  });
+}
+
+describe('Dispatch toast (inside ToastContext)', () => {
+  beforeEach(() => {
+    vi.useFakeTimers();
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+  });
+
+  it('does NOT render on a stray progress event before any uploading event', () => {
+    render(<div />);
+    emit({ type: 'queue_item_upload_progress', queue_item_id: 99, bytes_transferred: 1, total_bytes: 100, pct: 1 });
+    emit({ type: 'queue_item_acked', queue_item_id: 99 });
+    expect(screen.queryByTestId('dispatch-toast-wrapper')).toBeNull();
+  });
+
+  it('materializes on uploading; status chip stays PROCESSING through upload', () => {
+    render(<div />);
+
+    emit({
+      type: 'queue_item_uploading',
+      queue_item_id: 42,
+      printer_id: 1,
+      printer_name: 'H2D-1',
+      file_name: 'cube.3mf',
+      total_bytes: 10 * 1024 * 1024,
+    });
+    expect(screen.getByTestId('dispatch-toast-wrapper')).toBeInTheDocument();
+    expect(screen.getByText('cube.3mf')).toBeInTheDocument();
+    expect(screen.getByText('H2D-1')).toBeInTheDocument();
+    expect(screen.getByTestId('dispatch-toast-status-42')).toHaveTextContent(/processing/i);
+
+    emit({ type: 'queue_item_upload_progress', queue_item_id: 42, bytes_transferred: 5 * 1024 * 1024, total_bytes: 10 * 1024 * 1024, pct: 50.0 });
+    expect(screen.getByText(/50\.0%/)).toBeInTheDocument();
+    expect(screen.getByTestId('dispatch-toast-status-42')).toHaveTextContent(/processing/i);
+  });
+
+  it('shows "Awaiting printer" once pct >= 99.9 while status STAYS processing', () => {
+    render(<div />);
+    emit({
+      type: 'queue_item_uploading',
+      queue_item_id: 42,
+      printer_id: 1,
+      printer_name: 'H2D-1',
+      file_name: 'cube.3mf',
+      total_bytes: 100,
+    });
+    emit({ type: 'queue_item_upload_progress', queue_item_id: 42, bytes_transferred: 100, total_bytes: 100, pct: 100 });
+
+    expect(screen.getByTestId('dispatch-toast-status-42')).toHaveTextContent(/processing/i);
+    expect(screen.getByText(/awaiting/i)).toBeInTheDocument();
+  });
+
+  it('acked flips chip to COMPLETED and wrapper auto-dismisses', () => {
+    render(<div />);
+    emit({
+      type: 'queue_item_uploading',
+      queue_item_id: 42,
+      printer_id: 1,
+      printer_name: 'H2D-1',
+      file_name: 'cube.3mf',
+      total_bytes: 100,
+    });
+    emit({ type: 'queue_item_acked', queue_item_id: 42 });
+    expect(screen.getByTestId('dispatch-toast-status-42')).toHaveTextContent(/completed/i);
+
+    act(() => { vi.advanceTimersByTime(3501); });
+    expect(screen.queryByTestId('dispatch-toast-wrapper')).toBeNull();
+  });
+
+  it('two concurrent jobs render as rows inside ONE wrapper', () => {
+    render(<div />);
+
+    emit({ type: 'queue_item_uploading', queue_item_id: 1, printer_id: 1, printer_name: 'H2D-1', file_name: 'a.3mf', total_bytes: 1000 });
+    emit({ type: 'queue_item_uploading', queue_item_id: 2, printer_id: 2, printer_name: 'H2D-2', file_name: 'b.3mf', total_bytes: 2000 });
+
+    expect(screen.getAllByTestId('dispatch-toast-wrapper')).toHaveLength(1);
+    expect(screen.getByTestId('dispatch-toast-job-1')).toBeInTheDocument();
+    expect(screen.getByTestId('dispatch-toast-job-2')).toBeInTheDocument();
+  });
+
+  it('failed shows red bar + reason; wrapper auto-dismisses', () => {
+    render(<div />);
+    emit({ type: 'queue_item_uploading', queue_item_id: 7, printer_id: 1, printer_name: 'H2D-1', file_name: 'job.3mf', total_bytes: 100 });
+    emit({ type: 'queue_item_failed', queue_item_id: 7, reason: 'upload_failed' });
+    expect(screen.getByTestId('dispatch-toast-status-7')).toHaveTextContent(/failed/i);
+
+    act(() => { vi.advanceTimersByTime(3501); });
+    expect(screen.queryByTestId('dispatch-toast-wrapper')).toBeNull();
+  });
+
+  it('collapse hides job rows but keeps the header visible', () => {
+    render(<div />);
+    emit({ type: 'queue_item_uploading', queue_item_id: 1, printer_id: 1, printer_name: 'H2D-1', file_name: 'a.3mf', total_bytes: 1000 });
+    expect(screen.getByTestId('dispatch-toast-job-1')).toBeInTheDocument();
+    fireEvent.click(screen.getByTestId('dispatch-toast-collapse'));
+    expect(screen.queryByTestId('dispatch-toast-job-1')).toBeNull();
+    expect(screen.getByTestId('dispatch-toast-wrapper')).toBeInTheDocument();
+  });
+
+  it('dismiss button hides the wrapper immediately', () => {
+    render(<div />);
+    emit({ type: 'queue_item_uploading', queue_item_id: 1, printer_id: 1, printer_name: 'H2D-1', file_name: 'a.3mf', total_bytes: 1000 });
+    fireEvent.click(screen.getByTestId('dispatch-toast-dismiss'));
+    expect(screen.queryByTestId('dispatch-toast-wrapper')).toBeNull();
+  });
+});

+ 114 - 0
frontend/src/__tests__/hooks/useSponsorPrompt.test.tsx

@@ -0,0 +1,114 @@
+/**
+ * Tests for the in-app sponsor-toast hook (#2477 regression guard).
+ *
+ * The bug: the 14-day cooldown is backend-owned, but the anchor is only
+ * persisted by POST /sponsor-prompt/dismiss — and the hook used to call
+ * dismiss ONLY from the "View supporters" CTA's onClick. A user who saw the
+ * toast but never clicked it persisted no state, so the toast re-fired on
+ * every fresh browser session. The fix records-on-show: the hook POSTs
+ * /dismiss the moment it renders the toast. These tests pin that contract so
+ * it can't silently regress back to click-only anchoring.
+ */
+
+import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
+import { renderHook, waitFor, cleanup } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+import { useSponsorPrompt } from '../../hooks/useSponsorPrompt';
+
+// The hook only needs `loading` from auth and `showPersistentToast` from the
+// toast context — mock both so the test doesn't drag in the real providers
+// (auth bootstrap, toast portal). The real sponsorPromptApi still runs and
+// hits MSW, which is exactly what we want to assert on.
+vi.mock('../../contexts/AuthContext', () => ({
+  useAuth: () => ({ loading: false }),
+}));
+
+const showPersistentToast = vi.fn();
+vi.mock('../../contexts/ToastContext', () => ({
+  useToast: () => ({ showPersistentToast }),
+}));
+
+beforeEach(() => {
+  showPersistentToast.mockClear();
+  sessionStorage.clear();
+});
+
+afterEach(() => {
+  cleanup();
+});
+
+describe('useSponsorPrompt', () => {
+  it('records the toast as shown (POSTs /dismiss) as soon as it renders, without a CTA click', async () => {
+    const dismissed: string[] = [];
+    server.use(
+      http.get('/api/v1/sponsor-prompt/check', () =>
+        HttpResponse.json({
+          show: true,
+          milestone: 'prints-500',
+          family: 'prints',
+          threshold: 500,
+          payload: { count: 512 },
+        }),
+      ),
+      http.post('/api/v1/sponsor-prompt/dismiss', async ({ request }) => {
+        const body = (await request.json()) as { milestone: string };
+        dismissed.push(body.milestone);
+        return new HttpResponse(null, { status: 204 });
+      }),
+    );
+
+    renderHook(() => useSponsorPrompt('EUR'));
+
+    // The toast is shown...
+    await waitFor(() => expect(showPersistentToast).toHaveBeenCalledTimes(1));
+    // ...and the cooldown is anchored on show, not on any CTA interaction.
+    await waitFor(() => expect(dismissed).toEqual(['prints-500']));
+
+    // The CTA is present for navigation but carries no onClick side effect —
+    // anchoring no longer depends on the user clicking through.
+    const options = showPersistentToast.mock.calls[0][3];
+    expect(options.action.href).toContain('from=app-toast-prints-500');
+    expect(options.action.onClick).toBeUndefined();
+  });
+
+  it('does not show a toast or anchor the cooldown when the check returns show:false', async () => {
+    let dismissCalls = 0;
+    server.use(
+      http.get('/api/v1/sponsor-prompt/check', () => HttpResponse.json({ show: false })),
+      http.post('/api/v1/sponsor-prompt/dismiss', () => {
+        dismissCalls += 1;
+        return new HttpResponse(null, { status: 204 });
+      }),
+    );
+
+    renderHook(() => useSponsorPrompt('EUR'));
+
+    // Give the async effect a chance to run before asserting the negatives.
+    await waitFor(() => expect(sessionStorage.getItem('sponsorPromptShown')).toBe('1'));
+    expect(showPersistentToast).not.toHaveBeenCalled();
+    expect(dismissCalls).toBe(0);
+  });
+
+  it('does not re-check within the same browser session (sessionStorage guard)', async () => {
+    let checkCalls = 0;
+    server.use(
+      http.get('/api/v1/sponsor-prompt/check', () => {
+        checkCalls += 1;
+        return HttpResponse.json({ show: false });
+      }),
+      http.post('/api/v1/sponsor-prompt/dismiss', () => new HttpResponse(null, { status: 204 })),
+    );
+
+    const first = renderHook(() => useSponsorPrompt('EUR'));
+    await waitFor(() => expect(checkCalls).toBe(1));
+    first.unmount();
+
+    // A second mount in the same session (e.g. a route change that remounts
+    // Layout) must not re-run the check — that per-tab guard is what keeps the
+    // toast from flashing repeatedly while a session is open.
+    renderHook(() => useSponsorPrompt('EUR'));
+    await new Promise((r) => setTimeout(r, 20));
+    expect(checkCalls).toBe(1);
+  });
+});

+ 98 - 0
frontend/src/__tests__/hooks/useWebSocket.test.ts

@@ -68,6 +68,15 @@ class MockWebSocket {
     }
   }
 
+  // Helper to simulate the server closing with a specific code (e.g. 4401,
+  // the /ws auth-rejection close code).
+  simulateClose(code: number) {
+    this.readyState = MockWebSocket.CLOSED;
+    if (this.onclose) {
+      this.onclose(new CloseEvent('close', { code }));
+    }
+  }
+
   // Helper to simulate receiving a message
   simulateMessage(data: unknown) {
     if (this.onmessage) {
@@ -668,6 +677,95 @@ describe('useWebSocket hook', () => {
       vi.useRealTimers();
     });
 
+    it('does NOT reconnect after an auth-rejection close (4401)', async () => {
+      // Regression: a 4401 (ws-token invalid/expired or caller lacks
+      // WEBSOCKET_CONNECT) used to reschedule connect() every 3s, spamming
+      // /auth/ws-token forever. It must be terminal now.
+      vi.useFakeTimers();
+
+      const { useWebSocket } = await import('../../hooks/useWebSocket');
+      renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
+
+      await vi.advanceTimersByTimeAsync(0);
+      const firstWs = wsInstances[wsInstances.length - 1]!;
+      act(() => {
+        firstWs.open();
+      });
+
+      const instanceCountBefore = wsInstances.length;
+
+      // Server rejects auth.
+      act(() => {
+        firstWs.simulateClose(4401);
+      });
+
+      // No reconnect even after the 3s window elapses.
+      await vi.advanceTimersByTimeAsync(3000);
+      expect(wsInstances.length).toBe(instanceCountBefore);
+
+      vi.useRealTimers();
+    });
+
+    it('does NOT open a socket or reconnect when ws-token mint returns 403', async () => {
+      // Mike/Forge's case: an authenticated user whose group lacks
+      // WEBSOCKET_CONNECT. POST /auth/ws-token returns 403; the hook must NOT
+      // fall through to a tokenless socket (server closes it 4401) and must NOT
+      // enter the reconnect loop — it degrades to REST polling instead.
+      vi.useFakeTimers();
+
+      vi.stubGlobal(
+        'fetch',
+        vi.fn(async () => ({
+          ok: false,
+          status: 403,
+          statusText: 'Forbidden',
+          headers: { get: () => null },
+          json: async () => ({ detail: 'Insufficient permissions' }),
+        })),
+      );
+
+      const { useWebSocket } = await import('../../hooks/useWebSocket');
+      renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
+
+      // Flush the token-mint rejection, then let the (would-be) reconnect
+      // window pass. No socket should ever be constructed.
+      await vi.advanceTimersByTimeAsync(0);
+      await vi.advanceTimersByTimeAsync(3000);
+      expect(wsInstances.length).toBe(0);
+
+      vi.useRealTimers();
+    });
+
+    it('does NOT reconnect when a close fires during unmount', async () => {
+      // The provider unmounting (e.g. logout redirect) must not leave a
+      // scheduled reconnect behind — the cleanup marks disposed before
+      // close(), so the resulting onclose is a no-op.
+      vi.useFakeTimers();
+
+      const { useWebSocket } = await import('../../hooks/useWebSocket');
+      const { unmount } = renderHook(() => useWebSocket(), {
+        wrapper: createWrapper(queryClient),
+      });
+
+      await vi.advanceTimersByTimeAsync(0);
+      const ws = wsInstances[wsInstances.length - 1]!;
+      act(() => {
+        ws.open();
+      });
+
+      const instanceCountBefore = wsInstances.length;
+
+      // Unmount closes the socket, which fires onclose synchronously.
+      act(() => {
+        unmount();
+      });
+
+      await vi.advanceTimersByTimeAsync(3000);
+      expect(wsInstances.length).toBe(instanceCountBefore);
+
+      vi.useRealTimers();
+    });
+
     it('cleans up on unmount', async () => {
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 

+ 156 - 0
frontend/src/__tests__/pages/PipelineRunsPage.test.tsx

@@ -0,0 +1,156 @@
+/**
+ * Tests for PipelineRunsPage — dashboard for Slicer Pipeline runs (#1425 PR C).
+ *
+ * Pin the load → list → expand → cancel/retry flow. We mock the api client
+ * so we can exercise the dashboard without hitting the WS subscription.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { PipelineRunsView } from '../../pages/PipelineRunsPage';
+import { api, type PipelineRun } from '../../api/client';
+
+vi.mock('../../api/client', () => ({
+  api: {
+    listSlicerPipelines: vi.fn(),
+    listAllPipelineRuns: vi.fn(),
+    cancelPipelineRun: vi.fn(),
+    retryFailedPipelineRun: vi.fn(),
+    // Bootstrap calls — ThemeContext / AuthContext touch these on mount.
+    getSettings: vi.fn().mockResolvedValue({}),
+    updateSettings: vi.fn().mockResolvedValue({}),
+  },
+}));
+
+const mockApi = api as unknown as {
+  listSlicerPipelines: ReturnType<typeof vi.fn>;
+  listAllPipelineRuns: ReturnType<typeof vi.fn>;
+  cancelPipelineRun: ReturnType<typeof vi.fn>;
+  retryFailedPipelineRun: ReturnType<typeof vi.fn>;
+};
+
+function makeRun(overrides: Partial<PipelineRun> = {}): PipelineRun {
+  return {
+    id: 1,
+    pipeline_id: 10,
+    pipeline_name: 'Production Batch',
+    source_library_file_id: 99,
+    source_archive_id: null,
+    source_filename: 'cube.3mf',
+    parent_run_id: null,
+    copies: 3,
+    copies_completed: 1,
+    copies_failed: 0,
+    copies_cancelled: 0,
+    copies_in_progress: 2,
+    status: 'in_progress',
+    slice_job_id: 4242,
+    sliced_library_file_id: 100,
+    eligibility_overridden: false,
+    error_message: null,
+    created_by: null,
+    created_at: '2026-06-27T15:00:00Z',
+    started_at: '2026-06-27T15:00:01Z',
+    completed_at: null,
+    jobs: [
+      {
+        id: 1,
+        pipeline_run_id: 1,
+        copy_index: 0,
+        assigned_printer_id: 5,
+        assigned_printer_name: 'X1C #1',
+        queue_entry_id: 200,
+        status: 'completed',
+        error_message: null,
+        dispatched_at: '2026-06-27T15:00:02Z',
+        completed_at: '2026-06-27T15:30:00Z',
+      },
+      {
+        id: 2,
+        pipeline_run_id: 1,
+        copy_index: 1,
+        assigned_printer_id: 6,
+        assigned_printer_name: 'X1C #2',
+        queue_entry_id: 201,
+        status: 'printing',
+        error_message: null,
+        dispatched_at: '2026-06-27T15:00:02Z',
+        completed_at: null,
+      },
+    ],
+    target_kind: 'printer_class',
+    target_printer_id: null,
+    target_model_class: 'X1C',
+    fanout_strategy: 'max_parallel',
+    ...overrides,
+  };
+}
+
+describe('PipelineRunsView', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
+    mockApi.listAllPipelineRuns.mockResolvedValue({ runs: [], total: 0 });
+  });
+
+  it('renders empty state when no runs exist', async () => {
+    render(<PipelineRunsView />);
+    await waitFor(() => {
+      expect(screen.getByText(/No pipeline runs yet/i)).toBeInTheDocument();
+    });
+  });
+
+  it('lists runs and surfaces the pipeline name + status chip', async () => {
+    mockApi.listAllPipelineRuns.mockResolvedValue({ runs: [makeRun()], total: 1 });
+    render(<PipelineRunsView />);
+    await waitFor(() => {
+      expect(screen.getByText(/Production Batch/i)).toBeInTheDocument();
+      // Status chip uses the i18n key.
+      expect(screen.getAllByText(/printing/i).length).toBeGreaterThan(0);
+    });
+  });
+
+  it('shows a Cancel button on in-flight runs and fires the mutation', async () => {
+    mockApi.listAllPipelineRuns.mockResolvedValue({ runs: [makeRun()], total: 1 });
+    mockApi.cancelPipelineRun.mockResolvedValue(makeRun({ status: 'cancelled' }));
+    render(<PipelineRunsView />);
+    const user = userEvent.setup();
+    await waitFor(() => expect(screen.getByText(/Production Batch/i)).toBeInTheDocument());
+    await user.click(screen.getByRole('button', { name: /Cancel/i }));
+    await waitFor(() => expect(mockApi.cancelPipelineRun).toHaveBeenCalledWith(1));
+  });
+
+  it('shows a Retry-failed button on partial_failure runs and fires the mutation', async () => {
+    mockApi.listAllPipelineRuns.mockResolvedValue({
+      runs: [
+        makeRun({
+          status: 'partial_failure',
+          copies_completed: 1,
+          copies_failed: 2,
+          copies_in_progress: 0,
+        }),
+      ],
+      total: 1,
+    });
+    mockApi.retryFailedPipelineRun.mockResolvedValue(makeRun({ id: 2, parent_run_id: 1, copies: 2 }));
+    render(<PipelineRunsView />);
+    const user = userEvent.setup();
+    await waitFor(() => expect(screen.getByText(/Production Batch/i)).toBeInTheDocument());
+    await user.click(screen.getByRole('button', { name: /Retry failed/i }));
+    await waitFor(() => expect(mockApi.retryFailedPipelineRun).toHaveBeenCalledWith(1));
+  });
+
+  it('expands the row to show per-copy jobs', async () => {
+    mockApi.listAllPipelineRuns.mockResolvedValue({ runs: [makeRun()], total: 1 });
+    render(<PipelineRunsView />);
+    const user = userEvent.setup();
+    await waitFor(() => expect(screen.getByText(/Production Batch/i)).toBeInTheDocument());
+    await user.click(screen.getByRole('button', { name: /Expand/i }));
+    await waitFor(() => {
+      expect(screen.getByText('X1C #1')).toBeInTheDocument();
+      expect(screen.getByText('X1C #2')).toBeInTheDocument();
+    });
+  });
+});

+ 17 - 2
frontend/src/__tests__/pages/PrintersPageBucketing.test.ts

@@ -20,7 +20,7 @@ import { describe, it, expect } from 'vitest';
 type Status = {
   connected: boolean;
   state: string | null;
-  hms_errors?: { code: string; attr: number; severity: number }[];
+  hms_errors?: { code: string; attr: number; severity: number; actions?: string[] }[];
 };
 
 type Bucket = 'printing' | 'paused' | 'finished' | 'idle' | 'offline' | 'error';
@@ -32,7 +32,8 @@ function filterKnownHMSErrors(errors: Status['hms_errors']): NonNullable<Status[
     const codeNum = parseInt(e.code.replace('0x', ''), 16) || 0;
     const module = ((e.attr >> 16) & 0xFFFF).toString(16).padStart(4, '0').toUpperCase();
     const code = (codeNum & 0xFFFF).toString(16).padStart(4, '0').toUpperCase();
-    return KNOWN_HMS_CODES.has(`${module}_${code}`);
+    if (KNOWN_HMS_CODES.has(`${module}_${code}`)) return true;
+    return (e.actions?.length ?? 0) > 0;
   });
 }
 
@@ -77,6 +78,20 @@ describe('FAILED-without-HMS bucketing', () => {
     expect(classifyPrinterStatus(cancelEcho)).toBe('finished');
   });
 
+  it('classifies PAUSE + uncataloged HMS WITH actions as "error" (#1840: H2C 0500_809C carries actions but isnt in the bundled catalog)', () => {
+    const h2cActionableFault: Status = {
+      connected: true,
+      state: 'PAUSE',
+      hms_errors: [{
+        code: '0x809c',
+        attr: 0x0500_809C,
+        severity: 3,
+        actions: ['IGNORE_RESUME', 'PROBLEM_SOLVED_RESUME'],
+      }],
+    };
+    expect(classifyPrinterStatus(h2cActionableFault)).toBe('error');
+  });
+
   it('classifies FINISH as "finished" (unchanged baseline)', () => {
     const completedPrinter: Status = { connected: true, state: 'FINISH' };
     expect(classifyPrinterStatus(completedPrinter)).toBe('finished');

+ 55 - 2
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -303,7 +303,7 @@ describe('SettingsPage', () => {
 
       expect(localStorage.setItem).toHaveBeenCalledWith(
         SIDEBAR_ORDER_KEY,
-        JSON.stringify(['ext-7', 'printers', 'inventory', 'archives', 'queue', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'settings']),
+        JSON.stringify(['ext-7', 'printers', 'inventory', 'archives', 'queue', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'notifications', 'settings']),
       );
     });
 
@@ -345,7 +345,7 @@ describe('SettingsPage', () => {
       expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify([]));
       expect(localStorage.setItem).toHaveBeenCalledWith(
         SIDEBAR_ORDER_KEY,
-        JSON.stringify(['printers', 'inventory', 'archives', 'queue', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'settings', 'ext-7']),
+        JSON.stringify(['printers', 'inventory', 'archives', 'queue', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'notifications', 'settings', 'ext-7']),
       );
 
       const settingsRow = screen.getAllByText('Settings')
@@ -416,6 +416,7 @@ describe('SettingsPage', () => {
           'profiles',
           'maintenance',
           'stats',
+          'notifications',
           'settings',
         ],
         hiddenSystemItemIds: ['stats'],
@@ -1289,4 +1290,56 @@ describe('SettingsPage', () => {
       });
     });
   });
+
+  // --------------------------------------------------------------------
+  // Slicer Pipelines (#1425) — Workflow tab sub-tabs
+  // --------------------------------------------------------------------
+  describe('workflow sub-tabs (#1425)', () => {
+    beforeEach(() => {
+      // Endpoints the Pipelines panel calls (#1425).
+      server.use(
+        http.get('/api/v1/slicer-pipelines/', () => HttpResponse.json({ pipelines: [] })),
+        http.get('/api/v1/slicer/presets', () =>
+          HttpResponse.json({
+            orca_cloud: { printer: [], process: [], filament: [] },
+            cloud: { printer: [], process: [], filament: [] },
+            local: { printer: [], process: [], filament: [] },
+            standard: { printer: [], process: [], filament: [] },
+            cloud_status: 'ok',
+            orca_cloud_status: 'ok',
+          }),
+        ),
+      );
+    });
+
+    it('renders Queue & Dispatch + Pipelines sub-tabs under Workflow', async () => {
+      render(<SettingsPage />);
+      const user = userEvent.setup();
+      await waitFor(() => {
+        // Workflow tab in the sidebar — exact match to avoid colliding with
+        // "Print Queue" or "Queue Settings" labels elsewhere on the page.
+        expect(screen.getByRole('button', { name: 'Workflow' })).toBeInTheDocument();
+      });
+      await user.click(screen.getByRole('button', { name: 'Workflow' }));
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: /Queue & Dispatch/i })).toBeInTheDocument();
+        expect(screen.getByRole('button', { name: /^Pipelines$/i })).toBeInTheDocument();
+      });
+    });
+
+    it('clicking Pipelines sub-tab shows the empty-state hint and updates the URL', async () => {
+      render(<SettingsPage />);
+      const user = userEvent.setup();
+      await waitFor(() => expect(screen.getByRole('button', { name: 'Workflow' })).toBeInTheDocument());
+      await user.click(screen.getByRole('button', { name: 'Workflow' }));
+      await user.click(screen.getByRole('button', { name: /^Pipelines$/i }));
+
+      await waitFor(() => {
+        expect(screen.getByText(/No pipelines yet/i)).toBeInTheDocument();
+        // Deep-link URL carries both ?tab=queue and ?sub=pipelines
+        expect(window.location.search).toContain('tab=queue');
+        expect(window.location.search).toContain('sub=pipelines');
+      });
+    });
+  });
 });

+ 6 - 2
frontend/src/__tests__/utils/currency.test.ts

@@ -57,7 +57,11 @@ describe('SUPPORTED_CURRENCIES', () => {
     expect(SUPPORTED_CURRENCIES.find((c) => c.code === 'BZD')).toBeDefined();
   });
 
-  it('has 30 entries', () => {
-    expect(SUPPORTED_CURRENCIES).toHaveLength(30);
+  it('contains IDR', () => {
+    expect(SUPPORTED_CURRENCIES.find((c) => c.code === 'IDR')).toBeDefined();
+  });
+
+  it('has 31 entries', () => {
+    expect(SUPPORTED_CURRENCIES).toHaveLength(31);
   });
 });

+ 247 - 3
frontend/src/api/client.ts

@@ -470,7 +470,7 @@ export interface PrinterStatus {
   ipcam: boolean;  // Live view enabled
   wifi_signal: number | null;  // WiFi signal strength in dBm
   wired_network: boolean;  // Ethernet connection detected
-  door_open: boolean;  // Enclosure door open (X1/P1S/P2S/H2*)
+  door_open: boolean;  // Enclosure door open (models with a door sensor: X1/X1C/X1E/X2D/P2S/H2*)
   nozzles: NozzleInfo[];  // Nozzle hardware info (index 0=left/primary, 1=right)
   nozzle_rack: NozzleRackSlot[];  // H2C 6-nozzle tool-changer rack
   print_options: PrintOptions | null;  // AI detection and print options
@@ -1048,6 +1048,9 @@ export interface APIKey {
   can_read_status: boolean;
   can_manage_library: boolean;
   can_manage_inventory: boolean;
+  can_manage_maintenance: boolean;
+  can_manage_archives: boolean;
+  can_manage_projects: boolean;
   can_access_cloud: boolean;
   can_update_energy_cost: boolean;
   printer_ids: number[] | null;
@@ -1064,6 +1067,9 @@ export interface APIKeyCreate {
   can_read_status?: boolean;
   can_manage_library?: boolean;
   can_manage_inventory?: boolean;
+  can_manage_maintenance?: boolean;
+  can_manage_archives?: boolean;
+  can_manage_projects?: boolean;
   can_access_cloud?: boolean;
   can_update_energy_cost?: boolean;
   printer_ids?: number[] | null;
@@ -1081,6 +1087,9 @@ export interface APIKeyUpdate {
   can_read_status?: boolean;
   can_manage_library?: boolean;
   can_manage_inventory?: boolean;
+  can_manage_maintenance?: boolean;
+  can_manage_archives?: boolean;
+  can_manage_projects?: boolean;
   can_access_cloud?: boolean;
   can_update_energy_cost?: boolean;
   printer_ids?: number[] | null;
@@ -1139,6 +1148,7 @@ export interface AppSettings {
   spoolman_url: string;
   // Default printer
   default_printer_id: number | null;
+  pipeline_max_copies: number;
   // Dark mode theme settings
   dark_style: 'classic' | 'glow' | 'vibrant';
   dark_background: 'neutral' | 'warm' | 'cool' | 'oled' | 'slate' | 'forest';
@@ -1209,6 +1219,15 @@ export interface AppSettings {
   require_plate_clear: boolean;
   // Shortest job first scheduling
   queue_shortest_first: boolean;
+  // Preheat / heat-soak before queued prints (#1468). Master toggle is the
+  // default for new queue items; per-item PrintQueueItem.preheat_override can
+  // flip the decision per print. Chamber target derives from the loaded AMS
+  // filament types via preheat_filament_targets (JSON map of type → °C, max
+  // across loaded slots); the per-item override field bypasses derivation.
+  preheat_enabled: boolean;
+  preheat_filament_targets: string;
+  preheat_max_wait_seconds: number;
+  preheat_soak_seconds: number;
   // User-configurable presets for the printer-card popovers (JSON arrays of 3 ints).
   // Empty string = use built-in defaults.
   nozzle_temp_presets: string;
@@ -1502,6 +1521,139 @@ export interface UnifiedPresetsResponse {
   orca_cloud_status: SlicerCloudStatus;
 }
 
+// Slicer Pipelines (#1425) — named bundles of preset slots the SliceModal
+// can apply in one click. PR A surfaces only the bundle; target_* and
+// fanout_strategy round-trip from the backend but the UI doesn't yet expose
+// them (they come alive in PR B / PR C).
+export interface SlicerPipeline {
+  id: number;
+  name: string;
+  description: string | null;
+  printer_preset: PresetRef;
+  process_preset: PresetRef;
+  filament_presets: PresetRef[];
+  bed_type: string | null;
+  target_kind: 'specific_printer' | 'printer_class';
+  target_printer_id: number | null;
+  target_model_class: string | null;
+  fanout_strategy: 'max_parallel' | 'fill_one_first' | 'round_robin';
+  created_by: number | null;
+  created_at: string;
+  updated_at: string;
+}
+export interface SlicerPipelineCreateRequest {
+  name: string;
+  description?: string | null;
+  printer_preset: PresetRef;
+  process_preset: PresetRef;
+  filament_presets: PresetRef[];
+  bed_type?: string | null;
+}
+export type SlicerPipelineUpdateRequest = Partial<SlicerPipelineCreateRequest> & {
+  target_kind?: 'specific_printer' | 'printer_class';
+  // ``target_printer_id: 0`` means "clear the target" — the backend maps that
+  // to null. Use null in TypeScript for the same intent.
+  target_printer_id?: number | null;
+  target_model_class?: string | null;
+  fanout_strategy?: 'max_parallel' | 'fill_one_first' | 'round_robin';
+};
+export interface SlicerPipelinesListResponse {
+  pipelines: SlicerPipeline[];
+}
+
+// Slicer Pipeline runs (#1425 PR B + PR C)
+export type PipelineEligibilityKind =
+  | 'printer_not_set'
+  | 'printer_not_found'
+  | 'printer_disabled'
+  | 'printer_offline'
+  | 'filament_type_mismatch'
+  | 'filament_color_mismatch'
+  | 'ams_slot_missing'
+  | 'filament_unverified'
+  | 'no_class_matches'
+  | 'class_not_set';
+export interface PipelineEligibilityIssue {
+  kind: PipelineEligibilityKind;
+  slot_index: number | null;
+  expected: string | null;
+  actual: string | null;
+}
+export interface PipelinePerPrinterReport {
+  printer_id: number;
+  printer_name: string;
+  ok: boolean;
+  issues: PipelineEligibilityIssue[];
+}
+export interface PipelineEligibilityReport {
+  ok: boolean;
+  target_kind: 'specific_printer' | 'printer_class';
+  target_printer_id: number | null;
+  target_printer_name: string | null;
+  target_model_class: string | null;
+  issues: PipelineEligibilityIssue[];
+  printer_reports: PipelinePerPrinterReport[];
+}
+export interface PipelineJob {
+  id: number;
+  pipeline_run_id: number;
+  copy_index: number;
+  assigned_printer_id: number | null;
+  assigned_printer_name: string | null;
+  queue_entry_id: number | null;
+  status:
+    | 'pending'
+    | 'awaiting_printer'
+    | 'queued'
+    | 'printing'
+    | 'completed'
+    | 'failed'
+    | 'cancelled';
+  error_message: string | null;
+  dispatched_at: string | null;
+  completed_at: string | null;
+}
+export interface PipelineRun {
+  id: number;
+  pipeline_id: number | null;
+  pipeline_name: string | null;
+  source_library_file_id: number | null;
+  source_archive_id: number | null;
+  source_filename: string | null;
+  parent_run_id: number | null;
+  copies: number;
+  copies_completed: number;
+  copies_failed: number;
+  copies_cancelled: number;
+  copies_in_progress: number;
+  status:
+    | 'queued'
+    | 'slicing'
+    | 'dispatching'
+    | 'in_progress'
+    | 'completed'
+    | 'failed'
+    | 'partial_failure'
+    | 'cancelled';
+  slice_job_id: number | null;
+  sliced_library_file_id: number | null;
+  eligibility_overridden: boolean;
+  error_message: string | null;
+  created_by: number | null;
+  created_at: string;
+  started_at: string | null;
+  completed_at: string | null;
+  jobs: PipelineJob[];
+  target_kind: 'specific_printer' | 'printer_class' | null;
+  target_printer_id: number | null;
+  target_model_class: string | null;
+  fanout_strategy: 'max_parallel' | 'fill_one_first' | 'round_robin' | null;
+}
+export interface PipelineRunListResponse {
+  runs: PipelineRun[];
+  total: number;
+}
+
 export interface SliceResponse {
   library_file_id: number;
   name: string;
@@ -1942,6 +2094,8 @@ export interface PrintQueueItem {
   timelapse: boolean;
   use_ams: boolean;
   nozzle_offset_cali: boolean;
+  preheat_override: 'inherit' | 'on' | 'off';
+  preheat_chamber_target_override: number | null;
   status: 'pending' | 'printing' | 'completed' | 'failed' | 'skipped' | 'cancelled';
   started_at: string | null;
   completed_at: string | null;
@@ -2017,6 +2171,8 @@ export interface PrintQueueItemCreate {
   timelapse?: boolean;
   use_ams?: boolean;
   nozzle_offset_cali?: boolean;
+  preheat_override?: 'inherit' | 'on' | 'off';
+  preheat_chamber_target_override?: number | null;
   // Auto-print G-code injection
   gcode_injection?: boolean;
   // Batch: create multiple copies (creates a batch if > 1)
@@ -2059,6 +2215,8 @@ export interface PrintQueueItemUpdate {
   timelapse?: boolean;
   use_ams?: boolean;
   nozzle_offset_cali?: boolean;
+  preheat_override?: 'inherit' | 'on' | 'off';
+  preheat_chamber_target_override?: number | null;
   // Auto-print G-code injection
   gcode_injection?: boolean;
 }
@@ -2078,6 +2236,8 @@ export interface PrintQueueBulkUpdate {
   timelapse?: boolean;
   use_ams?: boolean;
   nozzle_offset_cali?: boolean;
+  preheat_override?: 'inherit' | 'on' | 'off';
+  preheat_chamber_target_override?: number | null;
   // Auto-print G-code injection
   gcode_injection?: boolean;
 }
@@ -3002,6 +3162,7 @@ export type Permission =
   | 'api_keys:read' | 'api_keys:create' | 'api_keys:update' | 'api_keys:delete'
   | 'users:read' | 'users:create' | 'users:update' | 'users:delete'
   | 'groups:read' | 'groups:create' | 'groups:update' | 'groups:delete'
+  | 'pipelines:read' | 'pipelines:write' | 'pipelines:run'
   | 'websocket:connect';
 
 // Group types
@@ -5190,7 +5351,7 @@ export const api = {
   // ── Spool label printing (#809) ──────────────────────────────────────────
   // Both endpoints return application/pdf. Frontend opens the resulting Blob
   // in a new tab so the user can print or save from the browser's PDF viewer.
-  printSpoolLabels: async (data: { spool_ids: number[]; template: SpoolLabelTemplate }): Promise<Blob> => {
+  printSpoolLabels: async (data: { spool_ids: number[]; template: SpoolLabelTemplate; monochrome?: boolean }): Promise<Blob> => {
     const headers: Record<string, string> = { 'Content-Type': 'application/json' };
     if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
     const response = await fetch(`${API_BASE}/inventory/labels`, {
@@ -5204,7 +5365,7 @@ export const api = {
     }
     return response.blob();
   },
-  printSpoolmanSpoolLabels: async (data: { spool_ids: number[]; template: SpoolLabelTemplate }): Promise<Blob> => {
+  printSpoolmanSpoolLabels: async (data: { spool_ids: number[]; template: SpoolLabelTemplate; monochrome?: boolean }): Promise<Blob> => {
     const headers: Record<string, string> = { 'Content-Type': 'application/json' };
     if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
     const response = await fetch(`${API_BASE}/spoolman/labels`, {
@@ -6199,6 +6360,89 @@ export const api = {
       options?.refresh ? '/slicer/presets?refresh=true' : '/slicer/presets',
     ),
 
+  // Slicer Pipelines (#1425) — preset bundles the SliceModal can apply in
+  // one click. CRUD is gated on PIPELINES_READ / PIPELINES_WRITE.
+  listSlicerPipelines: () =>
+    request<SlicerPipelinesListResponse>('/slicer-pipelines/'),
+  getSlicerPipeline: (id: number) =>
+    request<SlicerPipeline>(`/slicer-pipelines/${id}`),
+  createSlicerPipeline: (data: SlicerPipelineCreateRequest) =>
+    request<SlicerPipeline>('/slicer-pipelines/', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  updateSlicerPipeline: (id: number, data: SlicerPipelineUpdateRequest) =>
+    request<SlicerPipeline>(`/slicer-pipelines/${id}`, {
+      method: 'PUT',
+      body: JSON.stringify(data),
+    }),
+  deleteSlicerPipeline: (id: number) =>
+    request<void>(`/slicer-pipelines/${id}`, { method: 'DELETE' }),
+  checkPipelineEligibility: (
+    pipelineId: number,
+    source: { kind: 'libraryFile'; id: number } | { kind: 'archive'; id: number },
+  ) =>
+    request<PipelineEligibilityReport>(`/slicer-pipelines/${pipelineId}/check-eligibility`, {
+      method: 'POST',
+      body: JSON.stringify(
+        source.kind === 'libraryFile'
+          ? { source_library_file_id: source.id }
+          : { source_archive_id: source.id },
+      ),
+    }),
+  runPipeline: (
+    pipelineId: number,
+    source: { kind: 'libraryFile'; id: number } | { kind: 'archive'; id: number },
+    force = false,
+    copies = 1,
+  ) =>
+    request<PipelineRun>(`/slicer-pipelines/${pipelineId}/run`, {
+      method: 'POST',
+      body: JSON.stringify({
+        ...(source.kind === 'libraryFile'
+          ? { source_library_file_id: source.id }
+          : { source_archive_id: source.id }),
+        force,
+        copies,
+      }),
+    }),
+  listPipelineRuns: (pipelineId: number, limit = 5) =>
+    request<PipelineRunListResponse>(
+      `/slicer-pipelines/${pipelineId}/runs?limit=${limit}`,
+    ),
+  // Dashboard list across all pipelines (#1425 PR C).
+  listAllPipelineRuns: (params: {
+    limit?: number;
+    offset?: number;
+    pipelineId?: number;
+    status?: string;
+    targetPrinterId?: number;
+    targetModelClass?: string;
+  } = {}) => {
+    const search = new URLSearchParams();
+    if (params.limit) search.set('limit', String(params.limit));
+    if (params.offset) search.set('offset', String(params.offset));
+    if (params.pipelineId) search.set('pipeline_id', String(params.pipelineId));
+    if (params.status) search.set('status', params.status);
+    if (params.targetPrinterId) search.set('target_printer_id', String(params.targetPrinterId));
+    if (params.targetModelClass) search.set('target_model_class', params.targetModelClass);
+    const q = search.toString();
+    return request<PipelineRunListResponse>(
+      `/pipeline-runs${q ? '?' + q : ''}`,
+    );
+  },
+  // Clear terminal pipeline runs (#1425 PR C polish). Deletes all runs in
+  // a terminal state (completed/failed/cancelled/partial_failure); in-flight
+  // runs are preserved.
+  clearTerminalPipelineRuns: () =>
+    request<{ deleted: number }>('/pipeline-runs/clear', { method: 'POST' }),
+  getPipelineRun: (runId: number) =>
+    request<PipelineRun>(`/pipeline-runs/${runId}`),
+  cancelPipelineRun: (runId: number) =>
+    request<PipelineRun>(`/pipeline-runs/${runId}/cancel`, { method: 'POST' }),
+  retryFailedPipelineRun: (runId: number) =>
+    request<PipelineRun>(`/pipeline-runs/${runId}/retry-failed`, { method: 'POST' }),
+
   // Canonical Bambu printer-model registry — "Bambu Lab <model>" → short code.
   // Single source of truth shared with backend (PRINTER_MODEL_MAP); the
   // SliceModal uses this to classify cloud / standard presets by their

+ 2 - 2
frontend/src/components/AMSHistoryModal.tsx

@@ -136,8 +136,8 @@ export function AMSHistoryModal({
   const tempTrend = getTrend(chartData.map(d => d.temperature));
 
   const TrendIcon = ({ trend }: { trend: string }) => {
-    if (trend === 'up') return <TrendingUp className="w-4 h-4 text-red-400" />;
-    if (trend === 'down') return <TrendingDown className="w-4 h-4 text-green-400" />;
+    if (trend === 'up') return <TrendingUp className="w-4 h-4 text-red-600 dark:text-red-400" />;
+    if (trend === 'down') return <TrendingDown className="w-4 h-4 text-green-600 dark:text-green-400" />;
     return <Minus className="w-4 h-4 text-gray-400 dark:text-bambu-gray" />;
   };
 

+ 13 - 13
frontend/src/components/APIBrowser.tsx

@@ -70,11 +70,11 @@ interface APIResponse {
 }
 
 const METHOD_COLORS: Record<string, string> = {
-  get: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
-  post: 'bg-green-500/20 text-green-400 border-green-500/30',
-  put: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
-  patch: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
-  delete: 'bg-red-500/20 text-red-400 border-red-500/30',
+  get: 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400 border-blue-300 dark:border-blue-500/30',
+  post: 'bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400 border-green-300 dark:border-green-500/30',
+  put: 'bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400 border-yellow-300 dark:border-yellow-500/30',
+  patch: 'bg-orange-100 dark:bg-orange-500/20 text-orange-700 dark:text-orange-400 border-orange-300 dark:border-orange-500/30',
+  delete: 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400 border-red-300 dark:border-red-500/30',
 };
 
 function resolveRef(schema: OpenAPISchema, ref: string): SchemaSpec {
@@ -340,7 +340,7 @@ function EndpointItem({ path, method, spec, schema, apiKey }: EndpointItemProps)
                   <div key={param.name} className="flex items-center gap-2">
                     <label className="text-sm text-bambu-gray w-32 flex-shrink-0">
                       {param.name}
-                      {param.required && <span className="text-red-400 ml-1">*</span>}
+                      {param.required && <span className="text-red-700 dark:text-red-400 ml-1">*</span>}
                     </label>
                     <input
                       type="text"
@@ -364,7 +364,7 @@ function EndpointItem({ path, method, spec, schema, apiKey }: EndpointItemProps)
                   <div key={param.name} className="flex items-center gap-2">
                     <label className="text-sm text-bambu-gray w-32 flex-shrink-0">
                       {param.name}
-                      {param.required && <span className="text-red-400 ml-1">*</span>}
+                      {param.required && <span className="text-red-700 dark:text-red-400 ml-1">*</span>}
                     </label>
                     {param.schema?.enum ? (
                       <select
@@ -417,7 +417,7 @@ function EndpointItem({ path, method, spec, schema, apiKey }: EndpointItemProps)
               Execute
             </Button>
             {missingParams.length > 0 && (
-              <span className="text-xs text-yellow-400 flex items-center gap-1">
+              <span className="text-xs text-yellow-700 dark:text-yellow-400 flex items-center gap-1">
                 <AlertCircle className="w-3 h-3" />
                 Fill in: {missingParams.join(', ')}
               </span>
@@ -432,10 +432,10 @@ function EndpointItem({ path, method, spec, schema, apiKey }: EndpointItemProps)
                   Response
                   <span className={`px-2 py-0.5 text-xs rounded ${
                     response.status >= 200 && response.status < 300
-                      ? 'bg-green-500/20 text-green-400'
+                      ? 'bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400'
                       : response.status >= 400
-                        ? 'bg-red-500/20 text-red-400'
-                        : 'bg-yellow-500/20 text-yellow-400'
+                        ? 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400'
+                        : 'bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400'
                   }`}>
                     {response.status} {response.statusText}
                   </span>
@@ -443,7 +443,7 @@ function EndpointItem({ path, method, spec, schema, apiKey }: EndpointItemProps)
                 </h4>
                 <Button variant="secondary" size="sm" onClick={copyResponse}>
                   {copied ? (
-                    <CheckCircle className="w-3 h-3 text-green-400" />
+                    <CheckCircle className="w-3 h-3 text-green-600 dark:text-green-400" />
                   ) : (
                     <Copy className="w-3 h-3" />
                   )}
@@ -501,7 +501,7 @@ export function APIBrowser({ apiKey = '' }: APIBrowserProps) {
     return (
       <Card>
         <CardContent className="py-8">
-          <div className="text-center text-red-400">
+          <div className="text-center text-red-700 dark:text-red-400">
             <AlertCircle className="w-12 h-12 mx-auto mb-3 opacity-50" />
             <p>Failed to load API schema</p>
             <p className="text-sm text-bambu-gray mt-1">{error}</p>

+ 2 - 2
frontend/src/components/AddExternalLinkModal.tsx

@@ -187,7 +187,7 @@ export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProp
         {/* Form */}
         <form onSubmit={handleSubmit} className="p-6 space-y-4">
           {error && (
-            <div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
+            <div className="p-3 bg-red-100 dark:bg-red-500/20 border border-red-300 dark:border-red-500/50 rounded-lg text-sm text-red-700 dark:text-red-400">
               {error}
             </div>
           )}
@@ -256,7 +256,7 @@ export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProp
                     <button
                       type="button"
                       onClick={handleRemoveCustomIcon}
-                      className="p-1 text-red-400 hover:text-red-300 transition-colors"
+                      className="p-1 text-red-700 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 transition-colors"
                       title="Remove custom icon"
                     >
                       <Trash2 className="w-4 h-4" />

+ 2 - 2
frontend/src/components/AddNotificationModal.tsx

@@ -284,7 +284,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
         {/* Form */}
         <form onSubmit={handleSubmit} className="p-6 space-y-4">
           {error && (
-            <div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
+            <div className="p-3 bg-red-100 dark:bg-red-500/20 border border-red-300 dark:border-red-500/50 rounded-lg text-sm text-red-700 dark:text-red-400">
               {error}
             </div>
           )}
@@ -392,7 +392,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
             <div className={`p-3 rounded-lg flex items-center gap-2 ${
               testResult.success
                 ? 'bg-bambu-green/20 border border-bambu-green/50 text-bambu-green'
-                : 'bg-red-500/20 border border-red-500/50 text-red-400'
+                : 'bg-red-100 dark:bg-red-500/20 border border-red-300 dark:border-red-500/50 text-red-700 dark:text-red-400'
             }`}>
               {testResult.success ? (
                 <>

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