82 Commits 6d673d0626 ... c0129ef93f

Author SHA1 Message Date
  maziggy c0129ef93f chore(docker): silence Trivy DS-0026 on Dockerfile.test via HEALTHCHECK NONE 3 days ago
  maziggy b3b37e8f08 ci(docker): full backend suite in Docker, 4-way matrix shard, GHA cache backend 3 days ago
  maziggy ed905d8406 ci(docker): stop re-running unit tests inside the test image 3 days ago
  maziggy 39a075918a ci(docker): drop -v, -n auto instead of -n 30, pip cache mount 3 days ago
  maziggy 4fac9ff12c fix(test): stop sys.modules-deleting backend.app.main in test_code_quality 3 days ago
  maziggy af03a6f384 fix(test): snapshot _timelapse_baselines inside the patch context to dodge CI race 3 days ago
  MartinNYHC f385936c58 Merge pull request #1514 from maziggy/0.2.4.3 3 days ago
  maziggy 02e119ea45 fix(test): use /nonexistent/ instead of /tmp/ to satisfy Bandit B108 3 days ago
  maziggy d64c0dcd08 Merge remote-tracking branch 'origin/main' into 0.2.4.3 3 days ago
  maziggy 07ea69c3f0 test(docker): include gcode_viewer/ in the test image so the packaging assertion actually runs 3 days ago
  maziggy f513c2c1db Bumped version 3 days ago
  maziggy a64df5a922 Updated CHANGELOG 3 days ago
  maziggy b9d51ffd80 chore(deps): floor-pin starlette>=1.0.1 against PYSEC-2026-161 3 days ago
  maziggy 3b9633a178 ● feat(support): include sanitized connection / VP / log-health diagnostics in support bundle and bug report (#1506 follow-up) 3 days ago
  maziggy cc468ddd42 Updated BACKERS.md 3 days ago
  maziggy 17602774f3 Updated BACKERS.md 3 days ago
  maziggy 03896d1af0 fix(scheduler): use inventory weight for "Prefer Lowest Filament" sort (#1508) 3 days ago
  maziggy eae96da56e fix(camera): probe ffmpeg for the right RTSP socket-timeout flag (#1504) 3 days ago
  maziggy 6591fc011f fix(slicer): filter process / filament presets by nozzle diameter too (#1325 follow-up #2) 4 days ago
  maziggy ed08ed3787 fix(timelapse): capture baseline on restart-recovery so post-reboot timelapses attach (follow up issue #1485) 4 days ago
  maziggy c0c07bc509 fix(archives): cross-model re-slice no longer carries source printer_id 4 days ago
  maziggy 5f473801f8 fix(file-manager): list-view actions clipped + preview modal slice button now respects Slicer API setting 4 days ago
  maziggy 3058c5789b fix(slice): @BBL name fallback for users without slicer bundles (#1325 follow-up) 4 days ago
  maziggy 4096d8d6bd fix(csp): nonce-based script-src so Cloudflare-injected scripts pass (#1460 follow-up) 4 days ago
  maziggy 4686d108ef feat(slice): cross-printer re-slicing across nozzle classes + multi-plate slice-all 4 days ago
  maziggy ebba1385d3 feat(spoolbuddy-settings): show CPU load on the device card 4 days ago
  maziggy 7ea4410b21 fix(queue): insufficient-filament warning now fires on every dispatch path (#1496) 4 days ago
  maziggy 32fcd85827 fix(library): "All Files" view now shows files inside subfolders (#1499) 4 days ago
  maziggy 6b87cdefd7 Updated BACKERS 4 days ago
  maziggy e222a0ef0e feat(system): log-health scanner + Add/Edit-Printer setup pre-flight 5 days ago
  maziggy ab8e07618f fix(inventory): archive filament colour follows the assigned spool, not the 3MF (#1494) 5 days ago
  maziggy 6d7a92c024 fix(camera): P2S RTSP stream dropped every frame after the first (#1395) 5 days ago
  maziggy 6bc6a1d683 feat(virtual-printer): setup diagnostic + one-click slicer-certificate export 5 days ago
  maziggy ed31b8f4a4 fix(ui): collapse bug-report connection diagnostic for multi-printer setups 5 days ago
  maziggy 4925b4c830 fix(slice): re-slice correctness — model label, honest errors, filament usage, nozzle guard 5 days ago
  maziggy 16da533c9a fix(static): serve /fonts/*.woff2 — self-hosted Inter font (#1460 follow-up) 5 days ago
  maziggy 71e58e6cf1 fix(library): show the filename, not the embedded 3MF Title (#1489) 5 days ago
  maziggy 056f06a396 fix(camera): capture ffmpeg stderr when an RTSP stream stalls (#1395) 5 days ago
  maziggy 51b0d28b05 fix(camera): show diagnostic in window-mode camera page (#1395) 5 days ago
  maziggy 774eba73c8 feat(diagnostics): event-loop stall watchdog to catch silent backend freezes 5 days ago
  maziggy 745ed847e6 fix(archive): stop duplicating the job on a backend restart mid-print (#1485) 5 days ago
  maziggy 3286ccd7d2 fix(inventory): send honest Bambuddy User-Agent on FilamentColors.xyz sync 5 days ago
  maziggy 50d1984820 fix(printer): stop the File Manager polling the printer over FTPS every 30s (#1480) 5 days ago
  maziggy e0247fc6a6 fix(slicer): filter process/filament presets by uploaded bundles, not preset names (#1325) 5 days ago
  maziggy 17e39921bb fix(pwa): add in-app install button and self-host the Inter font (#1460) 5 days ago
  maziggy 379b1c14bc fix(printer): Flow Calibration was silently skipped — wrong project_file fields (#1478) 6 days ago
  maziggy 7aad3fb395 fix(inventory): show group totals on collapsed grouped rows (issue #1368) 6 days ago
  maziggy e738645b0d feat(slicer): filter slice profiles by printer + default from the 3MF (issue #1325) 6 days ago
  maziggy 7eba29624b feat(slicer): filter process & filament profiles by selected printer (issue #1325) 6 days ago
  maziggy a51d59eabf feat(i18n): add Spanish (es) locale 6 days ago
  maziggy 76e327f4a1 feat: connection diagnostic for "printer won't connect" triage 6 days ago
  maziggy e1a236e408 fix(spoolman): decide spool assignability from the slot-assignment ledger, not extra.tag (#1122) 6 days ago
  maziggy b06f8f6951 fix(spool-assignments): union both assignment tables in the missing-spool check + symmetric mode-switch clear (#1473) 6 days ago
  maziggy 305529f483 fix(notifications): missing-spool-assignment check now unions both assignment tables (#1473) 6 days ago
  maziggy 016e01781a fix(profiles): keep the Local Profiles search bar mounted when nothing matches (#1470) 6 days ago
  maziggy 5b3962e3e6 fix(obico): Failure Detection status panel shows thresholds for the selected sensitivity (#1469) 6 days ago
  maziggy 01787eb1e6 fix(printers): normalize serial numbers + diagnose connect-but-no-reports (#1465) 6 days ago
  maziggy 76582298b5 fix(drying): stop false "drying complete" from killing the printer via smart-plug auto-off (#1462) 6 days ago
  maziggy 8cff425c8d fix(printers): AMS drying popover scrolls instead of clipping the Start button (#1458) 6 days ago
  maziggy 2b8887c4d5 chore(ci): also ignore disputed PyJWT CVE-2025-45768 in ci.yml 1 week ago
  maziggy 4910a0fb86 Updated .gitignore 1 week ago
  maziggy 39a2a79524 This reverts commit 929aae7202f20b592ce96a7bac18d229bc432aca. 1 week ago
  maziggy 929aae7202 Added scripts/pip-audit.sh 1 week ago
  maziggy 9d440beb80 chore(security): bump idna >=3.15 (CVE-2026-45409) + ignore disputed PyJWT advisory 1 week ago
  maziggy ed27b27adb feat(slice): cross-printer re-slicing — drop the gate, the banner, and the dead plumbing 1 week ago
  maziggy 787ce9e146 Updated BACKERS.md 1 week ago
  maziggy fd620df3d6 feat(currency): add Belize Dollars (BZD) to currency dropdown (#1454) 1 week ago
  Seb 14919a80a5 Merge pull request #1440 from Person2099/fix/filament-override-ams-mapping-dispatch 1 week ago
  maziggy d3f0e9ac73 fix(spoolman): per-print weight tracker falls back to local slot-assignment table for tag-less spools (#1459) 1 week ago
  maziggy 12b0c138f7 fix(spoolman): clear stale fallback-tag links on assign + link, prefer slot-assignment over tag-link in UI (#1457) 1 week ago
  maziggy fbaf219094 Fix: AMS drying popover positioning + diagnostic logging (#1447) 1 week ago
  maziggy 0f68039416 Fix: AMS drying popover no longer renders off the bottom of the viewport (#1447 part 1) 1 week ago
  maziggy fcee1a6f7e Fix: Print Activity heatmap buckets by local date, not UTC date (#1446) 1 week ago
  maziggy 0406487eb3 Fix: Add Printer no longer hangs the container on P1S (#1445) 1 week ago
  maziggy badf0bed04 Fix: Failure Analysis widget honours edited failure_reason / status (#1444) 1 week ago
  maziggy a4e8ae3ab4 Fix: SpoolBuddy Write-Tag page honours Spoolman mode + complete ID surface (#1439) 1 week ago
  maziggy 6f050708da Fix: cap TLS to v1.2 for P2S FTPS to dodge vsFTPd session-reuse bug (#1401) 1 week ago
  maziggy 235a189e31 Fix: 3D preview no longer freezes the page on complex multi-part 3MFs (#1412) 1 week ago
  maziggy bfd3fc755d Fix: capture timelapse baseline on expected-archive on_print_start branch (#1403 follow-up) 1 week ago
  maziggy 74f759468c Bumped version 1 week ago
  maziggy d6d3fa2f99 chore(security): nosec false-positive Bandit findings in tests 1 week ago
  MartinNYHC 12a352e5b8 Merge branch 'main' into dev 1 week ago
100 changed files with 9598 additions and 529 deletions
  1. 71 17
      .github/workflows/ci.yml
  2. 2 0
      BACKERS.md
  3. 3 1
      CHANGELOG.md
  4. 27 4
      Dockerfile.test
  5. 2 0
      README.md
  6. 0 0
      backend/=0.9.0
  7. 40 11
      backend/app/api/routes/archives.py
  8. 42 13
      backend/app/api/routes/camera.py
  9. 7 1
      backend/app/api/routes/inventory.py
  10. 3 3
      backend/app/api/routes/kprofiles.py
  11. 421 52
      backend/app/api/routes/library.py
  12. 1 1
      backend/app/api/routes/obico.py
  13. 31 4
      backend/app/api/routes/print_queue.py
  14. 34 0
      backend/app/api/routes/printers.py
  15. 10 0
      backend/app/api/routes/settings.py
  16. 43 5
      backend/app/api/routes/slicer_presets.py
  17. 42 19
      backend/app/api/routes/spoolman.py
  18. 97 0
      backend/app/api/routes/spoolman_inventory.py
  19. 26 194
      backend/app/api/routes/support.py
  20. 16 0
      backend/app/api/routes/system.py
  21. 44 0
      backend/app/api/routes/virtual_printers.py
  22. 1 1
      backend/app/core/config.py
  23. 46 0
      backend/app/core/database.py
  24. 159 25
      backend/app/main.py
  25. 7 0
      backend/app/models/print_queue.py
  26. 5 0
      backend/app/schemas/print_queue.py
  27. 55 1
      backend/app/schemas/printer.py
  28. 8 2
      backend/app/schemas/slicer.py
  29. 10 0
      backend/app/schemas/slicer_presets.py
  30. 23 0
      backend/app/schemas/virtual_printer.py
  31. 26 0
      backend/app/services/archive.py
  32. 15 3
      backend/app/services/bambu_ftp.py
  33. 137 43
      backend/app/services/bambu_mqtt.py
  34. 67 0
      backend/app/services/camera.py
  35. 15 4
      backend/app/services/camera_profiles.py
  36. 207 0
      backend/app/services/diagnostic_snapshot.py
  37. 5 1
      backend/app/services/external_camera.py
  38. 287 0
      backend/app/services/filament_deficit.py
  39. 19 1
      backend/app/services/filament_requirements.py
  40. 99 0
      backend/app/services/ftp_profiles.py
  41. 256 0
      backend/app/services/log_health.py
  42. 213 0
      backend/app/services/log_reader.py
  43. 77 0
      backend/app/services/loop_watchdog.py
  44. 6 2
      backend/app/services/obico_detection.py
  45. 273 5
      backend/app/services/print_scheduler.py
  46. 201 0
      backend/app/services/printer_diagnostic.py
  47. 40 3
      backend/app/services/printer_manager.py
  48. 296 0
      backend/app/services/slicer_3mf_convert.py
  49. 20 0
      backend/app/services/slicer_api.py
  50. 17 5
      backend/app/services/spool_assignment_notifications.py
  51. 167 12
      backend/app/services/spoolman_tracking.py
  52. 6 1
      backend/app/services/stl_thumbnail.py
  53. 81 0
      backend/app/services/usage_tracker.py
  54. 22 0
      backend/app/services/virtual_printer/certificate.py
  55. 170 0
      backend/app/services/virtual_printer/diagnostic.py
  56. 12 0
      backend/app/services/virtual_printer/manager.py
  57. 33 0
      backend/app/utils/printer_models.py
  58. 39 32
      backend/app/utils/threemf_tools.py
  59. 96 0
      backend/tests/integration/test_archives_api.py
  60. 946 0
      backend/tests/integration/test_library_slice_api.py
  61. 87 0
      backend/tests/integration/test_print_queue_api.py
  62. 75 0
      backend/tests/integration/test_security_headers.py
  63. 27 15
      backend/tests/integration/test_spoolman_api.py
  64. 3 0
      backend/tests/integration/test_spoolman_slot_assignment_mqtt.py
  65. 34 0
      backend/tests/integration/test_spoolman_slot_assignments.py
  66. 3 0
      backend/tests/integration/test_spoolman_slot_concurrency.py
  67. 182 0
      backend/tests/integration/test_spoolman_tracking_slot_fallback.py
  68. 10 10
      backend/tests/integration/test_support_api.py
  69. 45 0
      backend/tests/integration/test_system_api.py
  70. 55 0
      backend/tests/integration/test_virtual_printer_api.py
  71. 86 0
      backend/tests/unit/services/test_archive_service.py
  72. 304 28
      backend/tests/unit/services/test_bambu_mqtt.py
  73. 18 0
      backend/tests/unit/services/test_camera_profiles.py
  74. 267 0
      backend/tests/unit/services/test_filament_deficit.py
  75. 48 0
      backend/tests/unit/services/test_filament_requirements.py
  76. 93 0
      backend/tests/unit/services/test_ftp_profiles.py
  77. 169 0
      backend/tests/unit/services/test_log_health.py
  78. 72 0
      backend/tests/unit/services/test_loop_watchdog.py
  79. 178 0
      backend/tests/unit/services/test_printer_diagnostic.py
  80. 115 1
      backend/tests/unit/services/test_printer_manager.py
  81. 328 0
      backend/tests/unit/services/test_slicer_3mf_convert.py
  82. 84 0
      backend/tests/unit/services/test_slicer_api.py
  83. 97 3
      backend/tests/unit/services/test_spool_assignment_notifications.py
  84. 67 0
      backend/tests/unit/services/test_spoolman_tracking.py
  85. 195 1
      backend/tests/unit/services/test_usage_tracker.py
  86. 167 0
      backend/tests/unit/services/test_vp_diagnostic.py
  87. 55 1
      backend/tests/unit/test_camera_stderr_summary.py
  88. 19 4
      backend/tests/unit/test_code_quality.py
  89. 291 0
      backend/tests/unit/test_diagnostic_snapshot.py
  90. 139 0
      backend/tests/unit/test_ffmpeg_rtsp_timeout_flag.py
  91. 95 0
      backend/tests/unit/test_library_print_name.py
  92. 16 0
      backend/tests/unit/test_obico_detection.py
  93. 107 0
      backend/tests/unit/test_print_start_assigns_printer_id_to_vp_archive.py
  94. 26 0
      backend/tests/unit/test_printer_models.py
  95. 42 0
      backend/tests/unit/test_printer_schema.py
  96. 264 0
      backend/tests/unit/test_scheduler_ams_mapping.py
  97. 119 0
      backend/tests/unit/test_scheduler_filament_deficit.py
  98. 243 0
      backend/tests/unit/test_scheduler_force_color_ams_fallback.py
  99. 194 0
      backend/tests/unit/test_scheduler_inventory_remain.py
  100. 55 0
      backend/tests/unit/test_slicer_presets.py

+ 71 - 17
.github/workflows/ci.yml

@@ -84,10 +84,17 @@ jobs:
             --ignore-vuln CVE-2025-45768
 
   backend-tests:
-    name: Backend Tests
+    name: Backend Tests (shard ${{ matrix.shard }}/4)
     runs-on: ubuntu-latest
     if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
     needs: backend-lint
+    strategy:
+      # Don't cancel sibling shards if one fails — we want every shard's
+      # failure list, not just the first one, so a single PR push shows
+      # all broken tests in one go.
+      fail-fast: false
+      matrix:
+        shard: [1, 2, 3, 4]
     steps:
       - uses: actions/checkout@v4
 
@@ -110,11 +117,22 @@ jobs:
           pip install -r requirements.txt
           pip install -r requirements-dev.txt
 
-      - name: Run tests
+      - name: Run tests (shard ${{ matrix.shard }}/4)
         timeout-minutes: 10
         run: |
           cd backend
-          python -m pytest tests/ -v --tb=short --timeout=60 --timeout-method=thread -n auto
+          # -v dropped: 5300+ "PASSED foo::bar" lines per worker eat 30-60s
+          # of stdout I/O time on 2-vCPU runners. --tb=short is enough.
+          # --splits 4 --group N uses pytest-split to slice the collected
+          # test set roughly evenly across the 4 matrix shards; first run
+          # is name-hash-based, subsequent runs improve via .test_durations
+          # if you ever commit one (we don't — even the naive hash split
+          # gets us ≈25% per shard given the test mix here).
+          python -m pytest tests/ \
+            --tb=short \
+            --timeout=60 --timeout-method=thread \
+            -n auto \
+            --splits 4 --group ${{ matrix.shard }}
 
   # ============================================================================
   # Frontend Checks
@@ -261,6 +279,56 @@ jobs:
   # Docker Tests (matches test_docker.sh)
   # ============================================================================
 
+  # Run the FULL backend test suite inside the test image, sharded 4-way
+  # so wall-clock matches the host-side backend-tests job. Catches the
+  # rare-but-real cases where a test passes on the GHA host but fails in
+  # the python:3.13-slim test image (system-binary version differences,
+  # locale/timezone, container vs host user, cwd assumptions). Without
+  # sharding this was a 5-10 min single-runner job; with sharding it's
+  # ~120-150s per shard running in parallel, gated by max(shard).
+  docker-backend-tests:
+    name: Docker Backend Tests (shard ${{ matrix.shard }}/4)
+    runs-on: ubuntu-latest
+    if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
+    timeout-minutes: 15
+    strategy:
+      fail-fast: false
+      matrix:
+        shard: [1, 2, 3, 4]
+    steps:
+      - uses: actions/checkout@v4
+
+      - name: Set up Docker Buildx
+        uses: docker/setup-buildx-action@v3
+
+      # Build the backend-test image with GHA BuildKit cache backend so
+      # the pip-install layer is shared across the 4 matrix shards AND
+      # across CI runs. First run on a given requirements.txt is cold
+      # (~60-90s); subsequent runs are ~5-10s.
+      - name: Build backend test image (cached)
+        uses: docker/build-push-action@v5
+        with:
+          context: .
+          file: Dockerfile.test
+          target: backend-test
+          load: true
+          tags: bambuddy-backend-test:latest
+          cache-from: type=gha,scope=backend-test
+          cache-to: type=gha,scope=backend-test,mode=max
+
+      - name: Run backend tests in Docker (shard ${{ matrix.shard }}/4)
+        run: |
+          docker run --rm \
+            -e TESTING=1 \
+            -e PYTHONUNBUFFERED=1 \
+            bambuddy-backend-test:latest \
+            pytest backend/tests/ \
+              --tb=short \
+              --timeout=60 --timeout-method=thread \
+              -p no:cacheprovider \
+              -n auto \
+              --splits 4 --group ${{ matrix.shard }}
+
   docker-test:
     name: Docker Build
     runs-on: ubuntu-latest
@@ -280,20 +348,6 @@ jobs:
       - name: Verify static files exist
         run: docker run --rm bambuddy:test test -d /app/static
 
-      # Test 2: Backend Unit Tests in Docker
-      - name: Build backend test image
-        run: docker compose -f docker-compose.test.yml build backend-test
-
-      - name: Run backend tests in Docker
-        run: docker compose -f docker-compose.test.yml run --rm backend-test
-
-      # Test 3: Frontend Unit Tests in Docker
-      - name: Build frontend test image
-        run: docker compose -f docker-compose.test.yml build frontend-test
-
-      - name: Run frontend tests in Docker
-        run: docker compose -f docker-compose.test.yml run --rm frontend-test
-
       # Test 4: Integration Tests
       - name: Build integration container
         run: docker compose -f docker-compose.test.yml build integration

+ 2 - 0
BACKERS.md

@@ -38,6 +38,8 @@ If you sponsor and your name isn't here within 48h, please write an email to mar
 - [@grizz0blaw](https://github.com/grizz0blaw)
 - [@NoahTingey](https://github.com/NoahTingey)
 - [@sentinel-center](https://github.com/sentinel-center)
+- [@brianehlert](https://github.com/brianehlert)
+- [@siiruup](https://github.com/siiruup)
 ---
 
 ## One-time and historical supporters

File diff suppressed because it is too large
+ 3 - 1
CHANGELOG.md


+ 27 - 4
Dockerfile.test

@@ -1,4 +1,5 @@
 # Test image for running backend and frontend tests
+# syntax=docker/dockerfile:1.7
 FROM python:3.13-slim AS backend-test
 
 WORKDIR /app
@@ -8,15 +9,26 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
     curl \
     && rm -rf /var/lib/apt/lists/*
 
-# Install Python dependencies including test dependencies
+# Install Python dependencies including test dependencies.
+# BuildKit cache mount makes subsequent builds re-use the pip download
+# cache so the second build only does install work — the slow ~60-90s
+# fetch step from the first build becomes ~5s. Requires DOCKER_BUILDKIT=1
+# (already set in test_docker.sh).
 COPY requirements.txt ./
 COPY requirements-dev.txt ./
-RUN pip install --no-cache-dir -r requirements.txt -r requirements-dev.txt
+RUN --mount=type=cache,target=/root/.cache/pip \
+    pip install -r requirements.txt -r requirements-dev.txt
 
 # Copy backend code
 COPY backend/ ./backend/
 COPY pyproject.toml ./
 
+# Embedded GCode viewer assets — required so the @app.get("/gcode-viewer/...")
+# packaging-regression test in tests/integration/test_gcode_viewer.py actually
+# runs instead of pytest-skipping with "index.html not present". Path matches
+# the production Dockerfile (static_dir.parent / "gcode_viewer" = /app/gcode_viewer/).
+COPY gcode_viewer/ ./gcode_viewer/
+
 # Create necessary directories
 RUN mkdir -p /app/data /app/logs /app/archive
 
@@ -25,8 +37,19 @@ ENV PYTHONUNBUFFERED=1
 ENV DATA_DIR=/app/data
 ENV TESTING=1
 
-# Default command runs pytest (excluding docker integration tests)
-CMD ["pytest", "backend/tests/", "-v", "--tb=short", "-p", "no:cacheprovider", "-n", "30"]
+# Test image runs pytest and exits — there is no long-running service
+# to probe. HEALTHCHECK NONE is the documented Docker opt-out and
+# silences Trivy DS-0026 without adding meaningless probe logic.
+HEALTHCHECK NONE
+
+# Default command runs pytest (excluding docker integration tests).
+# -v dropped: 5300+ "PASSED foo::bar" lines per worker eat noticeable
+# stdout I/O time and clutter test_docker.sh output. --tb=short still
+# gives full failure tracebacks when something breaks.
+# -n auto adapts to the host's vCPU count instead of hard-coding 30 —
+# on a 2-vCPU CI / VM runner, -n 30 spawns 30 Python processes fighting
+# for 2 cores, which is mostly IPC + import-thrash overhead.
+CMD ["pytest", "backend/tests/", "--tb=short", "-p", "no:cacheprovider", "-n", "auto"]
 
 # -------------------------------------------
 # Frontend test stage

+ 2 - 0
README.md

@@ -94,6 +94,8 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - 🍰 **One-click slicing** — Slice from any browser. The job runs server-side in a [tiny sidecar container](slicer-api/README.md), progress streams back as a toast, and the sliced file appears in your library when it's done.
 - 📱 **Slice from your phone or tablet** — Bambuddy's PWA + the new server-side slicer means you can drop an STL in from mobile and queue a print without ever touching a desktop.
 - 🎒 **Bring your own profiles** — Import a `Printer Preset Bundle` (`.bbscfg`) exported from Bambu Studio: pick a curated **printer + process + filament** triplet from a dropdown in the Slice dialog, no more juggling JSON files.
+- 🔄 **Re-slice for a different printer in one click** — Open any sliced archive in Bambuddy and re-slice it for any printer, including across the single-nozzle ↔ dual-nozzle (H2D / H2D Pro) boundary that BambuStudio's CLI would normally reject. Bambuddy detects the class change and auto-arranges objects laid out for the source bed (e.g. X1C 256×256) so they land safely on the target (e.g. H2D 350×320 with its per-nozzle dead zones).
+- 🍱 **Slice all plates at once** — Multi-plate projects (parted statues, multi-part kits) get a "Slice all N plates" toggle in the Slice dialog. One click produces a single `.gcode.3mf` containing every plate's gcode, ready for the printer. The toast shows "Plate 2 of 5 — Generating G-code (47%)" as the loop runs.
 - 🔁 **Same dispatch as the rest of Bambuddy** — The sliced output flows into the existing queue / plate-picker / AMS-mapping path, so all the regular conveniences (multi-printer dispatch, AMS routing, scheduled prints) just work.
 
 Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/README.md) next to your Bambuddy install and the **Slice** button lights up everywhere.

+ 0 - 0
backend/=0.9.0


+ 40 - 11
backend/app/api/routes/archives.py

@@ -31,9 +31,9 @@ from backend.app.schemas.slicer import SliceRequest
 from backend.app.services.archive import ArchiveService
 from backend.app.utils.http import build_content_disposition
 from backend.app.utils.threemf_tools import (
+    extract_embedded_presets_from_3mf,
     extract_nozzle_mapping_from_3mf,
     extract_project_filaments_from_3mf,
-    extract_source_printer_model_from_3mf,
 )
 
 logger = logging.getLogger(__name__)
@@ -1343,9 +1343,35 @@ async def update_archive(
         if archive.created_by_id != user.id:
             raise HTTPException(403, "You can only update your own archives")
 
-    for field, value in update_data.model_dump(exclude_unset=True).items():
+    update_payload = update_data.model_dump(exclude_unset=True)
+    for field, value in update_payload.items():
         setattr(archive, field, value)
 
+    # #1444: Mirror per-run classification fields to the most recent
+    # PrintLogEntry for this archive. PrintLogEntry.failure_reason is captured
+    # once at print-completion time from archive.failure_reason — which is
+    # NULL until the user classifies the failure via the Edit Archive modal.
+    # Without this mirror the Failure Analysis widget (which groups by
+    # print_log_entries.failure_reason) keeps showing "Unknown" forever.
+    # Same desync hits status: flipping it in the modal wouldn't update the
+    # entry either. Only the latest entry is touched because that's the run
+    # the modal is implicitly showing (archive.failure_reason / status are
+    # overwritten on each reprint to reflect the latest run's outcome).
+    mirror_fields = {"failure_reason", "status"}
+    to_mirror = {k: v for k, v in update_payload.items() if k in mirror_fields}
+    if to_mirror:
+        from backend.app.models.print_log import PrintLogEntry
+
+        latest_entry = await db.scalar(
+            select(PrintLogEntry)
+            .where(PrintLogEntry.archive_id == archive_id)
+            .order_by(PrintLogEntry.id.desc())
+            .limit(1)
+        )
+        if latest_entry is not None:
+            for field, value in to_mirror.items():
+                setattr(latest_entry, field, value)
+
     await db.commit()
 
     # Re-fetch with relationships loaded after commit
@@ -3045,10 +3071,14 @@ async def get_archive_plates(
     # never raises NameError when the archive isn't a valid zip (e.g. plain
     # .gcode file from a sliced-archive flow that didn't request 3MF output).
     gcode_files: list[str] = []
+    # Printer / process preset names the 3MF was prepared with — used by the
+    # SliceModal to default its dropdowns (#1325).
+    embedded_presets: dict[str, str | None] = {"printer": None, "process": None}
 
     try:
         with zipfile.ZipFile(file_path, "r") as zf:
             namelist = zf.namelist()
+            embedded_presets = extract_embedded_presets_from_3mf(zf)
 
             # Find all plate gcode files to determine available plates
             gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
@@ -3290,20 +3320,14 @@ async def get_archive_plates(
     # to preview gcode — the viewer, skip-objects — can gate on this instead of
     # 404-ing on every plate request.
     has_gcode = bool(gcode_files)
-    # SliceModal pre-check signal — see library.py for rationale.
-    source_printer_model: str | None = None
-    try:
-        with zipfile.ZipFile(file_path, "r") as zf:
-            source_printer_model = extract_source_printer_model_from_3mf(zf)
-    except (zipfile.BadZipFile, OSError):
-        pass
     return {
         "archive_id": archive_id,
         "filename": archive.filename,
         "plates": plates,
         "is_multi_plate": len(plates) > 1,
         "has_gcode": has_gcode,
-        "source_printer_model": source_printer_model,
+        "embedded_printer": embedded_presets["printer"],
+        "embedded_process": embedded_presets["process"],
     }
 
 
@@ -3583,7 +3607,7 @@ async def slice_archive(
     user originally sent to slice) → ``file_path`` (the sliced 3MF/gcode that
     actually printed).
     """
-    from backend.app.api.routes.library import slice_and_persist_as_archive
+    from backend.app.api.routes.library import guard_nozzle_class_reslice, slice_and_persist_as_archive
     from backend.app.core.database import async_session
     from backend.app.services.slice_dispatch import (
         http_exception_to_job_error,
@@ -3630,6 +3654,11 @@ async def slice_archive(
     archive_id_local = archive.id
     user_id = current_user.id if current_user else None
 
+    # Block a cross-nozzle-class re-slice (single-nozzle <-> H2D) up front —
+    # BambuStudio's multi-extruder validator would otherwise reject it with a
+    # cryptic error. No-op for same-class or un-sliced sources.
+    await guard_nozzle_class_reslice(db, current_user, request, archive.sliced_for_model)
+
     async def _run(job_id: int):
         async with async_session() as task_db:
             # Re-fetch the source archive on the background-task session.

+ 42 - 13
backend/app/api/routes/camera.py

@@ -29,6 +29,7 @@ from backend.app.services.camera import (
     get_ffmpeg_path,
     is_chamber_image_model,
     read_next_chamber_frame,
+    rtsp_socket_timeout_flag,
     test_camera_connection,
 )
 from backend.app.services.camera_fanout import (
@@ -276,20 +277,35 @@ def _summarize_ffmpeg_stderr(text: str | None) -> str:
 
 
 async def _read_ffmpeg_stderr(process: asyncio.subprocess.Process) -> str | None:
-    """Read ffmpeg stderr for diagnostics (best-effort, non-blocking).
-
-    Returns the stderr content with ffmpeg's boilerplate banner stripped,
-    so log output stays focused on the actual error.
+    """Read whatever ffmpeg has written to stderr so far (best-effort).
+
+    ffmpeg's stderr must be drained *incrementally*. A stalled-but-still-alive
+    ffmpeg — the typical P2S RTSP failure, where it connects but never produces
+    a frame — never closes stderr, so a plain ``stderr.read()`` (read-to-EOF)
+    blocks until the wait_for timeout and returns nothing, discarding the
+    banner + stream-analysis lines ffmpeg already printed. Reading in bounded
+    chunks returns the buffered output promptly whether or not ffmpeg has
+    exited. Returns the content with ffmpeg's boilerplate banner stripped.
     """
     if not process or not process.stderr:
         return None
+    chunks: list[bytes] = []
+    total = 0
+    cap = 65536
     try:
-        data = await asyncio.wait_for(process.stderr.read(), timeout=2.0)
-        if not data:
-            return None
-        return _summarize_ffmpeg_stderr(data.decode(errors="replace")) or None
-    except (TimeoutError, Exception):
+        while total < cap:
+            chunk = await asyncio.wait_for(process.stderr.read(8192), timeout=2.0)
+            if not chunk:
+                break  # EOF — ffmpeg has exited
+            chunks.append(chunk)
+            total += len(chunk)
+    except Exception:
+        # Timed out waiting for more data — ffmpeg is alive but quiet now.
+        # Fall through and return whatever it already printed.
+        pass
+    if not chunks:
         return None
+    return _summarize_ffmpeg_stderr(b"".join(chunks).decode(errors="replace")) or None
 
 
 async def generate_rtsp_mjpeg_stream(
@@ -333,8 +349,11 @@ async def generate_rtsp_mjpeg_stream(
         "tcp",
         "-rtsp_flags",
         "prefer_tcp",
-        "-timeout",
-        "30000000",  # 30 seconds in microseconds
+        # Socket I/O timeout name varies by ffmpeg version (#1504); see
+        # rtsp_socket_timeout_flag(). The 30s value is microseconds for
+        # both names.
+        f"-{rtsp_socket_timeout_flag()}",
+        "30000000",
         "-buffer_size",
         "1024000",  # 1MB buffer
         "-max_delay",
@@ -365,9 +384,19 @@ async def generate_rtsp_mjpeg_stream(
         _disconnect_events[stream_id] = disconnect_event
 
     logger.info(
-        "Starting RTSP camera stream for %s (stream_id=%s, model=%s, fps=%s)", ip_address, stream_id, model, fps
+        "Starting RTSP camera stream for %s (stream_id=%s, model=%s, fps=%s, probesize=%s, analyzeduration=%s)",
+        ip_address,
+        stream_id,
+        model,
+        fps,
+        profile.probesize,
+        profile.analyzeduration,
     )
-    logger.debug("ffmpeg command: %s ... (url hidden)", ffmpeg)
+    # Log the full argv so a support bundle shows the actual ffmpeg flags
+    # (probesize, analyzeduration, transport, ...). Only camera_url carries a
+    # secret (the access code), so redact just that one element.
+    _redacted_cmd = ["rtsp://<redacted>/streaming/live/1" if a == camera_url else a for a in cmd]
+    logger.debug("ffmpeg command: %s", " ".join(_redacted_cmd))
 
     # On Windows, spawn ffmpeg in its own process group so that
     # terminate() doesn't broadcast CTRL_C_EVENT to uvicorn (#605).

+ 7 - 1
backend/app/api/routes/inventory.py

@@ -822,7 +822,13 @@ async def sync_from_filamentcolors(
         total_available = 0
 
         try:
-            async with httpx.AsyncClient(timeout=120.0) as client:
+            # Identify honestly as Bambuddy rather than leaking httpx's
+            # default "python-httpx/x.y" UA — consistent with every other
+            # outbound client (bambu_cloud, makerworld, firmware_check).
+            async with httpx.AsyncClient(
+                timeout=120.0,
+                headers={"User-Agent": "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)"},
+            ) as client:
                 page = 1
                 while True:
                     response = await client.get(

+ 3 - 3
backend/app/api/routes/kprofiles.py

@@ -117,9 +117,9 @@ async def set_kprofile(
     # device.extruder.info beats serial-prefix heuristics — H2S shares prefix
     # "094" with H2D but is single-nozzle (#1386). Model name is the fallback
     # for the brief window after connect before push data arrives.
-    is_dual_nozzle = client._is_dual_nozzle or (
-        printer.model and printer.model.upper().strip() in ("H2D", "H2D PRO", "H2DPRO", "H2C", "X2D")
-    )
+    from backend.app.utils.printer_models import is_dual_nozzle_model
+
+    is_dual_nozzle = client._is_dual_nozzle or is_dual_nozzle_model(printer.model)
 
     if is_edit and is_dual_nozzle:
         # Dual-nozzle in-place edit: use cali_idx with slot_id=0 and empty setting_id

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

@@ -65,9 +65,9 @@ from backend.app.schemas.slicer import SliceRequest, SliceResponse
 from backend.app.services.archive import ThreeMFParser
 from backend.app.services.stl_thumbnail import generate_stl_thumbnail
 from backend.app.utils.threemf_tools import (
+    extract_embedded_presets_from_3mf,
     extract_nozzle_mapping_from_3mf,
     extract_project_filaments_from_3mf,
-    extract_source_printer_model_from_3mf,
 )
 
 logger = logging.getLogger(__name__)
@@ -364,6 +364,40 @@ def _clean_3mf_metadata(obj):
     return obj
 
 
+def _read_3mf_entry(zip_path: Path, entry: str) -> bytes | None:
+    """Return the raw bytes of an entry inside a 3MF (ZIP), or ``None`` when
+    the file isn't a parseable zip / doesn't contain that entry / any IO
+    error. Used to lift the source archive's per-plate render onto a
+    re-sliced archive (#1493 follow-up) — the slicer CLI often doesn't
+    emit a fresh ``Metadata/plate_N.png`` and the project-wide cover-art
+    fallback in :class:`ThreeMFParser` looks unrelated to the actual slice.
+    """
+    try:
+        with zipfile.ZipFile(zip_path, "r") as zf:
+            if entry not in zf.namelist():
+                return None
+            return zf.read(entry)
+    except (zipfile.BadZipFile, OSError, KeyError):
+        return None
+
+
+def _without_print_name(metadata: dict | None) -> dict | None:
+    """Drop the embedded 3MF Title (``print_name``) from library-file metadata.
+
+    The 3MF ``<metadata name="Title">`` holds the in-app project title — the
+    generic ``"Exported 3D Model"`` for a Bambu Studio "Save As", a marketing
+    title for a MakerWorld download — never the filename the user saved as.
+    The FileManager keys its display name, search and sort off ``print_name``,
+    so storing it makes every card show the wrong name (#1489). A library
+    file's display name is its filename; only ``PrintArchive`` carries a real
+    ``print_name``. Returns the input unchanged when there's nothing to strip;
+    otherwise a new dict (never mutates the argument).
+    """
+    if not metadata or "print_name" not in metadata:
+        return metadata
+    return {k: v for k, v in metadata.items() if k != "print_name"}
+
+
 async def save_3mf_bytes_to_library(
     db: AsyncSession,
     *,
@@ -435,7 +469,7 @@ async def save_3mf_bytes_to_library(
         file_size=len(file_bytes),
         file_hash=file_hash,
         thumbnail_path=to_relative_path(thumbnail_path) if thumbnail_path else None,
-        file_metadata=metadata,
+        file_metadata=_without_print_name(metadata),
         source_type=source_type,
         source_url=source_url,
         created_by_id=owner_id,
@@ -1378,7 +1412,7 @@ async def scan_external_folder(
                 file_size=stat.st_size,
                 file_hash=None,  # Skip hashing external files for performance
                 thumbnail_path=thumbnail_path,
-                file_metadata=file_metadata,
+                file_metadata=_without_print_name(file_metadata),
             )
             db.add(db_file)
             added += 1
@@ -1655,7 +1689,7 @@ async def upload_file(
             file_size=len(content),
             file_hash=file_hash,
             thumbnail_path=to_relative_path(thumbnail_path) if thumbnail_path else None,
-            file_metadata=metadata if metadata else None,
+            file_metadata=_without_print_name(metadata) if metadata else None,
             created_by_id=current_user.id if current_user else None,
         )
         db.add(library_file)
@@ -1908,7 +1942,7 @@ async def extract_zip_file(
                         file_size=len(file_content),
                         file_hash=file_hash,
                         thumbnail_path=to_relative_path(thumbnail_path) if thumbnail_path else None,
-                        file_metadata=metadata if metadata else None,
+                        file_metadata=_without_print_name(metadata) if metadata else None,
                         created_by_id=current_user.id if current_user else None,
                     )
                     db.add(library_file)
@@ -2194,10 +2228,15 @@ async def get_library_file_plates(
         return {"file_id": file_id, "filename": lib_file.filename, "plates": [], "is_multi_plate": False}
 
     plates = []
+    # Printer / process preset names the 3MF was prepared with — used by the
+    # SliceModal to default its dropdowns (#1325). Initialised here so the
+    # final return never raises NameError when the file isn't a valid zip.
+    embedded_presets: dict[str, str | None] = {"printer": None, "process": None}
 
     try:
         with zipfile.ZipFile(file_path, "r") as zf:
             namelist = zf.namelist()
+            embedded_presets = extract_embedded_presets_from_3mf(zf)
 
             # Find all plate gcode files to determine available plates
             gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
@@ -2419,22 +2458,13 @@ async def get_library_file_plates(
     except Exception as e:
         logger.warning("Failed to parse plates from library file %s: %s", file_id, e)
 
-    # SliceModal pre-check signal: the source 3MF's bound printer model. The
-    # CLI cannot re-slice for a different printer; surface this so the modal
-    # can warn the user before they pick a mismatched profile.
-    source_printer_model: str | None = None
-    try:
-        with zipfile.ZipFile(file_path, "r") as zf:
-            source_printer_model = extract_source_printer_model_from_3mf(zf)
-    except (zipfile.BadZipFile, OSError):
-        pass
-
     return {
         "file_id": file_id,
         "filename": lib_file.filename,
         "plates": plates,
         "is_multi_plate": len(plates) > 1,
-        "source_printer_model": source_printer_model,
+        "embedded_printer": embedded_presets["printer"],
+        "embedded_process": embedded_presets["process"],
     }
 
 
@@ -2886,6 +2916,34 @@ def _patch_process_bed_type(process_json: str, bed_type: str) -> str:
     return json.dumps(profile)
 
 
+# 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:"
+
+
+def _slicer_rejection_message(error_text: str) -> str | None:
+    """Extract the slicer's own rejection reason from a sidecar error string,
+    or ``None`` when the failure is not a slicer content rejection.
+
+    A content rejection means ``--load-settings`` *was* applied — the slicer
+    got far enough to evaluate the model against the chosen printer and say
+    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.
+    """
+    if _SLICER_REJECTION_MARKER not in error_text:
+        return None
+    reason = error_text.split(_SLICER_REJECTION_MARKER, 1)[1]
+    # 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
+
+
 async def _run_slicer_with_fallback(
     db: AsyncSession,
     *,
@@ -3009,6 +3067,39 @@ async def _run_slicer_with_fallback(
 
     used_embedded_settings = False
     service = SlicerApiService(api_url)
+
+    # #1493: cross-nozzle-class re-slice (single <-> dual). Without
+    # intervention the slicer rejects with either "G-code in unprintable
+    # area of multi-extruder printers" (the source's X1C-coordinate layout
+    # lands in the H2D's per-nozzle dead zone) or — worse — segfaults
+    # inside ZFiller's polygon clipping when the geometry pipeline trips
+    # on the cross-class transition. Forwarding the sidecar's --arrange
+    # flag for these cases lets BambuStudio reposition objects for the
+    # target bed and reconcile the embedded project_settings.config
+    # against the new printer, the same way the GUI's "Switch Printer"
+    # operation does. --arrange WILL reposition objects, so we only
+    # enable it on a true class crossing — same-printer slices keep the
+    # user's deliberate layout. The bed-type and arrange flags are
+    # orthogonal so this decision doesn't interact with the #1337 build-
+    # plate override.
+    cross_class_arrange = False
+    if is_3mf:
+        from backend.app.services.slicer_3mf_convert import (
+            extract_source_printer_model,
+        )
+        from backend.app.utils.printer_models import is_dual_nozzle_model
+
+        source_model = extract_source_printer_model(primary_bytes)
+        target_model = await _resolve_target_printer_model(db, user, request)
+        if source_model and target_model and is_dual_nozzle_model(source_model) != is_dual_nozzle_model(target_model):
+            logger.info(
+                "Cross-nozzle-class re-slice (%s -> %s, %s): enabling --arrange so BS reconciles "
+                "the embedded project layout against the target printer",
+                source_model,
+                target_model,
+                "bundle" if use_bundle else "presets",
+            )
+            cross_class_arrange = True
     # When this slice is dispatcher-tracked, generate a request_id so
     # the sidecar publishes progress under it, and wire a callback that
     # forwards each frame onto SliceDispatchService.set_progress for the
@@ -3026,9 +3117,142 @@ async def _run_slicer_with_fallback(
             _dispatch.set_progress(job_id, snapshot)
 
         progress_callback = _on_progress
+    # SliceModal lets the user pick a filament profile per slot, but each
+    # plate uses only a subset of the slots. The unused-slot dropdowns get
+    # whatever default the modal serves up — and a heterogeneous default
+    # (e.g. ABS in slot 2 next to a PLA in the used slot 1) makes
+    # BambuStudio reject the slice with "the temperature difference of
+    # the filaments used is too large" (exit 194) even though the G-code
+    # never touches the unused slot. Replace unused-slot entries with the
+    # slot-1 selection before the real slice so the loaded-filament set
+    # is materially homogeneous.
+    bundle_filament_names: list[str] | None = None
+    if is_3mf and request.plate is not None:
+        from backend.app.services.slicer_3mf_convert import substitute_unused_plate_filaments
+
+        if use_bundle:
+            assert request.bundle is not None
+            bundle_filament_names = substitute_unused_plate_filaments(
+                primary_bytes, request.plate, list(request.bundle.filament_names)
+            )
+        else:
+            filament_jsons = substitute_unused_plate_filaments(primary_bytes, request.plate, filament_jsons)
+
+    # Cross-class slice-all loop (#1493): when the user asks for
+    # ``plate=0`` (all plates) AND the source's nozzle class differs from
+    # the target's, ``--slice 0 --arrange 1`` consolidates every plate's
+    # objects onto a single target bed (BS's ``--arrange`` is project-
+    # wide) — either packing them all together or rejecting with "Some
+    # objects are located over the boundary of the heated bed" when
+    # nothing fits. Slice each plate independently with ``--arrange 1``
+    # and merge the per-plate outputs into one multi-plate 3MF instead.
+    # Same-class slice-all goes through the regular path below — the
+    # sidecar's native ``--slice 0`` produces the right shape directly.
+    use_cross_class_slice_all = cross_class_arrange and request.plate == 0 and request.export_3mf
+
     try:
         try:
-            if use_bundle:
+            if use_cross_class_slice_all:
+                from backend.app.services.slicer_3mf_convert import (
+                    count_plates_in_3mf,
+                    merge_plate_3mfs,
+                )
+
+                plate_count = count_plates_in_3mf(primary_bytes)
+                if plate_count == 0:
+                    raise HTTPException(
+                        status_code=400,
+                        detail=(
+                            "Couldn't read plate count from the source 3MF for cross-class "
+                            "slice-all. The source may be malformed or missing "
+                            "Metadata/model_settings.config."
+                        ),
+                    )
+                logger.info(
+                    "Cross-class slice-all: looping over %d plates with --arrange per plate, then merging",
+                    plate_count,
+                )
+                from backend.app.services.slicer_api import SliceResult
+
+                per_plate_results: list[tuple[int, SliceResult]] = []
+
+                # Forward the same progress request_id + callback to each
+                # per-plate sub-call so the toast keeps showing the
+                # sidecar's stage messages ("Generating G-code 45%…").
+                # The sub-calls run sequentially, so the poller for plate
+                # N is cancelled before plate N+1's poller starts — no
+                # cross-talk between plate streams. Wrap the callback to
+                # surface "(plate N/M)" alongside the slicer's stage
+                # message so the user sees progress through the whole
+                # multi-plate loop, not just one plate at a time.
+                def _wrap_progress_for_plate(plate_num: int, total: int):
+                    if progress_callback is None:
+                        return None
+
+                    def _cb(snapshot: dict) -> None:
+                        snapshot = dict(snapshot)
+                        snapshot["multi_plate_index"] = plate_num
+                        snapshot["multi_plate_count"] = total
+                        progress_callback(snapshot)
+
+                    return _cb
+
+                for plate_num in range(1, plate_count + 1):
+                    plate_cb = _wrap_progress_for_plate(plate_num, plate_count)
+                    if use_bundle:
+                        assert request.bundle is not None
+                        per_plate = await service.slice_with_bundle(
+                            model_bytes=primary_bytes,
+                            model_filename=model_filename,
+                            bundle_id=request.bundle.bundle_id,
+                            printer_name=request.bundle.printer_name,
+                            process_name=request.bundle.process_name,
+                            filament_names=(
+                                bundle_filament_names
+                                if bundle_filament_names is not None
+                                else request.bundle.filament_names
+                            ),
+                            plate=plate_num,
+                            export_3mf=True,
+                            arrange=True,
+                            bed_type=request.bed_type,
+                            request_id=progress_request_id,
+                            on_progress=plate_cb,
+                        )
+                    else:
+                        per_plate = await service.slice_with_profiles(
+                            model_bytes=primary_bytes,
+                            model_filename=model_filename,
+                            printer_profile_json=presets["printer"],
+                            process_profile_json=presets["process"],
+                            filament_profile_jsons=filament_jsons,
+                            plate=plate_num,
+                            export_3mf=True,
+                            arrange=True,
+                            request_id=progress_request_id,
+                            on_progress=plate_cb,
+                        )
+                    per_plate_results.append((plate_num, per_plate))
+
+                # Merge the N single-plate 3MFs into one multi-plate 3MF.
+                # ``primary_bytes`` is the source 3MF: it carries the
+                # original per-plate previews the slicer's --arrange
+                # pass doesn't regenerate, so the merger can fall back
+                # to those for each plate's cover image.
+                merged_bytes = merge_plate_3mfs(
+                    [(n, r.content) for n, r in per_plate_results],
+                    source_3mf_bytes=primary_bytes,
+                )
+                # Synthetic SliceResult: totals are the sum of each
+                # plate's so the archive card shows the project's print
+                # time and filament use, not just plate 1's.
+                result = SliceResult(
+                    content=merged_bytes,
+                    print_time_seconds=sum(r.print_time_seconds for _, r in per_plate_results),
+                    filament_used_g=sum(r.filament_used_g for _, r in per_plate_results),
+                    filament_used_mm=sum(r.filament_used_mm for _, r in per_plate_results),
+                )
+            elif use_bundle:
                 # Bundle dispatch: sidecar materialises the JSON triplet
                 # from the stored .bbscfg by name. ``request.bundle`` is
                 # guaranteed non-None here by the use_bundle branch above.
@@ -3039,9 +3263,12 @@ async def _run_slicer_with_fallback(
                     bundle_id=request.bundle.bundle_id,
                     printer_name=request.bundle.printer_name,
                     process_name=request.bundle.process_name,
-                    filament_names=request.bundle.filament_names,
+                    filament_names=bundle_filament_names
+                    if bundle_filament_names is not None
+                    else request.bundle.filament_names,
                     plate=request.plate,
                     export_3mf=request.export_3mf,
+                    arrange=cross_class_arrange,
                     bed_type=request.bed_type,
                     request_id=progress_request_id,
                     on_progress=progress_callback,
@@ -3055,14 +3282,24 @@ async def _run_slicer_with_fallback(
                     filament_profile_jsons=filament_jsons,
                     plate=request.plate,
                     export_3mf=request.export_3mf,
+                    arrange=cross_class_arrange,
                     request_id=progress_request_id,
                     on_progress=progress_callback,
                 )
         except SlicerApiServerError as exc:
+            rejection = _slicer_rejection_message(str(exc))
+            if rejection:
+                # The slicer ran and rejected the job for a content reason —
+                # the chosen printer/process/filament *were* applied. Falling
+                # back to embedded settings would silently re-slice for the
+                # source 3MF's original printer and hide the real problem
+                # (e.g. re-slicing an H2D model for an X1C: the object is off
+                # the smaller bed). Surface the slicer's reason instead.
+                raise HTTPException(status_code=400, detail=rejection) from exc
             if not is_3mf:
                 raise
             logger.warning(
-                "Slicer CLI rejected --load-settings for %s (%s); retrying with embedded settings",
+                "Slicer CLI failed on the --load-settings path for %s (%s); retrying with embedded settings",
                 model_filename,
                 exc,
             )
@@ -3098,6 +3335,73 @@ async def _run_slicer_with_fallback(
     return result, used_embedded_settings
 
 
+def _canonical_printer_model(raw: str | None) -> str | None:
+    """Normalise a printer-preset name / ``printer_model`` field to a canonical
+    model code. Strips the BambuStudio ``"# "`` user-clone prefix and the
+    ``" 0.4 nozzle"`` variant suffix that preset names carry but bare model
+    names don't — without this, ``"Bambu Lab H2D 0.4 nozzle"`` wouldn't
+    normalise to ``H2D``."""
+    import re
+
+    from backend.app.utils.printer_models import normalize_printer_model
+
+    if not raw:
+        return None
+    cleaned = str(raw).strip()
+    if cleaned.startswith("# "):
+        cleaned = cleaned[2:].strip()
+    cleaned = re.sub(r"\s+0\.\d+\s+nozzle$", "", cleaned, flags=re.IGNORECASE)
+    return normalize_printer_model(cleaned) if cleaned else None
+
+
+async def _resolve_target_printer_model(db: AsyncSession, user: User | None, request: SliceRequest) -> str | None:
+    """Best-effort: the printer model a slice request targets.
+
+    Returns ``None`` when it can't be determined (the nozzle-class guard
+    then simply doesn't fire — fail-open, never blocks a slice spuriously).
+    """
+    from backend.app.services.preset_resolver import resolve_preset_ref
+
+    if request.bundle is not None:
+        return _canonical_printer_model(request.bundle.printer_name)
+    if request.printer_preset is None:
+        return None
+    try:
+        printer_json = await resolve_preset_ref(db, user, request.printer_preset, "printer")
+        data = json.loads(printer_json)
+        if not isinstance(data, dict):
+            return None
+        return _canonical_printer_model(
+            data.get("printer_model") or data.get("printer_settings_id") or data.get("name")
+        )
+    except Exception:
+        return None
+
+
+async def guard_nozzle_class_reslice(
+    db: AsyncSession, user: User | None, request: SliceRequest, source_model: str | None
+) -> None:
+    """No-op guard, retained for call-site compatibility.
+
+    Cross-nozzle-class re-slicing is handled by ``_run_slicer_with_fallback``'s
+    two-pass conversion (#1493): a 1mm cube is sliced with the target triplet
+    (via either ``slice_with_profiles`` or ``slice_with_bundle``, whichever
+    dispatch mode the caller is using) to produce a fresh target-shaped
+    ``Metadata/project_settings.config``, which is then spliced into the
+    source 3MF before the real slice. So this guard never needs to block
+    anymore — both preset and bundle paths are covered.
+
+    The function and its call sites in ``archives.py`` / the library re-slice
+    route are kept so external pinned-version forks and downstream patches
+    don't break, but it does nothing on a successful slice path. If the
+    two-pass conversion fails inside the slicer, the existing
+    ``SlicerApiServerError`` / ``_slicer_rejection_message`` plumbing
+    surfaces the CLI's actual error to the user — which is more informative
+    than the old "isn't supported yet" 400 the guard used to raise.
+    """
+    return None
+
+
 async def slice_and_persist(
     db: AsyncSession,
     *,
@@ -3156,19 +3460,21 @@ async def slice_and_persist(
     except Exception as exc:
         logger.warning("Failed to parse sliced 3MF metadata for %s: %s", out_filename, exc)
 
-    # The parsed 3MF metadata carries a `print_name` lifted from the source
-    # file's embedded settings (BambuStudio always sets this; OrcaSlicer
-    # often leaves it blank). The FileManager listing prefers print_name
-    # over filename for display, which makes a sliced row indistinguishable
-    # from its source. Drop print_name so the listing falls back to the
-    # actual filename — which already ends in ".gcode.3mf" and self-describes
-    # as the sliced output.
-    metadata: dict = {k: v for k, v in parsed_metadata.items() if k != "print_name"}
+    # Drop the embedded `print_name` (see _without_print_name) so the sliced
+    # row's display falls back to its ".gcode.3mf" filename instead of the
+    # source file's project title, which would make the two indistinguishable.
+    metadata: dict = dict(_without_print_name(parsed_metadata) or {})
+    # Some slicer-sidecar builds leave the X-Filament-Used-* response headers
+    # unset, so result.filament_used_g/_mm arrive as 0 even for a real
+    # multi-hour print. Fall back to the totals ThreeMFParser read from the
+    # produced 3MF's own G-code header.
+    filament_g = result.filament_used_g or parsed_metadata.get("filament_used_grams") or 0.0
+    filament_mm = result.filament_used_mm or parsed_metadata.get("filament_used_mm") or 0.0
     metadata.update(
         {
             "print_time_seconds": result.print_time_seconds,
-            "filament_used_g": result.filament_used_g,
-            "filament_used_mm": result.filament_used_mm,
+            "filament_used_g": filament_g,
+            "filament_used_mm": filament_mm,
         }
     )
     if used_embedded_settings:
@@ -3202,8 +3508,8 @@ async def slice_and_persist(
         library_file_id=new_file.id,
         name=new_file.filename,
         print_time_seconds=result.print_time_seconds,
-        filament_used_g=result.filament_used_g,
-        filament_used_mm=result.filament_used_mm,
+        filament_used_g=filament_g,
+        filament_used_mm=filament_mm,
         used_embedded_settings=used_embedded_settings,
     )
 
@@ -3252,33 +3558,59 @@ async def slice_and_persist_as_archive(
     out_path = archive_dir / out_filename
     out_path.write_bytes(result.content)
 
-    # Extract a thumbnail from the produced 3MF so the new archive card has
-    # a preview. The 3MF parser pulls Metadata/plate_*.png; failures here
-    # shouldn't fail the whole slice — the archive row is still useful
-    # without a thumbnail.
+    # Extract a thumbnail for the new archive card. Priority order:
+    #   1. Source archive's ``Metadata/plate_{N}.png`` — the GUI-rendered
+    #      preview of the same plate the user is re-slicing. Closer to
+    #      "what's actually printing" than any other available image
+    #      (with --arrange the layout may differ slightly, but objects
+    #      and colours match).
+    #   2. ``ThreeMFParser`` fallback chain on the sliced output: the
+    #      slicer's own per-plate render if it wrote one, then the
+    #      project-wide thumbnail under ``Auxiliaries/.thumbnails/``.
+    # BambuStudio CLI frequently doesn't emit a fresh per-plate render
+    # (slice writes the new gcode but leaves the preview slot empty),
+    # so without (1) the card falls all the way through to the
+    # MakerWorld-style cover art — visually unrelated to what the user
+    # picked, see #1493 follow-up. Failures don't fail the slice — the
+    # archive row is still useful without a thumbnail.
+    plate_num = request.plate or 1
     thumbnail_path: str | None = None
     parsed_metadata: dict = {}
+
+    src_3mf_path = app_settings.base_dir / source_archive.file_path
+    source_plate_bytes = _read_3mf_entry(src_3mf_path, f"Metadata/plate_{plate_num}.png")
+    if source_plate_bytes:
+        thumb_dest = archive_dir / "thumbnail.png"
+        thumb_dest.write_bytes(source_plate_bytes)
+        thumbnail_path = str(thumb_dest.relative_to(app_settings.base_dir))
+
     try:
-        parser = ThreeMFParser(str(out_path))
+        parser = ThreeMFParser(str(out_path), plate_number=plate_num)
         parsed = parser.parse()
-        thumb_data = parsed.get("_thumbnail_data")
-        thumb_ext = parsed.get("_thumbnail_ext", ".png")
-        if thumb_data:
-            thumb_dest = archive_dir / f"thumbnail{thumb_ext}"
-            thumb_dest.write_bytes(thumb_data)
-            thumbnail_path = str(thumb_dest.relative_to(app_settings.base_dir))
+        if thumbnail_path is None:
+            thumb_data = parsed.get("_thumbnail_data")
+            thumb_ext = parsed.get("_thumbnail_ext", ".png")
+            if thumb_data:
+                thumb_dest = archive_dir / f"thumbnail{thumb_ext}"
+                thumb_dest.write_bytes(thumb_data)
+                thumbnail_path = str(thumb_dest.relative_to(app_settings.base_dir))
         parsed_metadata = {k: v for k, v in parsed.items() if not k.startswith("_")}
     except Exception as exc:
         logger.warning("Failed to parse sliced 3MF metadata for %s: %s", out_filename, exc)
 
     metadata = dict(source_archive.extra_data) if source_archive.extra_data else {}
     metadata.update(parsed_metadata)
+    # Fall back to the produced 3MF's G-code-header totals when the sidecar
+    # leaves the X-Filament-Used-* headers unset (result.filament_used_g == 0
+    # even for a real multi-hour print).
+    filament_g = result.filament_used_g or parsed_metadata.get("filament_used_grams") or 0.0
+    filament_mm = result.filament_used_mm or parsed_metadata.get("filament_used_mm") or 0.0
     metadata.update(
         {
             "sliced_from_archive_id": source_archive.id,
             "print_time_seconds": result.print_time_seconds,
-            "filament_used_g": result.filament_used_g,
-            "filament_used_mm": result.filament_used_mm,
+            "filament_used_g": filament_g,
+            "filament_used_mm": filament_mm,
         }
     )
     if used_embedded_settings:
@@ -3292,8 +3624,23 @@ async def slice_and_persist_as_archive(
     new_filament_type = parsed_metadata.get("filament_type") or source_archive.filament_type
     new_filament_color = parsed_metadata.get("filament_color") or source_archive.filament_color
 
+    # When the user re-slices for a different printer model than the source,
+    # the source's printer_id (e.g. an H2D's "Workshop H2C") no longer
+    # represents where the new archive can be reprinted. The archive card
+    # and reprint modal both read printer_id first and only fall back to
+    # sliced_for_model when it's None, so leaving the inherited id makes
+    # the X1C-sliced card display the source H2D's printer name.
+    # Same pitfall as the sliced_for_model copy a few lines below.
+    new_target_model = parsed_metadata.get("sliced_for_model") or source_archive.sliced_for_model
+    is_cross_model_reslice = (
+        new_target_model is not None
+        and source_archive.sliced_for_model is not None
+        and new_target_model != source_archive.sliced_for_model
+    )
+    new_printer_id = None if is_cross_model_reslice else source_archive.printer_id
+
     new_archive = PrintArchive(
-        printer_id=source_archive.printer_id,
+        printer_id=new_printer_id,
         project_id=source_archive.project_id,
         filename=out_filename,
         file_path=str(out_path.relative_to(app_settings.base_dir)),
@@ -3304,12 +3651,25 @@ async def slice_and_persist_as_archive(
         # up alongside its sibling in the archives list.
         print_name=(source_archive.print_name or base_name) + " (re-sliced)",
         print_time_seconds=result.print_time_seconds,
-        filament_used_grams=result.filament_used_g or None,
+        filament_used_grams=filament_g or None,
         filament_type=new_filament_type,
         filament_color=new_filament_color,
         layer_height=source_archive.layer_height,
         nozzle_diameter=source_archive.nozzle_diameter,
-        sliced_for_model=source_archive.sliced_for_model,
+        # The re-sliced output is for whatever printer the user just picked,
+        # not the source archive's printer — read the model the slicer baked
+        # into the new 3MF, falling back to the source only if it's absent.
+        # (Copying source_archive.sliced_for_model kept a cross-printer
+        # re-slice, e.g. X1C→H2D, showing the old "X1C sliced" model.)
+        sliced_for_model=parsed_metadata.get("sliced_for_model") or source_archive.sliced_for_model,
+        # Build plate type that the sliced output was produced for (#1493
+        # follow-up): the frontend's ArchiveCard reads ``archive.bed_type``
+        # off the top-level column, not extra_data, so without this lift the
+        # re-sliced card had no plate badge. ThreeMFParser pulls it from the
+        # sliced 3MF's ``slice_info.config`` ``curr_bed_type``; if that's
+        # absent (older sidecar / older slice profile) the source archive's
+        # bed_type is the right default.
+        bed_type=parsed_metadata.get("bed_type") or source_archive.bed_type,
         makerworld_url=source_archive.makerworld_url,
         designer=source_archive.designer,
         # Sliced-but-not-printed: keep status default ("completed") so it
@@ -3326,8 +3686,8 @@ async def slice_and_persist_as_archive(
         archive_id=new_archive.id,
         name=new_archive.print_name or out_filename,
         print_time_seconds=result.print_time_seconds,
-        filament_used_g=result.filament_used_g,
-        filament_used_mm=result.filament_used_mm,
+        filament_used_g=filament_g,
+        filament_used_mm=filament_mm,
         used_embedded_settings=used_embedded_settings,
     )
 
@@ -3393,6 +3753,16 @@ async def slice_library_file(
     src_ext = Path(lib_file.filename).suffix.lower() or ".3mf"
     model_filename = f"{src_print_name}{src_ext}" if src_print_name else lib_file.filename
 
+    # Block a cross-nozzle-class re-slice (single-nozzle <-> H2D) up front.
+    # Fires only when the source is itself a sliced file (carries
+    # sliced_for_model); a plain un-sliced model has no source nozzle class.
+    await guard_nozzle_class_reslice(
+        db,
+        cloud_token_user,
+        request,
+        (lib_file.file_metadata or {}).get("sliced_for_model"),
+    )
+
     async def _run(job_id: int):
         async with async_session() as task_db:
             try:
@@ -3641,9 +4011,8 @@ async def update_file(
         if "/" in data.filename or "\\" in data.filename:
             raise HTTPException(status_code=400, detail="Filename cannot contain path separators")
         file.filename = data.filename
-        # Also update print_name in file_metadata so the display name matches
-        if file.file_metadata and "print_name" in file.file_metadata:
-            file.file_metadata = {**file.file_metadata, "print_name": data.filename}
+        # No print_name to keep in sync — library files display by filename,
+        # and _without_print_name strips the embedded 3MF Title on import (#1489).
 
     if data.folder_id is not None:
         if data.folder_id == 0:

+ 1 - 1
backend/app/api/routes/obico.py

@@ -25,7 +25,7 @@ async def get_status(
 ):
     """Scheduler status, per-printer classification, and recent detection history."""
     settings = await obico_detection_service._load_settings()
-    status = obico_detection_service.get_status()
+    status = obico_detection_service.get_status(settings["sensitivity"])
     return {
         **status,
         "enabled": settings["enabled"],

+ 31 - 4
backend/app/api/routes/print_queue.py

@@ -32,6 +32,7 @@ from backend.app.schemas.print_queue import (
     PrintQueueItemUpdate,
     PrintQueueReorder,
 )
+from backend.app.services.filament_deficit import compute_deficit_for_queue_item
 from backend.app.services.notification_service import notification_service
 from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id
 from backend.app.utils.threemf_tools import extract_filament_usage_from_3mf
@@ -196,6 +197,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         "require_previous_success": item.require_previous_success,
         "auto_off_after": item.auto_off_after,
         "manual_start": item.manual_start,
+        "filament_short": bool(item.filament_short),
         "ams_mapping": ams_mapping_parsed,
         "plate_id": item.plate_id,
         "bed_levelling": item.bed_levelling,
@@ -1028,19 +1030,25 @@ async def stop_queue_item(
 @router.post("/{item_id}/start")
 async def start_queue_item(
     item_id: int,
+    skip_filament_check: bool = Query(default=False),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_OWN),
 ):
     """Manually start a staged (manual_start) queue item.
 
-    This clears the manual_start flag so the scheduler will pick it up,
-    or starts immediately if the printer is ready.
+    Clears the manual_start flag so the scheduler picks it up. When
+    ``skip_filament_check`` is false (the default) the live filament
+    deficit (#1496) is checked first — if the assigned spool can't satisfy
+    a slot's required grams, the route returns ``409`` with the deficit
+    payload so the caller can show a confirm dialog and retry with
+    ``skip_filament_check=true``.
     """
     result = await db.execute(
         select(PrintQueueItem)
         .options(
             selectinload(PrintQueueItem.archive),
             selectinload(PrintQueueItem.printer),
+            selectinload(PrintQueueItem.library_file),
             selectinload(PrintQueueItem.batch),
         )
         .where(PrintQueueItem.id == item_id)
@@ -1052,10 +1060,29 @@ async def start_queue_item(
     if item.status != "pending":
         raise HTTPException(400, f"Can only start pending items, current status: '{item.status}'")
 
-    # Clear manual_start flag so scheduler picks it up
+    # Live deficit check — re-evaluated against current spool state, so a
+    # spool swap between scheduler flagging and the user clicking ▶ clears
+    # the block automatically.
+    if not skip_filament_check:
+        deficit = await compute_deficit_for_queue_item(db, item)
+        if deficit:
+            raise HTTPException(
+                status_code=409,
+                detail={
+                    "code": "insufficient_filament",
+                    "deficit": [d.to_dict() for d in deficit],
+                },
+            )
+
+    # Print Anyway / no deficit: clear the flags and let the scheduler dispatch.
     item.manual_start = False
+    item.filament_short = False
     await db.commit()
     await db.refresh(item, ["archive", "printer", "library_file", "created_by", "batch"])
 
-    logger.info("Manually started queue item %s (cleared manual_start flag)", item_id)
+    logger.info(
+        "Manually started queue item %s (cleared manual_start; skip_filament_check=%s)",
+        item_id,
+        skip_filament_check,
+    )
     return _enrich_response(item)

+ 34 - 0
backend/app/api/routes/printers.py

@@ -19,11 +19,13 @@ from backend.app.schemas.printer import (
     AmsLabelBody,
     AMSTray,
     AMSUnit,
+    DiagnosticRequest,
     FilaSwitchResponse,
     HMSErrorResponse,
     NozzleInfoResponse,
     NozzleRackSlot,
     PrinterCreate,
+    PrinterDiagnosticResult,
     PrinterResponse,
     PrinterStatus,
     PrinterUpdate,
@@ -38,6 +40,7 @@ from backend.app.services.bambu_ftp import (
     get_storage_info_async,
     list_files_async,
 )
+from backend.app.services.printer_diagnostic import run_connection_diagnostic
 from backend.app.services.printer_manager import (
     get_derived_status_name,
     printer_manager,
@@ -770,6 +773,37 @@ async def test_printer_connection(
     return result
 
 
+@router.post("/diagnostic", response_model=PrinterDiagnosticResult)
+async def diagnose_connection(
+    req: DiagnosticRequest,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CREATE),
+):
+    """Run connection diagnostics for the Add-Printer flow (printer not yet saved).
+
+    When serial_number + access_code are supplied the MQTT credential check
+    also runs; otherwise only the network-level checks are performed.
+    """
+    return await run_connection_diagnostic(
+        req.ip_address,
+        serial_number=req.serial_number or None,
+        access_code=req.access_code or None,
+    )
+
+
+@router.get("/{printer_id}/diagnostic", response_model=PrinterDiagnosticResult)
+async def diagnose_printer(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """Run connection diagnostics for an existing saved printer."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+    return await run_connection_diagnostic(printer.ip_address, printer=printer)
+
+
 # Cache for cover images (printer_id -> {(subtask_name, view_key) -> image_bytes}).
 # Cleared on every print start by main.py::on_print_start, so re-dispatches with
 # different plates always fetch a fresh thumbnail without needing plate in the key.

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

@@ -412,6 +412,16 @@ async def update_spoolman_settings(
 
             result = await db.execute(delete(SpoolAssignment))
             logger.info("Cleared %d spool assignments on switch to Spoolman mode", result.rowcount)
+        # Switching back to internal mode: clear Spoolman slot assignments — the
+        # symmetric counterpart of the clear above. Without this, stale
+        # spoolman_slot_assignments rows linger and would wrongly count as
+        # "assigned" in any mode-agnostic check (e.g. the missing-spool-
+        # assignment notification, which unions both tables — #1473).
+        elif old_val.lower() == "true" and new_val.lower() != "true":
+            from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+
+            result = await db.execute(delete(SpoolmanSlotAssignment))
+            logger.info("Cleared %d Spoolman slot assignments on switch to internal mode", result.rowcount)
     if "spoolman_url" in settings:
         await set_setting(db, "spoolman_url", settings["spoolman_url"])
     if "spoolman_sync_mode" in settings:

+ 43 - 5
backend/app/api/routes/slicer_presets.py

@@ -45,6 +45,7 @@ from backend.app.services.slicer_api import (
     SlicerApiUnavailableError,
     SlicerInputError,
 )
+from backend.app.utils.printer_models import PRINTER_MODEL_MAP
 
 logger = logging.getLogger(__name__)
 
@@ -172,15 +173,35 @@ async def _fetch_local_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset
         slot = type_to_slot.get(p.preset_type)
         if slot is None:
             continue
-        extra: dict[str, str | None] = {}
+        preset = UnifiedPreset(id=str(p.id), name=p.name, source="local")
         if slot == "filament":
-            extra["filament_type"], extra["filament_colour"] = _parse_filament_metadata(p.setting)
-        slots[slot].append(
-            UnifiedPreset(id=str(p.id), name=p.name, source="local", **extra),
-        )
+            preset.filament_type, preset.filament_colour = _parse_filament_metadata(p.setting)
+        if slot in ("process", "filament"):
+            # Precise compatibility link — the slicer's own compatible_printers
+            # list, captured at import time. Lets the SliceModal filter the
+            # process / filament dropdowns by the selected printer without
+            # falling back to the uploaded-bundle index.
+            preset.compatible_printers = _parse_compatible_printers(p.compatible_printers)
+        slots[slot].append(preset)
     return slots
 
 
+def _parse_compatible_printers(raw: str | None) -> list[str] | None:
+    """``LocalPreset.compatible_printers`` stores a JSON array of printer-preset
+    names. Return the parsed list, or ``None`` on missing / malformed data so
+    the SliceModal falls back to the uploaded-bundle index for that preset."""
+    if not raw:
+        return None
+    try:
+        data = json.loads(raw)
+    except (ValueError, TypeError):
+        return None
+    if not isinstance(data, list):
+        return None
+    names = [s for s in data if isinstance(s, str) and s.strip()]
+    return names or None
+
+
 def _parse_filament_metadata(setting_json: str | None) -> tuple[str | None, str | None]:
     """Extract first-slot ``filament_type`` and ``filament_colour`` from a
     stored preset JSON. OrcaSlicer stores both as arrays (per-extruder) — we
@@ -340,6 +361,23 @@ def _dedupe_by_name(
     return cloud, deduped_local, deduped_standard
 
 
+@router.get("/printer-models")
+def list_printer_models() -> dict[str, str]:
+    """Canonical Bambu printer-model registry, surfaced for the SliceModal.
+
+    Returns the backend's ``PRINTER_MODEL_MAP`` unmodified: keys are the long
+    "Bambu Lab <model>" form that appears in 3MF metadata and in slicer
+    printer-preset names, values are the normalized short codes used in
+    BambuStudio's `@BBL <code>` cloud-preset filenames. The frontend uses this
+    mapping to classify cloud / standard presets against the selected printer
+    when no slicer bundle has been uploaded that covers the preset (#1325
+    follow-up) - avoiding a second, manually-maintained model table on the
+    frontend. No auth gate: this is a static reference dictionary, not
+    user data.
+    """
+    return dict(PRINTER_MODEL_MAP)
+
+
 @router.get("/presets", response_model=UnifiedPresetsResponse)
 async def list_unified_presets(
     db: AsyncSession = Depends(get_db),

+ 42 - 19
backend/app/api/routes/spoolman.py

@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
 from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
+from backend.app.api.routes.spoolman_inventory import _clear_stale_tag_links
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
@@ -654,7 +655,7 @@ async def get_unlinked_spools(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
 ):
-    """Get all Spoolman spools that don't have a tag (not linked to AMS)."""
+    """Get all Spoolman spools not currently assigned to an AMS slot."""
     sm = await get_spoolman_settings(db)
     enabled, url = sm["enabled"], sm["url"]
     if not enabled:
@@ -671,27 +672,34 @@ async def get_unlinked_spools(
         raise HTTPException(status_code=503, detail="Spoolman is not reachable")
 
     spools = await client.get_spools()
-    unlinked = []
 
+    # A spool is "assignable" iff it does not currently occupy an AMS slot.
+    # Assignability is decided by the spoolman_slot_assignments ledger — NOT by
+    # the presence of extra.tag. extra.tag is only an RFID/NFC matching key, and
+    # OpenSpoolman writes its own NFC tag value into that same field (#1122);
+    # treating any non-empty extra.tag as "linked" hid every OpenSpoolman-tagged
+    # spool from this picker even when it occupied no slot. Both link_spool and
+    # the AMS auto-sync upsert a row here for every occupied slot, so the ledger
+    # is a complete record of what is actually assigned.
+    assigned_result = await db.execute(select(SpoolmanSlotAssignment.spoolman_spool_id))
+    assigned_spool_ids = set(assigned_result.scalars().all())
+
+    unlinked = []
     for spool in spools:
-        # Check if spool has a tag in extra field
-        extra = spool.get("extra", {}) or {}
-        tag = extra.get("tag", "")
-        # Remove quotes if present (JSON encoded string) and check if empty
-        clean_tag = tag.strip('"') if tag else ""
-        if not clean_tag:
-            filament = spool.get("filament", {}) or {}
-            unlinked.append(
-                UnlinkedSpool(
-                    id=spool["id"],
-                    filament_name=filament.get("name"),
-                    filament_vendor=(filament.get("vendor") or {}).get("name"),
-                    filament_material=filament.get("material"),
-                    filament_color_hex=filament.get("color_hex"),
-                    remaining_weight=spool.get("remaining_weight"),
-                    location=spool.get("location"),
-                )
+        if spool["id"] in assigned_spool_ids:
+            continue
+        filament = spool.get("filament", {}) or {}
+        unlinked.append(
+            UnlinkedSpool(
+                id=spool["id"],
+                filament_name=filament.get("name"),
+                filament_vendor=(filament.get("vendor") or {}).get("name"),
+                filament_material=filament.get("material"),
+                filament_color_hex=filament.get("color_hex"),
+                remaining_weight=spool.get("remaining_weight"),
+                location=spool.get("location"),
             )
+        )
 
     return unlinked
 
@@ -842,6 +850,21 @@ async def link_spool(
 
     logger.info("Linked Spoolman spool %s to tag %s", spool_id, spool_tag)
 
+    # #1457: clear stale tag links on OTHER spools still claiming this exact tag.
+    # A given AMS-slot tag (RFID or deterministic fallback) belongs to one
+    # physical spool; without this cleanup the previous holder's extra.tag
+    # keeps it visible in the hover card / fill-level lookup.
+    await _clear_stale_tag_links(
+        client,
+        tag=spool_tag,
+        keep_spool_id=spool_id,
+        log_context=(
+            f"printer={printer_context[0]} ams={printer_context[1]} tray={printer_context[2]}"
+            if printer_context
+            else "via /spools/{id}/link"
+        ),
+    )
+
     # Auto-configure AMS slot via MQTT (best-effort; tag link and slot assignment already persisted)
     if printer_context:
         p_id, a_id, t_id = printer_context

+ 97 - 0
backend/app/api/routes/spoolman_inventory.py

@@ -51,6 +51,7 @@ from backend.app.services.spoolman import (
     get_spoolman_client,
     init_spoolman_client,
 )
+from backend.app.services.spoolman_tracking import get_fallback_spool_tag_for_slot
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
     MATERIAL_TEMPS,
@@ -73,6 +74,89 @@ def _tag_cleared(val: str | None) -> bool:
     return val is None
 
 
+async def _clear_stale_tag_links(
+    client: SpoolmanClient,
+    *,
+    tag: str,
+    keep_spool_id: int,
+    log_context: str,
+) -> int:
+    """Clear extra.tag on OTHER spools still claiming the given tag (#1457).
+
+    A given AMS slot tag — whether a real RFID (tray_uuid/tag_uid) or the
+    deterministic fallback derived from (printer_serial, ams_id, tray_id) for
+    non-RFID slots — uniquely identifies one physical slot. When a spool is
+    (re)bound to that slot via Assign or Link, any other Spoolman spool whose
+    extra.tag still holds the same value is stale and would resurface in the
+    hover card / fill-level lookup.
+
+    Best-effort: per-spool patch failures are logged and skipped, never raised.
+    Returns the number of spools cleared.
+    """
+    if not tag:
+        return 0
+    tag_upper = tag.upper()
+
+    try:
+        spools = await client.get_spools()
+    except (SpoolmanClientError, SpoolmanUnavailableError) as exc:
+        logger.warning("Could not enumerate spools for stale-tag cleanup: %s", exc)
+        return 0
+
+    cleared = 0
+    for spool in spools:
+        spool_id = spool.get("id")
+        if not spool_id or spool_id == keep_spool_id:
+            continue
+        extra = spool.get("extra") or {}
+        raw_tag = extra.get("tag", "")
+        if not raw_tag:
+            continue
+        clean_tag = raw_tag.strip('"').upper()
+        if clean_tag != tag_upper:
+            continue
+        try:
+            await client.merge_spool_extra(spool_id, {"tag": json.dumps("")})
+            cleared += 1
+            logger.info(
+                "Cleared stale tag '%s' from Spoolman spool %s (%s; reassigned to spool %s)",
+                tag_upper[:16],
+                spool_id,
+                log_context,
+                keep_spool_id,
+            )
+        except (SpoolmanClientError, SpoolmanUnavailableError, SpoolmanNotFoundError) as exc:
+            logger.warning(
+                "Failed to clear stale tag on Spoolman spool %s: %s",
+                spool_id,
+                exc,
+            )
+    return cleared
+
+
+async def _clear_stale_slot_fallback_tag_links(
+    client: SpoolmanClient,
+    *,
+    printer_serial: str,
+    ams_id: int,
+    tray_id: int,
+    keep_spool_id: int,
+) -> int:
+    """Convenience wrapper: compute the slot's fallback tag and clear it from
+    other spools. Used by the assign route, which identifies the slot by
+    (printer, ams, tray) rather than by an explicit tag value.
+    """
+    fallback_tag = get_fallback_spool_tag_for_slot(printer_serial, ams_id, tray_id)
+    if not fallback_tag:
+        return 0
+    return await _clear_stale_tag_links(
+        client,
+        tag=fallback_tag,
+        keep_spool_id=keep_spool_id,
+        log_context=f"printer={printer_serial} ams={ams_id} tray={tray_id}",
+    )
+
+
 async def _get_client(db: AsyncSession) -> SpoolmanClient:
     """Return a validated Spoolman client (URL checked, health-checked) or raise an HTTP error."""
     result = await db.execute(select(Settings))
@@ -1170,6 +1254,19 @@ async def assign_spoolman_slot(
         logger.error("Failed to persist slot assignment: %s", exc)
         raise HTTPException(status_code=500, detail="Failed to save slot assignment") from exc
 
+    # #1457: clear stale fallback-tag links on OTHER spools still bound to this
+    # slot. Without this, a non-RFID slot's deterministic fallback tag stays
+    # attached to the previous spool in Spoolman's extra.tag and re-surfaces in
+    # the hover card whenever the local slot assignment is removed.
+    if printer.serial_number:
+        await _clear_stale_slot_fallback_tag_links(
+            client,
+            printer_serial=printer.serial_number,
+            ams_id=body.ams_id,
+            tray_id=body.tray_id,
+            keep_spool_id=body.spoolman_spool_id,
+        )
+
     mapped = _map_spoolman_spool(spool)
 
     # Fetch K-profiles before the MQTT try block so we can use async DB access.

+ 26 - 194
backend/app/api/routes/support.py

@@ -33,6 +33,12 @@ from backend.app.models.settings import Settings
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.user import User
 from backend.app.services.discovery import is_running_in_docker
+from backend.app.services.log_reader import (
+    LogEntry,
+    collect_sensitive_strings,
+    read_log_entries,
+    sanitize_log_content,
+)
 from backend.app.services.network_utils import get_network_interfaces
 from backend.app.services.printer_manager import printer_manager
 
@@ -156,15 +162,6 @@ async def toggle_debug_logging(
     )
 
 
-class LogEntry(BaseModel):
-    """A single log entry."""
-
-    timestamp: str
-    level: str
-    logger_name: str
-    message: str
-
-
 class LogsResponse(BaseModel):
     """Response containing log entries."""
 
@@ -173,107 +170,6 @@ class LogsResponse(BaseModel):
     filtered_count: int
 
 
-# Log line regex pattern: "2024-01-15 10:30:45,123 INFO [module.name] Message here"
-LOG_LINE_PATTERN = re.compile(r"^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2},\d{3})\s+(\w+)\s+\[([^\]]+)\]\s+(.*)$")
-
-
-def _parse_log_line(line: str) -> LogEntry | None:
-    """Parse a single log line into a LogEntry."""
-    match = LOG_LINE_PATTERN.match(line.strip())
-    if match:
-        return LogEntry(
-            timestamp=match.group(1),
-            level=match.group(2),
-            logger_name=match.group(3),
-            message=match.group(4),
-        )
-    return None
-
-
-def _read_log_entries(
-    limit: int = 200,
-    level_filter: str | None = None,
-    search: str | None = None,
-) -> tuple[list[LogEntry], int]:
-    """Read and parse log entries from file with optional filtering."""
-    log_file = settings.log_dir / "bambuddy.log"
-    if not log_file.exists():
-        return [], 0
-
-    entries: list[LogEntry] = []
-    total_lines = 0
-
-    try:
-        with open(log_file, encoding="utf-8", errors="replace") as f:
-            # Read all lines and process
-            lines = f.readlines()
-            total_lines = len(lines)
-
-            # Parse lines in reverse order (newest first)
-            current_entry: LogEntry | None = None
-            multi_line_buffer: list[str] = []
-
-            for line in reversed(lines):
-                parsed = _parse_log_line(line)
-                if parsed:
-                    # Found a new log entry start
-                    if current_entry:
-                        # Apply filters and add previous entry (without multi_line_buffer - it belongs to new entry)
-                        should_include = True
-
-                        # Level filter
-                        if level_filter and current_entry.level.upper() != level_filter.upper():
-                            should_include = False
-
-                        # Search filter (case-insensitive)
-                        if search and should_include:
-                            search_lower = search.lower()
-                            if not (
-                                search_lower in current_entry.message.lower()
-                                or search_lower in current_entry.logger_name.lower()
-                            ):
-                                should_include = False
-
-                        if should_include:
-                            entries.append(current_entry)
-
-                            if len(entries) >= limit:
-                                break
-
-                    # Set new entry and attach any accumulated multi-line content to it
-                    # (in reverse order, continuation lines come before their parent entry)
-                    current_entry = parsed
-                    if multi_line_buffer:
-                        current_entry.message += "\n" + "\n".join(reversed(multi_line_buffer))
-                    multi_line_buffer = []
-                elif line.strip():
-                    # Continuation of multi-line log entry (will be attached to next parsed entry)
-                    multi_line_buffer.append(line.rstrip())
-
-            # Don't forget the last (oldest) entry
-            # Note: any remaining multi_line_buffer would be orphaned lines before the first entry
-            if current_entry and len(entries) < limit:
-                should_include = True
-                if level_filter and current_entry.level.upper() != level_filter.upper():
-                    should_include = False
-                if search and should_include:
-                    search_lower = search.lower()
-                    if not (
-                        search_lower in current_entry.message.lower()
-                        or search_lower in current_entry.logger_name.lower()
-                    ):
-                        should_include = False
-                if should_include:
-                    entries.append(current_entry)
-
-    except Exception as e:
-        logger.error("Error reading log file: %s", e)
-        return [], 0
-
-    # Entries are already in newest-first order
-    return entries, total_lines
-
-
 @router.get("/logs", response_model=LogsResponse)
 async def get_logs(
     limit: int = Query(200, ge=1, le=1000, description="Maximum number of entries to return"),
@@ -282,7 +178,7 @@ async def get_logs(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
 ):
     """Get recent application log entries with optional filtering."""
-    entries, total_lines = _read_log_entries(limit=limit, level_filter=level, search=search)
+    entries, total_lines = read_log_entries(limit=limit, level_filter=level, search=search)
 
     return LogsResponse(
         entries=entries,
@@ -1203,43 +1099,23 @@ async def _collect_support_info() -> dict:
     except Exception:
         logger.debug("Failed to collect WebSocket info", exc_info=True)
 
-    return info
-
-
-def _sanitize_log_content(content: str, sensitive_strings: dict[str, str] | None = None) -> str:
-    """Remove sensitive data from log content."""
-    # First, replace known sensitive values (database-aware exact matching)
-    # This catches printer names, usernames, and other arbitrary user-chosen strings
-    # that regex patterns cannot detect
-    if sensitive_strings:
-        # Sort by length descending to avoid partial matches (e.g. "My Printer 1" before "My Printer")
-        for value, label in sorted(sensitive_strings.items(), key=lambda x: len(x[0]), reverse=True):
-            if len(value) < 3:
-                continue  # Skip very short strings to prevent over-redaction
-            content = re.sub(re.escape(value), label, content)
-
-    # Replace credentials in URLs (e.g. http://user:pass@host, rtsps://bblp:code@host)
-    content = re.sub(r"((?:https?|rtsps?)://)[^/:@\s]+:[^/@\s]+@", r"\1[CREDENTIALS]@", content)
-
-    # Replace email addresses
-    content = re.sub(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "[EMAIL]", content)
-
-    # Replace Bambu Lab printer serial numbers (format: 00M/01D/01S/01P/03W + alphanumeric, 12-16 chars total)
-    content = re.sub(r"\b0[0-3][A-Z0-9][A-Z0-9]{9,13}\b", "[SERIAL]", content, flags=re.IGNORECASE)
-
-    # Replace IPv4 addresses (skip firmware versions like 01.09.01.00 which have leading zeros)
-    content = re.sub(
-        r"\b(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)\b",
-        "[IP]",
-        content,
-    )
+    # Active diagnostics — per-printer connection check, per-VP setup check,
+    # and the log-health scan. These all surface in the UI today (System page +
+    # bug-report bubble) but were never persisted into what the maintainer
+    # receives, so a "looks broken in bambuddy" report arrived with no
+    # actionable signal beyond raw logs. The snapshot helper is fail-soft per
+    # probe and bounded by a per-probe wall-clock cap, so a hung interface
+    # adds at most ~15 s to bundle generation regardless of fleet size (probes
+    # run concurrently).
+    try:
+        from backend.app.services.diagnostic_snapshot import collect_diagnostic_snapshot
 
-    # Replace paths with usernames
-    content = re.sub(r"/home/[^/\s]+/", "/home/[user]/", content)
-    content = re.sub(r"/Users/[^/\s]+/", "/Users/[user]/", content)
-    content = re.sub(r"/opt/[^/\s]+/", "/opt/[user]/", content)
+        async with async_session() as db:
+            info["diagnostics"] = await collect_diagnostic_snapshot(db)
+    except Exception:
+        logger.warning("Failed to collect diagnostic snapshot", exc_info=True)
 
-    return content
+    return info
 
 
 def _get_log_content(max_bytes: int = 10 * 1024 * 1024, sensitive_strings: dict[str, str] | None = None) -> bytes:
@@ -1260,35 +1136,15 @@ def _get_log_content(max_bytes: int = 10 * 1024 * 1024, sensitive_strings: dict[
             content = f.read().decode("utf-8", errors="replace")
 
     # Sanitize sensitive data
-    content = _sanitize_log_content(content, sensitive_strings)
+    content = sanitize_log_content(content, sensitive_strings)
     return content.encode("utf-8")
 
 
 async def _get_recent_sanitized_logs(max_lines: int = 200) -> str:
     """Get recent log lines, sanitized for inclusion in bug reports."""
     # Collect sensitive strings from DB for redaction
-    sensitive_strings: dict[str, str] = {}
     async with async_session() as db:
-        result = await db.execute(select(Printer.name, Printer.serial_number, Printer.ip_address, Printer.access_code))
-        for name, serial, ip_address, access_code in result.all():
-            if name:
-                sensitive_strings[name] = "[PRINTER]"
-            if serial:
-                sensitive_strings[serial] = "[SERIAL]"
-            if ip_address:
-                sensitive_strings[ip_address] = "[IP]"
-            if access_code:
-                sensitive_strings[access_code] = "[ACCESS_CODE]"
-
-        result = await db.execute(select(User.username))
-        for (username,) in result.all():
-            if username:
-                sensitive_strings[username] = "[USER]"
-
-        result = await db.execute(select(Settings.value).where(Settings.key == "bambu_cloud_email"))
-        cloud_email = result.scalar_one_or_none()
-        if cloud_email:
-            sensitive_strings[cloud_email] = "[EMAIL]"
+        sensitive_strings = await collect_sensitive_strings(db)
 
     log_file = settings.log_dir / "bambuddy.log"
     if not log_file.exists():
@@ -1299,7 +1155,7 @@ async def _get_recent_sanitized_logs(max_lines: int = 200) -> str:
         content = log_file.read_text(encoding="utf-8", errors="replace")
         lines = content.splitlines()
         recent = "\n".join(lines[-max_lines:])
-        return _sanitize_log_content(recent, sensitive_strings)
+        return sanitize_log_content(recent, sensitive_strings)
     except Exception:
         logger.debug("Failed to read logs for bug report", exc_info=True)
         return ""
@@ -1322,31 +1178,7 @@ async def generate_support_bundle(
             )
 
         # Collect known sensitive values for log redaction
-        sensitive_strings: dict[str, str] = {}
-
-        # Printer names, serial numbers, IP addresses, and access codes
-        result = await db.execute(select(Printer.name, Printer.serial_number, Printer.ip_address, Printer.access_code))
-        for name, serial, ip_address, access_code in result.all():
-            if name:
-                sensitive_strings[name] = "[PRINTER]"
-            if serial:
-                sensitive_strings[serial] = "[SERIAL]"
-            if ip_address:
-                sensitive_strings[ip_address] = "[IP]"
-            if access_code:
-                sensitive_strings[access_code] = "[ACCESS_CODE]"
-
-        # Auth usernames
-        result = await db.execute(select(User.username))
-        for (username,) in result.all():
-            if username:
-                sensitive_strings[username] = "[USER]"
-
-        # Bambu Cloud email
-        result = await db.execute(select(Settings.value).where(Settings.key == "bambu_cloud_email"))
-        cloud_email = result.scalar_one_or_none()
-        if cloud_email:
-            sensitive_strings[cloud_email] = "[EMAIL]"
+        sensitive_strings = await collect_sensitive_strings(db)
 
     # Collect support info
     support_info = await _collect_support_info()

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

@@ -23,6 +23,8 @@ from backend.app.models.printer import Printer
 from backend.app.models.project import Project
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.user import User
+from backend.app.services.log_health import ScanResult, scan_logs
+from backend.app.services.log_reader import collect_sensitive_strings
 from backend.app.services.printer_manager import printer_manager
 
 router = APIRouter(prefix="/system", tags=["system"])
@@ -574,3 +576,17 @@ async def get_storage_usage(
     """Get storage usage breakdown for Bambuddy data directories."""
     max_age_seconds = max(0, min(max_age_seconds, 3600))
     return await _get_storage_usage_cached(refresh=refresh, max_age_seconds=max_age_seconds)
+
+
+@router.get("/health", response_model=ScanResult)
+async def get_system_health(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
+):
+    """Scan the recent application log against the known-issue catalog.
+
+    Powers the self-service triage surfaces (System page + bug reporter).
+    Sample lines are sanitized before they leave the process.
+    """
+    sensitive_strings = await collect_sensitive_strings(db)
+    return await asyncio.to_thread(scan_logs, sensitive_strings=sensitive_strings)

+ 44 - 0
backend/app/api/routes/virtual_printers.py

@@ -10,6 +10,7 @@ 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.user import User
+from backend.app.schemas.virtual_printer import VPDiagnosticResult
 
 # Imported at module scope so tests can patch
 # backend.app.api.routes.virtual_printers.tailscale_service.
@@ -254,6 +255,49 @@ async def get_tailscale_status(
     )
 
 
+@router.get("/ca-certificate")
+async def get_ca_certificate(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
+    """Return the shared virtual-printer CA certificate (PEM) for slicer trust import.
+
+    One CA is shared by every virtual printer — the user imports it into their
+    slicer's trust store once. Only the public certificate is returned; the CA
+    private key never leaves the backend.
+    """
+    from backend.app.services.virtual_printer import virtual_printer_manager
+
+    try:
+        return virtual_printer_manager.get_ca_certificate_info()
+    except Exception as e:
+        logger.error("Failed to obtain virtual printer CA certificate: %s", e)
+        return JSONResponse(status_code=500, content={"detail": "Could not generate the CA certificate"})
+
+
+@router.get("/{vp_id}/diagnostic", response_model=VPDiagnosticResult)
+async def diagnose_virtual_printer(
+    vp_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
+    """Run setup diagnostics for a virtual printer.
+
+    Probes the VP's own bind IP and services so the user can self-diagnose the
+    common "my virtual printer doesn't show up in the slicer" failures.
+    """
+    from backend.app.models.virtual_printer import VirtualPrinter
+    from backend.app.services.virtual_printer import virtual_printer_manager
+    from backend.app.services.virtual_printer.diagnostic import run_vp_diagnostic
+
+    result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
+    vp = result.scalar_one_or_none()
+    if not vp:
+        return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
+
+    instance = virtual_printer_manager.get_instance(vp.id)
+    return await run_vp_diagnostic(vp, instance)
+
+
 @router.get("/{vp_id}")
 async def get_virtual_printer(
     vp_id: int,

+ 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.2"
+APP_VERSION = "0.2.4.3"
 GITHUB_REPO = "maziggy/bambuddy"
 BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")
 

+ 46 - 0
backend/app/core/database.py

@@ -415,6 +415,39 @@ async def _migrate_normalize_printer_ids(conn) -> None:
             await conn.execute(text("UPDATE api_keys SET printer_ids = NULL WHERE printer_ids::text = '[]'"))
 
 
+async def _migrate_drop_library_print_name(conn) -> None:
+    """Strip the embedded 3MF Title (``print_name``) from library file metadata (#1489).
+
+    Library files stored the 3MF's ``<metadata name="Title">`` as
+    ``file_metadata.print_name`` — generic ("Exported 3D Model") for Bambu
+    Studio exports, a marketing title for MakerWorld downloads — and the
+    FileManager wrongly preferred it over the filename for the card label,
+    search and sort. New imports no longer store it; this clears it from rows
+    imported before the fix so existing libraries don't need a rename
+    round-trip. Idempotent — rows without the key are untouched.
+    """
+    from sqlalchemy import text
+
+    async with conn.begin_nested():
+        if is_sqlite():
+            await conn.execute(
+                text(
+                    "UPDATE library_files SET file_metadata = json_remove(file_metadata, '$.print_name') "
+                    "WHERE json_extract(file_metadata, '$.print_name') IS NOT NULL"
+                )
+            )
+        else:
+            # file_metadata is a JSON (not JSONB) column — cast to jsonb for the
+            # key-exists test (jsonb_exists, avoiding the `?` operator which
+            # clashes with driver parameter syntax) and the `- key` removal.
+            await conn.execute(
+                text(
+                    "UPDATE library_files SET file_metadata = (file_metadata::jsonb - 'print_name')::json "
+                    "WHERE jsonb_exists(file_metadata::jsonb, 'print_name')"
+                )
+            )
+
+
 async def _migrate_update_auto_link_constraint(conn) -> None:
     """Update the auto_link CHECK constraint to allow Fall C (custom email claim).
 
@@ -873,6 +906,15 @@ async def run_migrations(conn):
     # Migration: Add ams_mapping column to print_queue for storing filament slot assignments
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN ams_mapping TEXT")
 
+    # Migration: filament_short flag on print_queue (#1496). Set by the
+    # dispatch scheduler when the assigned spool can't satisfy the print's
+    # per-slot weight; surfaced as a "filament short" badge on the queue row.
+    # Postgres rejects `DEFAULT 0` for BOOLEAN — branch on dialect.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN filament_short BOOLEAN DEFAULT 0")
+    else:
+        await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN filament_short BOOLEAN DEFAULT false")
+
     # Migration: Add queue_force_color_match column to virtual_printers (#1188).
     # Opt-in flag: when true, VP queue-mode uploads pin the per-slot type+color
     # from the 3MF onto the queue item's filament_overrides so the scheduler
@@ -2615,6 +2657,10 @@ async def run_migrations(conn):
             "ALTER TABLE smart_plugs ADD COLUMN IF NOT EXISTS off_delay_after_drying_minutes INTEGER DEFAULT 10",
         )
 
+    # Data migration: drop the embedded 3MF Title (`print_name`) from library
+    # file metadata so the FileManager displays the filename, not the title (#1489).
+    await _migrate_drop_library_print_name(conn)
+
 
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""

+ 159 - 25
backend/app/main.py

@@ -3,6 +3,7 @@ import logging
 import mimetypes as _mimetypes
 import os
 import posixpath
+import secrets
 import time
 from contextlib import asynccontextmanager
 from datetime import datetime, timedelta, timezone
@@ -2039,8 +2040,23 @@ async def on_print_start(printer_id: int, data: dict):
                 # Update archive status to printing
                 archive.status = "printing"
                 archive.started_at = datetime.now(timezone.utc)
-                if subtask_id and not archive.subtask_id:
-                    archive.subtask_id = subtask_id
+                # Persist a restart-stable id so a later restart resumes this
+                # archive by subtask_id instead of name-matching + duplicating
+                # it (#1485). The printer often hasn't echoed subtask_id back
+                # this soon after dispatch, so fall back to the id Bambuddy
+                # minted when it sent the print command. Scoped to this
+                # expected-print branch on purpose: an expected match means
+                # Bambuddy dispatched this exact print in this process, so the
+                # client's last-dispatch id genuinely belongs to it — using it
+                # for an externally-started print could mis-tag the archive.
+                effective_subtask_id = subtask_id
+                if not effective_subtask_id:
+                    _client = printer_manager.get_client(printer_id)
+                    _dispatched = getattr(_client, "last_dispatch_subtask_id", None) if _client else None
+                    if _dispatched:
+                        effective_subtask_id = str(_dispatched).strip() or None
+                if effective_subtask_id and not archive.subtask_id:
+                    archive.subtask_id = effective_subtask_id
                 # #1403 follow-up: VP-queue archives are created with
                 # printer_id=None at queue-add time (we don't know which
                 # printer will run the job yet). When the print actually
@@ -2121,6 +2137,14 @@ async def on_print_start(printer_id: int, data: dict):
                 except Exception as e:
                     logger.warning("[SPOOLMAN] Failed to store tracking data: %s", e)
 
+                # Capture timelapse file baseline for snapshot-diff on completion
+                # (mirrors the new-archive branch). Queue / VP-dispatched prints
+                # hit this branch — without the baseline the completion-time scan
+                # falls into its "take baseline now" fallback, which snapshots
+                # AFTER the new MP4 already exists and never matches a diff
+                # (#1403 follow-up — see pwostran's 2026-05-18 support bundle).
+                await _capture_timelapse_baseline_at_start(printer, printer_id, logger)
+
             return  # Skip creating a new archive
 
         # Check if there's already a "printing" archive for this printer/file
@@ -2200,18 +2224,31 @@ async def on_print_start(printer_id: int, data: dict):
                 _load_objects_from_archive(existing_archive, printer_id, logger)
                 return
 
-            # Name-match only: fall back to the legacy 4h staleness heuristic.
+            # Name-match only (no subtask_id to anchor on): decide resume vs.
+            # stale from the printer's *current* progress, not wall-clock age.
+            # A genuinely long print used to trip a blind 4h cutoff and have its
+            # live archive cancelled + duplicated on every backend restart
+            # (#1485). If the printer reports real progress, this name-matched
+            # 'printing' archive IS that ongoing print — resume it whatever its
+            # age. Only treat it as a stale leftover when the printer clearly
+            # shows a different, freshly-started print: near-0% progress on an
+            # archive far too old to still be at 0%. Unknown progress (printer
+            # not connected) never cancels — resuming is the safe default.
             archive_age = datetime.now(timezone.utc) - existing_archive.created_at.replace(tzinfo=timezone.utc)
-            if archive_age.total_seconds() > 4 * 60 * 60:  # 4 hours
+            live_status = printer_manager.get_status(printer_id)
+            live_progress = getattr(live_status, "progress", None) if live_status else None
+            looks_stale = (
+                live_progress is not None and live_progress < 1.0 and archive_age.total_seconds() > 2 * 60 * 60
+            )
+            if looks_stale:
                 logger.warning(
-                    f"Found stale 'printing' archive {existing_archive.id} (age: {archive_age}), "
-                    f"marking as cancelled and creating new archive"
+                    f"Found stale 'printing' archive {existing_archive.id} (age: {archive_age}, "
+                    f"printer progress {live_progress:.0f}%) — marking cancelled and creating new archive"
                 )
                 existing_archive.status = "cancelled"
                 existing_archive.failure_reason = "Stale - print likely cancelled or failed without status update"
                 await db.commit()
                 # Fall through to create new archive (don't return)
-                _existing_archive = None  # Clear so we don't use stale archive
             else:
                 logger.info(
                     f"Skipping duplicate - already have printing archive {existing_archive.id} for {check_name}"
@@ -2725,16 +2762,7 @@ async def on_print_start(printer_id: int, data: dict):
                     logger.warning("[SPOOLMAN] Failed to store tracking data: %s", e)
 
                 # Capture timelapse file baseline for snapshot-diff on completion
-                try:
-                    baseline_files, _ = await _list_timelapse_videos(printer)
-                    _timelapse_baselines[printer_id] = {f.get("name", "") for f in baseline_files}
-                    logger.info(
-                        "[TIMELAPSE] Baseline at print start: %s video files for printer %s",
-                        len(_timelapse_baselines[printer_id]),
-                        printer_id,
-                    )
-                except Exception as e:
-                    logger.warning("[TIMELAPSE] Failed to capture baseline at print start: %s", e)
+                await _capture_timelapse_baseline_at_start(printer, printer_id, logger)
         finally:
             # Keep temp_path around until print completes so the cover endpoint
             # can reuse it (#972). Cache eviction in on_print_complete deletes
@@ -2779,6 +2807,32 @@ async def _list_timelapse_videos(printer) -> tuple[list[dict], str | None]:
     return [], None
 
 
+async def _capture_timelapse_baseline_at_start(printer, printer_id: int, logger: logging.Logger) -> None:
+    """Snapshot the printer's timelapse directory at print start so the
+    completion-time scan can pick the new file by set-difference.
+
+    Must be called from every on_print_start path that proceeds to a real
+    print — both the new-archive branch and the expected-archive branch (which
+    queue / VP-dispatched prints take). Without a baseline,
+    _scan_for_timelapse_with_retries falls into its "take baseline now"
+    fallback that runs AFTER the new MP4 has already landed on the SD card,
+    so the new file ends up in the "baseline" set and no diff ever matches.
+
+    Bambu printers in LAN-only mode don't sync NTP, so mtime ordering is
+    unreliable — the snapshot-diff approach sidesteps that entirely.
+    """
+    try:
+        baseline_files, _ = await _list_timelapse_videos(printer)
+        _timelapse_baselines[printer_id] = {f.get("name", "") for f in baseline_files}
+        logger.info(
+            "[TIMELAPSE] Baseline at print start: %s video files for printer %s",
+            len(_timelapse_baselines[printer_id]),
+            printer_id,
+        )
+    except Exception as e:
+        logger.warning("[TIMELAPSE] Failed to capture baseline at print start: %s", e)
+
+
 async def _scan_for_timelapse_with_retries(archive_id: int, baseline_names: set[str] | None = None):
     """
     Scan for timelapse with retries using a snapshot-diff approach.
@@ -2970,6 +3024,52 @@ async def _scan_for_timelapse_with_retries(archive_id: int, baseline_names: set[
     logger.warning("[TIMELAPSE] All attempts exhausted for archive %s, giving up", archive_id)
 
 
+async def on_print_running_observed(printer_id: int, data: dict):
+    """Restart-recovery: capture a fresh timelapse baseline for a print that
+    started before Bambuddy came up.
+
+    bambu_mqtt.py suppresses ``on_print_start`` on the first RUNNING push
+    after Bambuddy startup (#1304 guard, prevents duplicate archive
+    creation). Without that path, ``_capture_timelapse_baseline_at_start``
+    never runs and ``_scan_for_timelapse_with_retries`` falls into its
+    "take baseline now" fallback at completion time — but by then the
+    printer has already uploaded the in-flight MP4, so the baseline
+    includes it and no diff ever matches (#1485 follow-up).
+
+    Fires once per session, in lieu of on_print_start when restart-recovery
+    kicks in. The printer doesn't upload the timelapse until after PRINT
+    COMPLETE, so a baseline captured any time during the print is still
+    pre-upload.
+    """
+    logger = logging.getLogger(__name__)
+
+    # Avoid double-capture: on_print_start may have run earlier in this
+    # Bambuddy process if the print started AFTER startup and we crashed
+    # later in the same session. (Realistically this can't happen — the
+    # MQTT client object would have been recreated — but the cheap guard
+    # is correct regardless.)
+    if printer_id in _timelapse_baselines:
+        logger.debug(
+            "[TIMELAPSE] on_print_running_observed: baseline already present for printer %s, skipping",
+            printer_id,
+        )
+        return
+
+    async with async_session() as db:
+        from backend.app.models.printer import Printer
+
+        result = await db.execute(select(Printer).where(Printer.id == printer_id))
+        printer = result.scalar_one_or_none()
+        if not printer:
+            logger.warning(
+                "[TIMELAPSE] on_print_running_observed: printer %s not found in DB, skipping baseline",
+                printer_id,
+            )
+            return
+
+    await _capture_timelapse_baseline_at_start(printer, printer_id, logger)
+
+
 async def on_print_complete(printer_id: int, data: dict):
     """Handle print completion - update the archive status."""
     import time
@@ -4667,6 +4767,7 @@ async def lifespan(app: FastAPI):
     printer_manager.set_status_change_callback(on_printer_status_change)
     printer_manager.set_print_start_callback(on_print_start)
     printer_manager.set_print_complete_callback(on_print_complete)
+    printer_manager.set_print_running_observed_callback(on_print_running_observed)
     printer_manager.set_ams_change_callback(on_ams_change)
 
     # Rehydrate persisted awaiting-plate-clear gate (#961) so prompts survive restarts
@@ -4880,6 +4981,12 @@ async def lifespan(app: FastAPI):
     # L-2: Start periodic auth cleanup (stale TOTP + expired revoked JTIs)
     start_auth_cleanup()
 
+    # Event-loop stall watchdog: dumps all thread stacks to stderr if the loop
+    # freezes (#1486 — silent "container hangs after adding a printer" reports).
+    from backend.app.services.loop_watchdog import start_loop_watchdog
+
+    start_loop_watchdog()
+
     # Initialize virtual printer manager and sync from DB
     from backend.app.services.virtual_printer import virtual_printer_manager
 
@@ -4907,6 +5014,9 @@ async def lifespan(app: FastAPI):
     stop_runtime_tracking()
     stop_spoolbuddy_watchdog()
     stop_camera_cleanup()
+    from backend.app.services.loop_watchdog import stop_loop_watchdog
+
+    stop_loop_watchdog()
     # Tear down all camera fan-out broadcasters (#1089) so subscribers exit
     # cleanly rather than waiting on a queue that nothing will ever fill.
     try:
@@ -5085,6 +5195,16 @@ def _frame_ancestors(default_value: str) -> str:
 @app.middleware("http")
 async def security_headers_middleware(request, call_next):
     """Add standard HTTP security headers to every response."""
+    # Per-request nonce stamped into `script-src` (#1460). On its own this
+    # changes nothing for Bambuddy's own pages — index.html has no inline
+    # scripts since the SW registration moved to /sw-register.js. The reason
+    # it's here is Cloudflare: a CF-fronted deployment has the bot-detection
+    # script injected into the HTML on the edge, with a fresh hash on every
+    # load (so hashes can't be allowlisted). When CF sees a nonce in our CSP,
+    # it clones the same nonce onto its injected <script>, and the inline
+    # script passes the policy without us needing 'unsafe-inline'. See
+    # https://developers.cloudflare.com/cloudflare-challenges/challenge-types/javascript-detections/#if-you-have-a-content-security-policy-csp
+    csp_nonce = secrets.token_urlsafe(16)
     response = await call_next(request)
     response.headers["X-Content-Type-Options"] = "nosniff"
     # X-Frame-Options is the legacy cross-origin embedding control. Modern
@@ -5110,11 +5230,11 @@ async def security_headers_middleware(request, call_next):
         response.headers["Content-Security-Policy"] = (
             "default-src 'self'; "
             "script-src 'self' 'unsafe-eval'; "
-            "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
+            "style-src 'self' 'unsafe-inline'; "
             "img-src 'self' data: blob:; "
             "media-src 'self' blob:; "
             "connect-src 'self' ws: wss:; "
-            "font-src 'self' data: https://fonts.gstatic.com; "
+            "font-src 'self' data:; "
             "object-src 'none'; "
             "base-uri 'self'; "
             "frame-src 'self' http: https:; " + _frame_ancestors("'self'")
@@ -5137,12 +5257,12 @@ async def security_headers_middleware(request, call_next):
     else:
         response.headers["Content-Security-Policy"] = (
             "default-src 'self'; "
-            "script-src 'self'; "
-            "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
+            f"script-src 'self' 'nonce-{csp_nonce}'; "
+            "style-src 'self' 'unsafe-inline'; "
             "img-src 'self' data: blob:; "
             "media-src 'self' blob:; "
             "connect-src 'self' ws: wss:; "
-            "font-src 'self' data: https://fonts.gstatic.com; "
+            "font-src 'self' data:; "
             "object-src 'none'; "
             "base-uri 'self'; "
             "frame-src 'self' http: https:; " + _frame_ancestors("'none'")
@@ -5395,6 +5515,16 @@ if app_settings.static_dir.exists() and any(app_settings.static_dir.iterdir()):
             StaticFiles(directory=app_settings.static_dir / "icons"),
             name="icons",
         )
+    # Self-hosted Inter woff2 files (#1460). Without this mount /fonts/*.woff2
+    # falls through to the SPA catch-all and returns index.html, which the
+    # browser's font sanitizer rejects ("downloadable font: rejected by
+    # sanitizer").
+    if (app_settings.static_dir / "fonts").exists():
+        app.mount(
+            "/fonts",
+            StaticFiles(directory=app_settings.static_dir / "fonts"),
+            name="fonts",
+        )
 
 
 @app.get("/")
@@ -5429,7 +5559,11 @@ async def health_check():
     return {"status": "healthy"}
 
 
-@app.get("/manifest.json")
+# GET + HEAD on the three PWA bootstrap routes (#1460). Scanners and a plain
+# `curl -I` use HEAD; FastAPI's @app.get only registers GET, so HEAD answers
+# with 405 Method Not Allowed and shows up as a "broken manifest" red herring
+# in deployment debugging.
+@app.api_route("/manifest.json", methods=["GET", "HEAD"])
 async def serve_manifest():
     """Serve PWA manifest."""
     manifest_file = app_settings.static_dir / "manifest.json"
@@ -5438,7 +5572,7 @@ async def serve_manifest():
     return {"error": "Manifest not found"}
 
 
-@app.get("/sw.js")
+@app.api_route("/sw.js", methods=["GET", "HEAD"])
 async def serve_service_worker():
     """Serve service worker."""
     sw_file = app_settings.static_dir / "sw.js"
@@ -5451,7 +5585,7 @@ async def serve_service_worker():
     return {"error": "Service worker not found"}
 
 
-@app.get("/sw-register.js")
+@app.api_route("/sw-register.js", methods=["GET", "HEAD"])
 async def serve_sw_register():
     """Serve the service-worker registration bootstrap script.
 

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

@@ -76,6 +76,13 @@ class PrintQueueItem(Base):
     # Status: pending, printing, completed, failed, skipped, cancelled
     status: Mapped[str] = mapped_column(String(20), default="pending")
 
+    # Set by the dispatch scheduler when the assigned spool can't satisfy
+    # this print's per-slot filament weight (#1496). Display-only flag — the
+    # actual deficit is recomputed live every time the user clicks ▶, so
+    # swapping a spool to a fuller one between flag and dispatch clears the
+    # block automatically.
+    filament_short: Mapped[bool] = mapped_column(Boolean, default=False)
+
     # Tracking
     started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

+ 5 - 0
backend/app/schemas/print_queue.py

@@ -86,6 +86,11 @@ class PrintQueueItemResponse(BaseModel):
     require_previous_success: bool
     auto_off_after: bool
     manual_start: bool
+    # True when the dispatch scheduler last evaluated this item and the
+    # assigned spool could not satisfy at least one slot's required grams
+    # (#1496). Display-only — the ▶ click recomputes deficit against live
+    # spool state.
+    filament_short: bool = False
     ams_mapping: list[int] | None = None
     plate_id: int | None = None  # Plate ID for multi-plate 3MF files
     # Print options

+ 55 - 1
backend/app/schemas/printer.py

@@ -1,11 +1,29 @@
 from datetime import datetime
 
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, field_validator
 
 
 class PrinterBase(BaseModel):
     name: str = Field(..., min_length=1, max_length=100)
     serial_number: str = Field(..., min_length=1, max_length=50)
+
+    @field_validator("serial_number")
+    @classmethod
+    def _normalize_serial_number(cls, v: str) -> str:
+        """Uppercase and trim the serial number.
+
+        Bambu serial numbers are uppercase alphanumeric, and the MQTT report
+        topic ``device/<serial>/report`` is case-sensitive. A serial entered
+        in the wrong case (or with stray whitespace) connects and subscribes
+        without error but never receives a message — the printer publishes to
+        the correctly-cased topic, so every status field stays unknown (#1465).
+        Normalising on input makes the subscribed topic always match.
+        """
+        normalized = v.strip().upper()
+        if not normalized:
+            raise ValueError("serial_number must not be blank")
+        return normalized
+
     ip_address: str = Field(
         ...,
         max_length=253,
@@ -306,3 +324,39 @@ class PrinterStatus(BaseModel):
     # Set for every active print regardless of plate count; the frontend decides
     # whether to render it based on current_archive_id's is_multi_plate flag.
     current_plate_id: int | None = None
+
+
+class DiagnosticCheck(BaseModel):
+    """One connection-diagnostic check result.
+
+    ``id`` is a stable key (port_mqtt, port_ftps, port_rtsps, network_mode,
+    subnet, mqtt_auth, developer_mode); the frontend renders the localized
+    title and fix text from id + status. ``params`` carries interpolation
+    values (e.g. network mode, IP addresses) for that text.
+    """
+
+    id: str
+    status: str  # "pass" | "fail" | "warn" | "skip"
+    params: dict = Field(default_factory=dict)
+
+
+class PrinterDiagnosticResult(BaseModel):
+    """Result of a printer connection diagnostic run."""
+
+    printer_id: int | None = None
+    ip_address: str
+    overall: str  # "ok" | "warnings" | "problems"
+    checks: list[DiagnosticCheck]
+
+
+class DiagnosticRequest(BaseModel):
+    """Pre-save (Add Printer) connection diagnostic request.
+
+    serial_number + access_code are optional: when both are present the
+    diagnostic also probes MQTT credentials, otherwise only the
+    network-level checks run.
+    """
+
+    ip_address: str
+    serial_number: str | None = None
+    access_code: str | None = None

+ 8 - 2
backend/app/schemas/slicer.py

@@ -102,8 +102,14 @@ class SliceRequest(BaseModel):
 
     plate: int | None = Field(
         default=None,
-        ge=1,
-        description="Plate number to slice (1-indexed). Defaults to plate 1 on the sidecar.",
+        ge=0,
+        description=(
+            "Plate number to slice. ``None`` defaults to plate 1 on the sidecar "
+            "(matches the pre-multi-plate behaviour). ``0`` is the sidecar's "
+            "'all plates' sentinel — produces a single multi-plate 3MF whose "
+            "``Metadata/plate_N.gcode`` entries cover every plate in the "
+            "source. ``>= 1`` slices that one plate."
+        ),
     )
     export_3mf: bool = Field(
         default=False,

+ 10 - 0
backend/app/schemas/slicer_presets.py

@@ -31,6 +31,15 @@ class UnifiedPreset(BaseModel):
     the multi-color flow by matching against the source 3MF's per-slot type
     and color. Populated when the underlying preset JSON exposes them; left
     as ``None`` on bundled profiles where colour is a runtime spool attribute.
+
+    ``compatible_printers`` is the slicer's own list of printer-preset names a
+    process / filament preset declares itself valid for. Populated for the
+    local tier (stored at import time); left ``None`` for cloud (no per-preset
+    detail is fetched — rate limits) and standard (the sidecar's bundled
+    listing doesn't expose it). The SliceModal uses it to filter the
+    process / filament dropdowns by the selected printer (#1325); when it is
+    ``None`` the modal falls back to the user's uploaded Slicer Bundles, which
+    map each printer to the presets it ships.
     """
 
     id: str
@@ -38,6 +47,7 @@ class UnifiedPreset(BaseModel):
     source: Literal["cloud", "local", "standard"]
     filament_type: str | None = None
     filament_colour: str | None = None
+    compatible_printers: list[str] | None = None
 
 
 class UnifiedPresetsBySlot(BaseModel):

+ 23 - 0
backend/app/schemas/virtual_printer.py

@@ -0,0 +1,23 @@
+"""Schemas for virtual printer diagnostics."""
+
+from pydantic import BaseModel
+
+from backend.app.schemas.printer import DiagnosticCheck
+
+
+class VPDiagnosticResult(BaseModel):
+    """Result of a virtual-printer setup diagnostic run.
+
+    Mirrors ``PrinterDiagnosticResult`` but keyed to a virtual printer: the
+    checks probe the VP's own bind IP and local services rather than a remote
+    printer. ``checks[].id`` values are VP-specific (enabled, running,
+    bind_interface, access_code, target_printer, port_ftps, port_mqtt,
+    port_bind, certificate); the frontend renders the localized title and
+    fix text from id + status.
+    """
+
+    vp_id: int
+    vp_name: str
+    mode: str
+    overall: str  # "ok" | "warnings" | "problems"
+    checks: list[DiagnosticCheck]

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

@@ -316,6 +316,20 @@ class ThreeMFParser:
             if match:
                 self.metadata["total_layers"] = int(match.group(1))
 
+            # Total filament usage. The slicer writes the print's totals into
+            # the G-code header ("; total filament weight [g] : 126.26"). Only
+            # a fallback — slice_info.config is more authoritative when present
+            # — but it covers sliced outputs whose slice_info lacks per-filament
+            # used_g, and it's the slicer's own figure regardless.
+            if "filament_used_grams" not in self.metadata:
+                match = re.search(r";\s*total\s+filament\s+weight\s*\[g\]\s*:\s*([\d.]+)", header, re.IGNORECASE)
+                if match:
+                    self.metadata["filament_used_grams"] = float(match.group(1))
+            if "filament_used_mm" not in self.metadata:
+                match = re.search(r";\s*total\s+filament\s+length\s*\[mm\]\s*:\s*([\d.]+)", header, re.IGNORECASE)
+                if match:
+                    self.metadata["filament_used_mm"] = float(match.group(1))
+
             # Look for printer_model in gcode header (fallback if not found in slice_info)
             # Format: "; printer_model = Bambu Lab X1 Carbon" or "; printer_model = X1C"
             if "sliced_for_model" not in self.metadata:
@@ -509,6 +523,18 @@ class ThreeMFParser:
                 "Metadata/plate_1.png",
                 "Metadata/thumbnail.png",
                 "Metadata/model_thumbnail.png",
+                # Project-wide thumbnail BambuStudio embeds at upload time. We
+                # only reach this when BS hasn't written a per-plate
+                # ``Metadata/plate_N.png`` — most notably the #1493 cross-class
+                # re-slice path where ``--arrange`` rearranges objects but the
+                # CLI then doesn't emit a fresh per-plate preview. The
+                # ``_middle`` size is the editor-quality variant (~500 KB);
+                # ``_small`` and ``_3mf`` are smaller alternates if it's not
+                # present. Without this fallback the re-sliced archive cards
+                # render without a cover image.
+                "Auxiliaries/.thumbnails/thumbnail_middle.png",
+                "Auxiliaries/.thumbnails/thumbnail_small.png",
+                "Auxiliaries/.thumbnails/thumbnail_3mf.png",
             ]
         )
 

+ 15 - 3
backend/app/services/bambu_ftp.py

@@ -35,15 +35,20 @@ class ImplicitFTP_TLS(FTP_TLS):
     A1/A1 Mini printers have issues with SSL on the data channel entirely and
     timeout waiting for transfer completion. Set skip_session_reuse=True for A1
     printers to skip SSL on the data channel (control channel remains encrypted).
+
+    Optionally caps the SSL context's maximum TLS version to v1.2 (P2S firmware
+    01.02.00.00 needs this — see :mod:`ftp_profiles` and #1401).
     """
 
-    def __init__(self, *args, skip_session_reuse: bool = False, **kwargs):
+    def __init__(self, *args, skip_session_reuse: bool = False, cap_tls_v1_2: bool = False, **kwargs):
         super().__init__(*args, **kwargs)
         self._sock = None
         self.skip_session_reuse = skip_session_reuse
         self.ssl_context = ssl.create_default_context()
         self.ssl_context.check_hostname = False
         self.ssl_context.verify_mode = ssl.CERT_NONE
+        if cap_tls_v1_2:
+            self.ssl_context.maximum_version = ssl.TLSVersion.TLSv1_2
 
     def connect(self, host="", port=990, timeout=-999, source_address=None):
         """Connect to host, wrapping socket in TLS immediately (implicit FTPS)."""
@@ -150,11 +155,18 @@ class BambuFTPClient:
         """Connect to the printer FTP server (implicit FTPS on port 990)."""
         try:
             use_prot_c = self._should_use_prot_c()
+            from backend.app.services.ftp_profiles import get_ftp_profile
+
+            profile = get_ftp_profile(self.printer_model)
             logger.debug(
                 f"FTP connecting to {self.ip_address}:{self.FTP_PORT} "
-                f"(timeout={self.timeout}s, model={self.printer_model}, prot_c={use_prot_c})"
+                f"(timeout={self.timeout}s, model={self.printer_model}, prot_c={use_prot_c}, "
+                f"cap_tls_v1_2={profile.cap_tls_v1_2})"
+            )
+            self._ftp = ImplicitFTP_TLS(
+                skip_session_reuse=use_prot_c,
+                cap_tls_v1_2=profile.cap_tls_v1_2,
             )
-            self._ftp = ImplicitFTP_TLS(skip_session_reuse=use_prot_c)
             self._ftp.connect(self.ip_address, self.FTP_PORT, timeout=self.timeout)
             logger.debug("FTP connected, logging in as bblp")
             self._ftp.login("bblp", self.access_code)

+ 137 - 43
backend/app/services/bambu_mqtt.py

@@ -333,6 +333,7 @@ class BambuMQTTClient:
         on_layer_change: Callable[[int], None] | None = None,
         on_bed_temp_update: Callable[[float], None] | None = None,
         on_drying_complete: Callable[[int], None] | None = None,
+        on_print_running_observed: Callable[[dict], None] | None = None,
     ):
         self.ip_address = ip_address
         self.serial_number = serial_number
@@ -348,6 +349,14 @@ class BambuMQTTClient:
         # the drying cycle just finished (auto- or manually-triggered).
         # Receives the AMS id of the unit that finished drying.
         self.on_drying_complete = on_drying_complete
+        # #1485 follow-up: fired the first time we see RUNNING state in a
+        # session WHEN on_print_start was suppressed (Bambuddy started mid-
+        # print, the #1304 first-push guard skipped the start event). Lets
+        # main.py capture a fresh timelapse baseline at restart-recovery
+        # time so the completion-time snapshot-diff still works. Receives
+        # the same shape as on_print_start (filename / subtask_name /
+        # remaining_time / raw_data / ams_mapping).
+        self.on_print_running_observed = on_print_running_observed
         # Per-AMS previous dry_time, used to detect the falling edge above.
         # Seeded lazily as we observe each AMS unit.
         self._previous_dry_times: dict[int, int] = {}
@@ -362,10 +371,23 @@ class BambuMQTTClient:
         self._timelapse_during_print: bool = False  # Track if timelapse was active during this print
         self._last_valid_progress: float = 0.0  # Last non-zero progress (firmware resets on cancel)
         self._last_valid_layer_num: int = 0  # Last non-zero layer (firmware resets on cancel)
+        # The subtask_id minted for the most recent start_print() command. The
+        # printer echoes it back in status, but often not within the first few
+        # seconds — so on_print_start uses this as the id source when the
+        # printer hasn't reported it yet, letting queue/scheduled archives
+        # persist a restart-stable id from the moment they dispatch (#1485).
+        self.last_dispatch_subtask_id: str | None = None
         self._is_dual_nozzle: bool = False  # Set when device.extruder.info has >= 2 entries
         self._message_log: deque[MQTTLogEntry] = deque(maxlen=100)
         self._logging_enabled: bool = False
         self._last_message_time: float = 0.0  # Track when we last received a message
+        # Count of report-topic messages received since the last (re)connect.
+        # Lets check_staleness() distinguish "printer never sent a status
+        # report" (typically a wrong / mis-cased serial) from a normal quiet
+        # gap mid-session. _zero_report_hint_logged keeps the actionable hint
+        # to once per client lifetime so the stale loop doesn't spam it (#1465).
+        self._report_messages_since_connect: int = 0
+        self._zero_report_hint_logged: bool = False
         # Raw-message fan-out for VP MQTT bridge (non-proxy modes republish the
         # printer's pushes verbatim to slicers connected to a virtual printer).
         # Handlers receive (topic, payload_bytes) before JSON parsing.
@@ -467,6 +489,22 @@ class BambuMQTTClient:
             logger.warning(
                 f"[{self.serial_number}] Connection stale - no message for {now - self._last_message_time:.1f}s, forcing reconnect"
             )
+            # A connection that keeps going stale without ever receiving a
+            # status report is almost always a wrong or mis-cased serial
+            # number — the broker accepts the connection and the subscription
+            # regardless, but the printer publishes to device/<real-serial>/
+            # report, which is case-sensitive. Surface that once so the user
+            # has something actionable instead of an endless reconnect loop.
+            if self._report_messages_since_connect == 0 and not self._zero_report_hint_logged:
+                self._zero_report_hint_logged = True
+                logger.warning(
+                    "[%s] Connected and subscribed, but the printer has sent zero "
+                    "status reports. The most common cause is a wrong or mis-cased "
+                    "serial number — the device/<serial>/report MQTT topic is "
+                    "case-sensitive. Verify the serial number configured in Bambuddy "
+                    "exactly matches the printer.",
+                    self.serial_number,
+                )
             self._last_stale_reconnect = now
             self.state.connected = False
             if self.on_state_change:
@@ -587,6 +625,7 @@ class BambuMQTTClient:
             self._dev_mode_probe_time = 0.0
             self._dev_mode_probe_failures = 0
             self._connect_time = time.monotonic()
+            self._report_messages_since_connect = 0
             self._last_ams_cmd_time = 0.0
             self._ams_cmd_unanswered = 0
             client.subscribe(self.topic_subscribe)
@@ -729,6 +768,11 @@ class BambuMQTTClient:
                 self._handle_request_message(payload)
                 return
 
+            # Count status reports per connection so check_staleness() can tell
+            # "printer never sent a report" apart from a mid-session quiet gap.
+            if msg.topic == self.topic_subscribe:
+                self._report_messages_since_connect += 1
+
             # Log message if logging is enabled
             if self._logging_enabled:
                 self._message_log.append(
@@ -918,6 +962,12 @@ class BambuMQTTClient:
                 logger.debug("[%s] Received command response: %s", self.serial_number, cmd)
                 if cmd in ("extrusion_cali_sel", "extrusion_cali_set", "extrusion_cali_del", "ams_filament_setting"):
                     logger.debug("[%s] %s response: %s", self.serial_number, cmd, print_data)
+                # AMS drying responses are rare (user-initiated only) and the
+                # full payload — including `result` and any `reason` code —
+                # is the only way to diagnose silent rejections like #1447.
+                # INFO level so the body lands in support bundles by default.
+                elif cmd == "ams_filament_drying":
+                    logger.info("[%s] ams_filament_drying response: %s", self.serial_number, print_data)
                 # Check for developer mode probe response
                 if (
                     cmd == "ams_filament_setting"
@@ -1708,8 +1758,15 @@ class BambuMQTTClient:
                             merged_trays.append(merged_tray)
                         else:
                             merged_trays.append(new_tray)
-                    # Update ams_unit with merged trays
-                    ams_unit = {**ams_unit, "tray": merged_trays}
+                    # Update ams_unit with merged trays. Spread existing_unit
+                    # FIRST so top-level fields the partial update omits —
+                    # dry_time, info (which drives dry_status / dry_sub_status),
+                    # humidity, temp — are preserved instead of dropped. The
+                    # printer sends tray-bearing partials that carry no drying
+                    # fields; without this, dry_time reads as absent → 0 and the
+                    # falling-edge detector below fires a false "drying complete"
+                    # (#1462). Mirrors the no-tray branch's merge semantics.
+                    ams_unit = {**existing_unit, **ams_unit, "tray": merged_trays}
                 elif existing_unit:
                     # Partial update without tray data: merge new fields into existing
                     # unit to preserve tray, sn, sw_ver, and other accumulated data.
@@ -1866,10 +1923,18 @@ class BambuMQTTClient:
                     continue
                 if ams_id < 0:
                     continue
+                # Only evaluate the edge when this update carries an explicit
+                # dry_time. An absent / unparseable value is NOT zero — treating
+                # it as 0 lets a tray-only partial fake a drying-complete edge
+                # (#1462). Skip without touching the remembered value so the
+                # next update that DOES carry dry_time sees the true previous.
+                raw_dry_time = ams_unit.get("dry_time")
+                if raw_dry_time is None:
+                    continue
                 try:
-                    current = int(ams_unit.get("dry_time") or 0)
+                    current = int(raw_dry_time)
                 except (TypeError, ValueError):
-                    current = 0
+                    continue
                 previous = self._previous_dry_times.get(ams_id, 0)
                 self._previous_dry_times[ams_id] = current
                 if previous > 0 and current == 0:
@@ -2853,6 +2918,7 @@ class BambuMQTTClient:
         )
 
         # Track RUNNING state for more robust completion detection
+        running_first_observed = False
         if self.state.state == "RUNNING" and current_file:
             if not self._was_running:
                 logger.debug("[%s] Now tracking RUNNING state for %s", self.serial_number, current_file)
@@ -2860,6 +2926,14 @@ class BambuMQTTClient:
                 if self.state.timelapse:
                     self._timelapse_during_print = True
                     logger.debug("[%s] Timelapse detected when entering RUNNING state", self.serial_number)
+                # Mark this as the first RUNNING observation of the session.
+                # If is_new_print also fires below, on_print_start handles
+                # baseline capture and we suppress on_print_running_observed
+                # to avoid double-capture. If is_new_print does NOT fire
+                # (Bambuddy started mid-print — the #1304 guard suppressed
+                # it), main.py needs this hook to catch the restart-recovery
+                # case (#1485 follow-up).
+                running_first_observed = True
             self._was_running = True
             self._completion_triggered = False
 
@@ -2905,6 +2979,25 @@ class BambuMQTTClient:
                     "ams_mapping": self._captured_ams_mapping,
                 }
             )
+        elif running_first_observed and self.on_print_running_observed:
+            # Restart-recovery hook (#1485 follow-up): Bambuddy started mid-
+            # print, so the #1304 first-push guard suppressed on_print_start,
+            # but we still need main.py to capture a fresh timelapse baseline
+            # before the printer uploads the in-flight MP4. Same payload
+            # shape as on_print_start so the consumer can reuse fields.
+            logger.info(
+                f"[{self.serial_number}] RUNNING observed without PRINT START "
+                f"(restart-recovery) - file: {current_file}, subtask: {self.state.subtask_name}"
+            )
+            self.on_print_running_observed(
+                {
+                    "filename": current_file,
+                    "subtask_name": self.state.subtask_name,
+                    "remaining_time": self.state.remaining_time * 60 if self.state.remaining_time > 0 else None,
+                    "raw_data": data,
+                    "ams_mapping": self._captured_ams_mapping,
+                }
+            )
 
         # Detect print completion (FINISH = success, FAILED = error, IDLE = aborted)
         # Use _was_running flag in addition to _previous_gcode_state for more robust detection
@@ -3212,21 +3305,17 @@ class BambuMQTTClient:
             use_ams: Use AMS for automatic filament changes
         """
         if self._client and self.state.connected:
-            # Bambu print command format - matches Bambu Studio's format
-            # H2-family firmware (H2D, H2D Pro, H2C, H2S, X2D) requires integer
-            # values (0/1) for calibration/leveling fields. X1C/P1S/A1/P2S need
-            # actual booleans. use_ams stays boolean across the board — H2D Pro
-            # firmware interprets integer use_ams as nozzle index (1 = deputy),
-            # causing wrong extruder routing (#1386 root cause was here too: the
-            # old flag conflated firmware-format with dual-nozzle routing).
-            is_h_family = self.model and self.model.upper().strip() in (
-                "H2D",
-                "H2D PRO",
-                "H2DPRO",
-                "H2C",
-                "H2S",
-                "X2D",
-            )
+            # Bambu print command format — matches Bambu Studio's format.
+            # The calibration/leveling fields (timelapse, bed_leveling,
+            # flow_cali, vibration_cali, layer_inspect) are JSON booleans for
+            # every model. An earlier revision integer-encoded them for the H2
+            # family (H2D/H2S/H2C/X2D) on the belief that H2 firmware required
+            # 0/1 — but a BambuStudio request-topic capture from a real H2D
+            # sends plain booleans, and the integer encoding made the H2S
+            # silently skip flow-dynamics calibration (#1478). use_ams is the
+            # one field that genuinely must stay boolean: H2D Pro firmware
+            # reads an integer use_ams as a nozzle index (1 = deputy), which is
+            # what actually caused the wrong-extruder routing behind #1386.
             # Dual-nozzle routing for external spool (254 = deputy/left,
             # 255 = main/right) and the use_ams=False fallback. H2S is in the
             # H2 firmware family but is single-nozzle, despite sharing serial
@@ -3235,9 +3324,9 @@ class BambuMQTTClient:
             # model name for the brief window after connect before push data
             # arrives. _is_dual_nozzle only ever flips False→True, so it's safe
             # as the primary signal.
-            is_dual_nozzle = self._is_dual_nozzle or (
-                self.model and self.model.upper().strip() in ("H2D", "H2D PRO", "H2DPRO", "H2C", "X2D")
-            )
+            from backend.app.utils.printer_models import is_dual_nozzle_model
+
+            is_dual_nozzle = self._is_dual_nozzle or is_dual_nozzle_model(self.model)
 
             # Build ams_mapping2 from ams_mapping (detailed format with ams_id/slot_id)
             ams_mapping2 = []
@@ -3312,6 +3401,9 @@ class BambuMQTTClient:
             # Modulo keeps uniqueness within a ~24-day wrap window; `or 1` guards
             # the (astronomically unlikely) zero case since task_id=0 is rejected.
             submission_id = str(int(time.time() * 1000) % 2_147_483_647 or 1)
+            # Remember it so on_print_start can persist a restart-stable id on
+            # the archive even before the printer echoes subtask_id back (#1485).
+            self.last_dispatch_subtask_id = submission_id
 
             command = {
                 "print": {
@@ -3322,15 +3414,20 @@ class BambuMQTTClient:
                     "file": filename,
                     "md5": "",
                     "bed_type": "auto",
-                    "timelapse": (1 if timelapse else 0) if is_h_family else timelapse,
-                    "bed_leveling": (1 if bed_levelling else 0) if is_h_family else bed_levelling,
+                    "timelapse": timelapse,
+                    "bed_leveling": bed_levelling,
                     "auto_bed_leveling": 1 if bed_levelling else 0,
-                    "flow_cali": (1 if flow_cali else 0) if is_h_family else flow_cali,
-                    "vibration_cali": (1 if vibration_cali else 0) if is_h_family else vibration_cali,
-                    "layer_inspect": (1 if layer_inspect else 0) if is_h_family else layer_inspect,
+                    "flow_cali": flow_cali,
+                    "vibration_cali": vibration_cali,
+                    "layer_inspect": layer_inspect,
                     "use_ams": use_ams,
                     "cfg": "0",
-                    "extrude_cali_flag": 0,
+                    # extrude_cali_flag gates flow-dynamics calibration:
+                    # 1 = run it, 2 = skip and reuse the stored PA value.
+                    # BambuStudio always pairs this with flow_cali and never
+                    # sends 0; a hardcoded 0 made the printer skip calibration
+                    # regardless of the flow_cali toggle (#1478).
+                    "extrude_cali_flag": 1 if flow_cali else 2,
                     "extrude_cali_manual_mode": 0,
                     "nozzle_offset_cali": 2,
                     "subtask_name": filename.replace(".3mf", "").replace(".gcode", ""),
@@ -3341,12 +3438,6 @@ class BambuMQTTClient:
                 }
             }
 
-            if is_h_family:
-                logger.debug(
-                    "[%s] H-family firmware detected: using integer format for calibration fields (use_ams stays boolean)",
-                    self.serial_number,
-                )
-
             # P2S-specific parameter adjustments
             # P2S printer doesn't support vibration calibration like X1/P1 series
             if self.model and self.model.upper().strip() in ("P2S", "N7"):
@@ -3703,14 +3794,17 @@ class BambuMQTTClient:
                 "close_power_conflict": False,
             }
         }
-        self._client.publish(self.topic_publish, json.dumps(command), qos=1)
+        # Log the full wire JSON at INFO so support bundles capture exactly
+        # what we sent — needed to diagnose silent rejections (#1447) where
+        # the printer ACKs the command but never starts/stops drying.
+        # Paired with the ams_filament_drying response-payload INFO log so
+        # both halves of the conversation land in the bundle by default.
+        wire_json = json.dumps(command)
+        self._client.publish(self.topic_publish, wire_json, qos=1)
         logger.info(
-            "[%s] Sent drying command: ams_id=%d, temp=%d, duration=%d, mode=%d",
+            "[%s] Sent ams_filament_drying: %s",
             self.serial_number,
-            ams_id,
-            temp,
-            duration,
-            mode,
+            wire_json,
         )
         return True
 
@@ -4060,9 +4154,9 @@ class BambuMQTTClient:
         # Prefer runtime detection from device.extruder.info; fall back to
         # model name. H2S is single-nozzle but shares serial prefix "094" with
         # H2D, so a prefix-only check misclassified it (#1386).
-        is_dual_nozzle = self._is_dual_nozzle or (
-            self.model and self.model.upper().strip() in ("H2D", "H2D PRO", "H2DPRO", "H2C", "X2D")
-        )
+        from backend.app.utils.printer_models import is_dual_nozzle_model
+
+        is_dual_nozzle = self._is_dual_nozzle or is_dual_nozzle_model(self.model)
 
         if is_dual_nozzle:
             # H2D format: uses extruder_id, nozzle_id, nozzle_diameter

+ 67 - 0
backend/app/services/camera.py

@@ -11,6 +11,7 @@ import os
 import shutil
 import ssl
 import struct
+import subprocess
 import uuid
 from datetime import datetime
 from pathlib import Path
@@ -24,6 +25,9 @@ JPEG_END = b"\xff\xd9"
 # Cache the ffmpeg path after first lookup
 _ffmpeg_path: str | None = None
 
+# Cached result of rtsp_socket_timeout_flag(); see that function for context.
+_rtsp_socket_timeout_flag: str | None = None
+
 # Track PIDs of ffmpeg processes spawned for one-shot frame capture (snapshot).
 # The cleanup task in routes/camera.py checks this set to avoid killing active captures.
 _active_capture_pids: set[int] = set()
@@ -66,6 +70,69 @@ def get_ffmpeg_path() -> str | None:
     return ffmpeg_path
 
 
+def rtsp_socket_timeout_flag() -> str:
+    """Return the ffmpeg argv flag (without the leading dash) that sets the
+    RTSP demuxer's client-side TCP socket I/O timeout, in microseconds.
+
+    ffmpeg has shipped three different option arrangements for this over
+    time, and Bambuddy supports the full range:
+
+    - **Modern ffmpeg (5.x / 6.x / 7.x)** — Debian 13, Ubuntu 24.04, current
+      Homebrew, etc. ``-timeout`` is the socket I/O timeout (microseconds);
+      ``-stimeout`` was REMOVED.
+    - **Transitional ffmpeg (~late-4.x, some 5.x builds)** — Ubuntu 22.04's
+      shipped version is one of these. ``-timeout`` was deprecated and
+      *repurposed* to mean the RTSP listen-mode incoming-connection
+      timeout — and any non-zero value implies ``-listen``, which makes
+      ffmpeg bind the localhost proxy port and fail with EADDRINUSE
+      (#1504). ``-stimeout`` was the replacement socket I/O timeout in
+      that window.
+    - **Old ffmpeg (early 4.x and earlier)** — ``-timeout`` is socket I/O
+      timeout (the original meaning, before the deprecation churn).
+
+    We probe ``-h demuxer=rtsp`` once and cache: if ``-stimeout`` is
+    advertised, prefer it (covers the transitional window and stays
+    correct on the older builds that still accept it as an alias); else
+    fall back to ``-timeout`` (correct on modern and pre-deprecation
+    ffmpeg). The result is cached for the process lifetime — ffmpeg
+    isn't going to swap mid-run.
+
+    Returns the option name without the leading dash, e.g. ``"timeout"``
+    or ``"stimeout"``. Callers must prepend ``-`` themselves so a string
+    formatting bug can't pass an empty flag.
+    """
+    global _rtsp_socket_timeout_flag
+
+    if _rtsp_socket_timeout_flag is not None:
+        return _rtsp_socket_timeout_flag
+
+    ffmpeg = get_ffmpeg_path()
+    chosen = "timeout"  # safe default for modern ffmpeg
+    if ffmpeg:
+        try:
+            result = subprocess.run(
+                [ffmpeg, "-hide_banner", "-h", "demuxer=rtsp"],
+                capture_output=True,
+                text=True,
+                timeout=5,
+                check=False,
+            )
+            help_text = (result.stdout or "") + (result.stderr or "")
+            # Help lines list each option as `-<name> ` (trailing space) — match
+            # that exact form so we don't accidentally hit a substring elsewhere.
+            if "-stimeout " in help_text:
+                chosen = "stimeout"
+        except (OSError, subprocess.SubprocessError) as exc:
+            # If probing fails, keep the modern-ffmpeg default. Worst case
+            # is the EADDRINUSE regression returns for transitional-ffmpeg
+            # users — same as before this function existed.
+            logger.warning("Could not probe ffmpeg RTSP timeout flag, defaulting to -timeout: %s", exc)
+
+    _rtsp_socket_timeout_flag = chosen
+    logger.info("RTSP socket I/O timeout flag: -%s", chosen)
+    return chosen
+
+
 def supports_rtsp(model: str | None) -> bool:
     """Check if printer model supports RTSP camera streaming.
 

+ 15 - 4
backend/app/services/camera_profiles.py

@@ -77,13 +77,24 @@ DEFAULT_PROFILE = CameraProfile()
 # AFTER alias normalisation, so internal SSDP codes ("N7") resolve via
 # ``_MODEL_ALIASES`` below.
 _PROFILES: dict[str, CameraProfile] = {
-    # P2S firmware 01.02.00.00 RTSP keyframe pacing is slow enough that
-    # ffmpeg's "32-byte probe + zero analyze" combo can't estimate the
-    # frame rate. ffmpeg's own stderr literally says "consider increasing
-    # probesize" (#1395 follow-up).
+    # P2S firmware 01.02.00.00 has two RTSP quirks, both surfaced by #1395:
+    #
+    # 1. Slow keyframe pacing — ffmpeg's "32-byte probe + zero analyze"
+    #    combo can't estimate the frame rate ("consider increasing
+    #    probesize"). Fixed by the relaxed probesize/analyzeduration below.
+    #
+    # 2. Non-advancing RTP timestamps — every frame is stamped at ~t=0.06s.
+    #    With ffmpeg's default CFR rate conversion (`-r 15`), this freezes
+    #    the output clock after the first frame and drops every subsequent
+    #    frame as a same-timestamp duplicate (ffmpeg stderr: `frame=1
+    #    time=00:00:00.06 dup=0 drop=526`). `-use_wallclock_as_timestamps 1`
+    #    regenerates each packet's PTS from arrival wall-clock time, so the
+    #    output clock advances and CFR conversion works. X1/H2 send correct
+    #    timestamps and need no override.
     "P2S": CameraProfile(
         probesize=1_000_000,
         analyzeduration=500_000,
+        extra_ffmpeg_input_args=("-use_wallclock_as_timestamps", "1"),
     ),
 }
 

+ 207 - 0
backend/app/services/diagnostic_snapshot.py

@@ -0,0 +1,207 @@
+"""Aggregate connection, virtual-printer, and log-health diagnostics into a
+single snapshot for the support bundle and bug-report submission paths.
+
+Each user-triggered support artifact (the System-page support ZIP and the
+bug-report bubble) already exposed these three checks inline in the UI but
+omitted them from what landed in the maintainer's hands. This module is the
+single entry point both flows call to capture all three at once.
+
+Designed around three constraints:
+
+- **Fail-soft per probe.** A crash inside one printer's check must not nuke the
+  whole snapshot — that's the whole point of including diagnostics in the
+  bundle: a partial result is more useful than a 500.
+- **Bounded total runtime.** Each probe runs concurrently and is guarded by an
+  outer wall-clock cap; timeouts emit a marker entry rather than blocking.
+- **No mutation.** Connection / VP diagnostics only probe TCP ports and read
+  state; log-health is a passive scanner. Safe to run on every bundle.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import re
+from typing import Any
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+logger = logging.getLogger(__name__)
+
+# Mirrors the IPv4 pattern in services.log_reader.sanitize_log_content. Kept as
+# a literal here (not imported) so a refactor of that module's internals can't
+# silently change snapshot sanitization. Skips firmware-version-shaped strings
+# (leading-zero octets like "01.09.01.00") via the [1-9]\d|\d alternations.
+_IPV4_RE = re.compile(r"\b(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)\b")
+
+# Per-diagnostic wall-clock cap. Each underlying probe carries its own (smaller)
+# TCP / HTTP timeouts; this is the outer guard so a hung interface or a wedged
+# subprocess can't stall bundle generation past about this many seconds per
+# printer/VP. Snapshot total runtime is bounded by max(per-cap) thanks to the
+# concurrent gather, not the sum.
+_PER_DIAGNOSTIC_TIMEOUT_SECONDS = 15.0
+
+
+def _serialize(result: Any) -> Any:
+    """Convert a Pydantic model to a dict; pass through plain dicts/lists."""
+    if hasattr(result, "model_dump"):
+        return result.model_dump()
+    return result
+
+
+async def _run_connection_for(printer) -> dict:
+    from backend.app.services.printer_diagnostic import run_connection_diagnostic
+
+    base = {"printer_id": printer.id, "printer_name": printer.name}
+    try:
+        result = await asyncio.wait_for(
+            run_connection_diagnostic(
+                printer.ip_address,
+                printer=printer,
+                serial_number=printer.serial_number,
+                access_code=printer.access_code,
+            ),
+            timeout=_PER_DIAGNOSTIC_TIMEOUT_SECONDS,
+        )
+        return {**base, "result": _serialize(result)}
+    except asyncio.TimeoutError:
+        return {**base, "error": "timed_out"}
+    except Exception as e:
+        # Log with traceback so the bundle generation isn't silent about
+        # a broken probe, but never propagate.
+        logger.warning("Connection diagnostic failed for printer %s: %s", printer.id, e, exc_info=True)
+        return {**base, "error": str(e)}
+
+
+async def _run_vp_for(vp) -> dict:
+    from backend.app.services.virtual_printer import virtual_printer_manager
+    from backend.app.services.virtual_printer.diagnostic import run_vp_diagnostic
+
+    base = {"vp_id": vp.id, "name": vp.name}
+    try:
+        instance = virtual_printer_manager.get_instance(vp.id)
+        result = await asyncio.wait_for(
+            run_vp_diagnostic(vp, instance),
+            timeout=_PER_DIAGNOSTIC_TIMEOUT_SECONDS,
+        )
+        return {**base, "result": _serialize(result)}
+    except asyncio.TimeoutError:
+        return {**base, "error": "timed_out"}
+    except Exception as e:
+        logger.warning("VP diagnostic failed for VP %s: %s", vp.id, e, exc_info=True)
+        return {**base, "error": str(e)}
+
+
+async def _run_log_health() -> Any:
+    from backend.app.services.log_health import scan_logs
+
+    try:
+        # scan_logs is sync I/O-bound (file read + regex); push off the loop.
+        result = await asyncio.wait_for(
+            asyncio.to_thread(scan_logs),
+            timeout=_PER_DIAGNOSTIC_TIMEOUT_SECONDS,
+        )
+        return _serialize(result)
+    except asyncio.TimeoutError:
+        return {"error": "timed_out"}
+    except Exception as e:
+        logger.warning("Log-health scan failed: %s", e, exc_info=True)
+        return {"error": str(e)}
+
+
+async def collect_diagnostic_snapshot(db: AsyncSession) -> dict[str, Any]:
+    """Return the three-section diagnostic snapshot.
+
+    Always returns a dict with keys ``connection_diagnostics`` (list, one entry
+    per active printer), ``vp_diagnostics`` (list, one entry per enabled VP —
+    empty if none), and ``log_health`` (the ``scan_logs`` result or an error
+    marker). Each list entry carries either ``result`` (success) or ``error``
+    (timeout / exception) so the maintainer can tell at a glance whether a
+    given probe ran.
+    """
+    from backend.app.models.printer import Printer
+    from backend.app.models.virtual_printer import VirtualPrinter
+
+    printers_result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
+    printers = list(printers_result.scalars().all())
+
+    vps_result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.enabled.is_(True)))
+    vps = list(vps_result.scalars().all())
+
+    # Concurrent: total wall-clock ≈ max(per-cap), not sum.
+    results = await asyncio.gather(
+        asyncio.gather(*(_run_connection_for(p) for p in printers)) if printers else _noop_list(),
+        asyncio.gather(*(_run_vp_for(vp) for vp in vps)) if vps else _noop_list(),
+        _run_log_health(),
+        return_exceptions=True,
+    )
+    connection_results, vp_results, log_health = results
+
+    def _coerce_list(r) -> list:
+        if isinstance(r, BaseException):
+            logger.warning("Diagnostic snapshot batch failed: %s", r)
+            return []
+        return list(r) if r is not None else []
+
+    snapshot = {
+        "connection_diagnostics": _coerce_list(connection_results),
+        "vp_diagnostics": _coerce_list(vp_results),
+        "log_health": log_health if not isinstance(log_health, BaseException) else {"error": str(log_health)},
+    }
+
+    # Sanitize before returning. The diagnostic schemas embed printer/host IPs
+    # (`PrinterDiagnosticResult.ip_address`, network-mode check params, VP
+    # `bind_ip`) and the snapshot adds printer names — none of which should
+    # leak into a submitted GitHub issue or a shared support ZIP. Use the
+    # same `collect_sensitive_strings` table the log sanitizer already
+    # consults so the replacement labels stay consistent ([PRINTER], [SERIAL],
+    # [IP], [ACCESS_CODE]); the IPv4 regex fallback in `_mask_string` then
+    # catches host / bind IPs that aren't in the DB.
+    try:
+        from backend.app.services.log_reader import collect_sensitive_strings
+
+        sensitive_strings = await collect_sensitive_strings(db)
+    except Exception:
+        logger.warning("Could not collect sensitive strings for snapshot sanitization", exc_info=True)
+        sensitive_strings = {}
+    return _sanitize_recursive(snapshot, sensitive_strings)
+
+
+async def _noop_list() -> list:
+    return []
+
+
+def _mask_string(value: str, sensitive_strings: dict[str, str]) -> str:
+    """Apply known-value replacement + IPv4 regex masking to a single string.
+
+    Known values are matched first (longest first so "My Printer 1" beats
+    "My Printer"); the regex pass then catches any IPs the sensitive_strings
+    table didn't already cover — most importantly the Bambuddy host's own
+    IP (returned by ``_get_host_ip`` inside the diagnostic, not in the DB)
+    and any virtual-printer ``bind_ip`` the user picked at setup.
+    """
+    if not value:
+        return value
+    for raw, label in sorted(sensitive_strings.items(), key=lambda x: len(x[0]), reverse=True):
+        if len(raw) < 3:
+            continue
+        if raw in value:
+            value = value.replace(raw, label)
+    value = _IPV4_RE.sub("[IP]", value)
+    return value
+
+
+def _sanitize_recursive(node: Any, sensitive_strings: dict[str, str]) -> Any:
+    """Walk the snapshot and redact strings in place — dicts, lists, scalars.
+
+    Non-string scalars (ints, bools, None) pass through; we only need to
+    mask user-visible values. Keys are NOT renamed (those are structural).
+    """
+    if isinstance(node, str):
+        return _mask_string(node, sensitive_strings)
+    if isinstance(node, dict):
+        return {k: _sanitize_recursive(v, sensitive_strings) for k, v in node.items()}
+    if isinstance(node, list):
+        return [_sanitize_recursive(item, sensitive_strings) for item in node]
+    return node

+ 5 - 1
backend/app/services/external_camera.py

@@ -683,6 +683,8 @@ async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
         logger.error("ffmpeg not found - required for RTSP streaming")
         return
 
+    from backend.app.services.camera import rtsp_socket_timeout_flag
+
     # If the URL uses rtsps://, set up a TLS proxy so ffmpeg uses plain rtsp://
     proxy_server = None
     effective_url = url
@@ -715,7 +717,9 @@ async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
         "tcp",
         "-rtsp_flags",
         "prefer_tcp",
-        "-timeout",
+        # Socket I/O timeout name varies by ffmpeg version (#1504); see
+        # `rtsp_socket_timeout_flag()` in services.camera.
+        f"-{rtsp_socket_timeout_flag()}",
         "30000000",
         "-buffer_size",
         "1024000",

+ 287 - 0
backend/app/services/filament_deficit.py

@@ -0,0 +1,287 @@
+"""Filament-deficit check used by every queue dispatch path.
+
+The PrintModal warns when an assigned spool can't satisfy a print's per-slot
+filament weight (``Pre-print checks now also warn when the spool has
+insufficient material`` — #720). That check only runs when the user clicks
+"Print" inside PrintModal; ``QueuePage`` Play button, ``start_queue_item``
+route, and the VP intake + scheduler auto-dispatch path all skip it (#1496).
+
+This module is the single source of truth for the check. Both the route
+handler (``POST /print-queue/{id}/start``) and the dispatch scheduler call
+``compute_deficit_for_queue_item`` against live spool state.
+
+Design notes:
+* The 3MF parser is the same one used by PrintModal: per-slot ``used_grams``
+  comes from ``extract_filament_requirements`` (#1188's filament-overrides
+  pipeline) or — when the item points at an unsliced library file — falls
+  through to the file's archive copy. Anything that yields no requirements
+  is treated as "no deficit" so a malformed or stripped 3MF never blocks.
+* Both internal-inventory and Spoolman modes are covered. Internal mode
+  resolves via ``SpoolAssignment`` joined to ``Spool`` (``label_weight``
+  minus ``weight_used``). Spoolman mode resolves via
+  ``SpoolmanSlotAssignment`` then ``SpoolmanClient.get_spool`` for the live
+  remaining weight; if Spoolman is unreachable we return no deficit rather
+  than wedge the queue on a flaky network call.
+* The ``disable_filament_warnings`` user setting is respected at the
+  service boundary — callers do not have to know about it.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from dataclasses import dataclass
+from pathlib import Path
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from backend.app.core.config import settings as app_settings
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.spool_assignment import SpoolAssignment
+from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+from backend.app.services.filament_requirements import extract_filament_requirements
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True)
+class FilamentDeficit:
+    """One slot's filament shortfall."""
+
+    slot_id: int
+    ams_id: int | None
+    tray_id: int | None
+    filament_type: str
+    required_grams: float
+    remaining_grams: float | None  # None = could not determine
+
+    def to_dict(self) -> dict:
+        return {
+            "slot_id": self.slot_id,
+            "ams_id": self.ams_id,
+            "tray_id": self.tray_id,
+            "filament_type": self.filament_type,
+            "required_grams": self.required_grams,
+            "remaining_grams": self.remaining_grams,
+        }
+
+
+def _global_to_ams_key(global_tray_id: int) -> tuple[int, int]:
+    """Inverse of ``ams_id * 4 + tray_id`` — matches ``usage_tracker``."""
+    if global_tray_id >= 254:
+        return (255, global_tray_id - 254)
+    if global_tray_id >= 128:
+        return (global_tray_id, 0)
+    return (global_tray_id // 4, global_tray_id % 4)
+
+
+def _resolve_source_3mf(item: PrintQueueItem) -> Path | None:
+    """Locate the 3MF file backing this queue item (archive or library)."""
+    if item.archive is not None and item.archive.file_path:
+        return app_settings.base_dir / item.archive.file_path
+    if item.library_file is not None and item.library_file.file_path:
+        return Path(item.library_file.file_path)
+    return None
+
+
+async def _spoolman_remaining_grams(spoolman_spool_id: int) -> float | None:
+    """Live remaining grams for a Spoolman spool, or None if unavailable."""
+    try:
+        from backend.app.services.spoolman import (
+            SpoolmanClientError,
+            SpoolmanNotFoundError,
+            get_spoolman_client,
+        )
+    except ImportError:
+        return None
+    try:
+        client = await get_spoolman_client()
+        if client is None:
+            return None
+        spool = await client.get_spool(spoolman_spool_id)
+    except (SpoolmanNotFoundError, SpoolmanClientError):
+        return None
+    except Exception as e:
+        logger.debug("Spoolman fetch failed for spool %s: %s", spoolman_spool_id, e)
+        return None
+
+    if not spool:
+        return None
+
+    # Spoolman exposes either an absolute remaining_weight, or used_weight +
+    # filament.weight. Either is sufficient — prefer remaining_weight when
+    # present (the user may have overridden it).
+    remaining = spool.get("remaining_weight")
+    if isinstance(remaining, (int, float)) and remaining >= 0:
+        return float(remaining)
+
+    used = spool.get("used_weight")
+    filament = spool.get("filament") or {}
+    total = filament.get("weight")
+    if isinstance(used, (int, float)) and isinstance(total, (int, float)) and total > 0:
+        return max(0.0, float(total) - float(used))
+
+    return None
+
+
+async def _is_spoolman_mode(db: AsyncSession) -> bool:
+    """Check whether the user has opted in to Spoolman inventory mode."""
+    try:
+        from backend.app.api.routes.settings import get_setting
+
+        spoolman_enabled = await get_setting(db, "spoolman_enabled")
+        return bool(spoolman_enabled) and spoolman_enabled.lower() == "true"
+    except Exception:
+        return False
+
+
+async def _warnings_disabled(db: AsyncSession) -> bool:
+    """Honour the ``disable_filament_warnings`` setting (#720)."""
+    try:
+        from backend.app.api.routes.settings import get_setting
+
+        disabled = await get_setting(db, "disable_filament_warnings")
+        return bool(disabled) and disabled.lower() == "true"
+    except Exception:
+        return False
+
+
+def _parse_ams_mapping(raw: str | None) -> list[int] | None:
+    if not raw:
+        return None
+    try:
+        parsed = json.loads(raw)
+    except (json.JSONDecodeError, TypeError):
+        return None
+    if not isinstance(parsed, list):
+        return None
+    return [v for v in parsed if isinstance(v, int)]
+
+
+async def compute_deficit_for_queue_item(
+    db: AsyncSession,
+    item: PrintQueueItem,
+) -> list[FilamentDeficit]:
+    """Return per-slot filament shortfalls for ``item``, or [] when it's safe to dispatch.
+
+    Returns an empty list whenever any of the following hold:
+
+    * The ``disable_filament_warnings`` setting is on.
+    * The item has no resolved ``printer_id`` (model-based assignment not
+      yet picked a printer — the scheduler re-runs the check after it does).
+    * No source 3MF is available, or the 3MF carries no per-slot
+      requirements (treated as "nothing to verify" rather than an error,
+      matching the PrintModal behaviour).
+    * No AMS mapping is set yet — the scheduler computes the mapping just
+      before dispatch; until it does we cannot map slot → tray.
+    * Spoolman mode is on but the Spoolman server is unreachable. We do not
+      wedge the queue on a network blip.
+    """
+    if await _warnings_disabled(db):
+        return []
+    if item.printer_id is None:
+        return []
+
+    # Refresh the relationships we need without assuming the caller eagerly
+    # loaded them — both the route and the scheduler call this from contexts
+    # with different loading strategies.
+    refreshed = await db.execute(
+        select(PrintQueueItem)
+        .options(
+            selectinload(PrintQueueItem.archive),
+            selectinload(PrintQueueItem.library_file),
+        )
+        .where(PrintQueueItem.id == item.id)
+    )
+    item = refreshed.scalar_one_or_none() or item
+
+    source_path = _resolve_source_3mf(item)
+    if source_path is None or not source_path.exists():
+        return []
+
+    requirements = extract_filament_requirements(source_path, item.plate_id)
+    if not requirements:
+        return []
+
+    mapping = _parse_ams_mapping(item.ams_mapping)
+    if not mapping:
+        return []
+
+    spoolman_mode = await _is_spoolman_mode(db)
+
+    deficits: list[FilamentDeficit] = []
+    for req in requirements:
+        slot_id = req.get("slot_id")
+        used_grams = req.get("used_grams")
+        if not isinstance(slot_id, int) or slot_id <= 0:
+            continue
+        if not isinstance(used_grams, (int, float)) or used_grams <= 0:
+            continue
+        idx = slot_id - 1
+        if idx >= len(mapping):
+            continue
+        global_tray_id = mapping[idx]
+        if global_tray_id is None or global_tray_id < 0:
+            continue
+        ams_id, tray_id = _global_to_ams_key(global_tray_id)
+
+        remaining: float | None = None
+        if spoolman_mode:
+            sm_result = await db.execute(
+                select(SpoolmanSlotAssignment).where(
+                    SpoolmanSlotAssignment.printer_id == item.printer_id,
+                    SpoolmanSlotAssignment.ams_id == ams_id,
+                    SpoolmanSlotAssignment.tray_id == tray_id,
+                )
+            )
+            sm_assignment = sm_result.scalar_one_or_none()
+            if sm_assignment is None:
+                continue
+            remaining = await _spoolman_remaining_grams(sm_assignment.spoolman_spool_id)
+        else:
+            internal_result = await db.execute(
+                select(SpoolAssignment)
+                .options(selectinload(SpoolAssignment.spool))
+                .where(
+                    SpoolAssignment.printer_id == item.printer_id,
+                    SpoolAssignment.ams_id == ams_id,
+                    SpoolAssignment.tray_id == tray_id,
+                )
+            )
+            assignment = internal_result.scalar_one_or_none()
+            if assignment is None or assignment.spool is None:
+                continue
+            spool = assignment.spool
+            label_weight = float(spool.label_weight or 0)
+            weight_used = float(spool.weight_used or 0)
+            if label_weight <= 0:
+                continue
+            remaining = max(0.0, label_weight - weight_used)
+
+        if remaining is None:
+            # Spoolman unreachable for this spool — skip rather than block.
+            continue
+        if remaining >= float(used_grams):
+            continue
+
+        deficits.append(
+            FilamentDeficit(
+                slot_id=slot_id,
+                ams_id=ams_id,
+                tray_id=tray_id,
+                filament_type=str(req.get("type", "")),
+                required_grams=float(used_grams),
+                remaining_grams=remaining,
+            )
+        )
+
+    return deficits
+
+
+# Re-export the most useful pieces for callers that just want the data.
+__all__ = [
+    "FilamentDeficit",
+    "compute_deficit_for_queue_item",
+]

+ 19 - 1
backend/app/services/filament_requirements.py

@@ -66,7 +66,25 @@ def extract_filament_requirements(file_path: Path, plate_id: int | None = None)
                         _collect_filaments(plate_elem, filaments)
                         break
             else:
-                _collect_filaments(root, filaments)
+                # Modern BambuStudio format wraps filaments inside <plate> elements.
+                # When no plate filter is requested, collect from every plate and
+                # deduplicate by slot_id (first occurrence wins after sort).
+                plate_elems = root.findall("./plate")
+                if plate_elems:
+                    for plate_elem in plate_elems:
+                        _collect_filaments(plate_elem, filaments)
+                    # Deduplicate: same slot_id can appear on multiple plates.
+                    # Keep the entry with the highest used_grams; ties go to the
+                    # first plate (stable after sort + dict insertion order).
+                    seen: dict[int, dict] = {}
+                    for f in filaments:
+                        sid = f["slot_id"]
+                        if sid not in seen or f["used_grams"] > seen[sid]["used_grams"]:
+                            seen[sid] = f
+                    filaments = list(seen.values())
+                else:
+                    # Older / non-plate-wrapped format: filaments are direct children of root.
+                    _collect_filaments(root, filaments)
 
             filaments.sort(key=lambda x: x["slot_id"])
 

+ 99 - 0
backend/app/services/ftp_profiles.py

@@ -0,0 +1,99 @@
+"""Per-printer-model FTP tuning knobs.
+
+Mirrors the shape of :mod:`backend.app.services.camera_profiles` — a
+small registry of per-model overrides so quirky firmwares can be
+tuned without sprinkling ``if model == "X":`` branches through
+``bambu_ftp.py``. Adding a new model's quirk is a config edit (an
+entry in ``_PROFILES`` plus the alias for its internal SSDP code if
+needed), not another hard-coded branch.
+
+The default profile matches the historical pre-fix behaviour, so
+every model that doesn't have an entry here keeps its existing FTP
+behaviour byte-for-byte.
+
+Currently only the TLS-version cap lives here (P2S firmware
+01.02.00.00 needs it — see ``cap_tls_v1_2`` below). The A1
+data-channel-plaintext quirk still lives in :class:`BambuFTPClient`
+via ``A1_MODELS`` / ``skip_session_reuse``; folding that into a
+profile field is a future cleanup, not load-bearing for this fix.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class FTPProfile:
+    """Tuning knobs for one printer model's FTP path.
+
+    All defaults reflect the historical behaviour. Models with quirky
+    firmware override individual fields rather than re-defining the
+    whole profile.
+    """
+
+    # Pin the SSL context's ``maximum_version`` to TLS 1.2.
+    #
+    # Python 3.13's default ``ssl.create_default_context()`` negotiates
+    # TLS 1.3 when both peers support it. The Bambuddy Docker image is
+    # ``python:3.13-slim-trixie``, so every Docker user gets 1.3 by
+    # default. Some Bambu printer firmwares (P2S 01.02.00.00 confirmed
+    # by @iitazz, #1401) implement session reuse on the FTPS data
+    # channel against an old vsFTPd build that doesn't tolerate TLS
+    # 1.3's asynchronous session-ticket model: the data channel gets
+    # torn down mid-stream and the upload aborts with 426 "Failure
+    # reading network stream" — visible as a clean truncation at a
+    # chunk boundary (one reporter saw exactly 7 × 64 KB landed on
+    # the printer). Capping to TLS 1.2 makes session resumption
+    # synchronous and the upload completes normally.
+    #
+    # **Defaults to False** — only applied to printer models where a
+    # reporter has confirmed the symptom. Existing P1S / X1C / H2D
+    # installs that work fine today stay on the negotiated TLS 1.3.
+    # This is deliberately conservative; flipping a printer to the
+    # capped path is a config edit when a new model surfaces the
+    # same bug.
+    cap_tls_v1_2: bool = False
+
+
+# ---------------------------------------------------------------------------
+# Profile registry
+# ---------------------------------------------------------------------------
+
+# Default profile = historical behaviour. Used for every model that
+# doesn't have an entry in ``_PROFILES``.
+DEFAULT_PROFILE = FTPProfile()
+
+# Per-model overrides. Keys are uppercase display names (e.g. "P2S")
+# AFTER alias normalisation, so internal SSDP codes ("N7") resolve via
+# ``_MODEL_ALIASES`` below.
+_PROFILES: dict[str, FTPProfile] = {
+    # P2S firmware 01.02.00.00 trips the vsFTPd + TLS 1.3 session-reuse
+    # bug on the FTPS data channel (#1401, reporter @iitazz). Cap to
+    # TLS 1.2 so session resumption is synchronous and the upload
+    # completes.
+    "P2S": FTPProfile(
+        cap_tls_v1_2=True,
+    ),
+}
+
+# SSDP internal codes that should resolve to a display-name profile.
+# Mirrors the same map in :mod:`camera_profiles`.
+_MODEL_ALIASES: dict[str, str] = {
+    "N7": "P2S",  # P2S internal SSDP code
+}
+
+
+def get_ftp_profile(model: str | None) -> FTPProfile:
+    """Return the :class:`FTPProfile` for *model*, or the default.
+
+    ``model`` can be either a display name (e.g. ``"P2S"``) or an
+    internal SSDP code (e.g. ``"N7"``). Unknown / missing models fall
+    back to :data:`DEFAULT_PROFILE` so the FTP path is never blocked
+    on a missing entry.
+    """
+    if not model:
+        return DEFAULT_PROFILE
+    key = model.upper().strip()
+    key = _MODEL_ALIASES.get(key, key)
+    return _PROFILES.get(key, DEFAULT_PROFILE)

+ 256 - 0
backend/app/services/log_health.py

@@ -0,0 +1,256 @@
+"""Log-health scanner.
+
+Matches the recent Bambuddy app log against a curated catalog of known failure
+signatures, so users can self-diagnose setup ("layer 8") issues before filing a
+bug report.
+
+The catalog is a deliberate *allowlist*: only known-bad, actionable signatures
+are matched — a healthy install produces an empty finding list. Human-readable
+cause and fix text is intentionally NOT stored here; the frontend renders it
+from i18n keys ``systemHealth.signature.<id>.{name,cause,fix}`` so it stays
+translatable across all locales. This module only carries the machine-facing
+fields (pattern, severity, category, wiki anchor).
+"""
+
+import logging
+import re
+from dataclasses import dataclass
+
+from pydantic import BaseModel
+
+from backend.app.core.config import settings
+from backend.app.services.log_reader import LogEntry, read_log_entries, sanitize_log_content
+
+logger = logging.getLogger(__name__)
+
+# How many recent log entries to scan by default.
+DEFAULT_SCAN_LIMIT = 4000
+
+# Log levels ranked so a signature can require "at least WARNING" etc.
+_LEVEL_RANK = {"DEBUG": 10, "INFO": 20, "WARNING": 30, "ERROR": 40, "CRITICAL": 50}
+
+# Findings are ordered layer8 first (the user can act on these), then
+# environment, then bug (please report). Within a group: errors before warnings.
+_CATEGORY_ORDER = {"layer8": 0, "environment": 1, "bug": 2}
+_SEVERITY_ORDER = {"error": 0, "warning": 1}
+
+# Cap the sample line length so a finding can never carry a huge folded traceback.
+_SAMPLE_MAX_LEN = 400
+
+
+@dataclass(frozen=True)
+class LogSignature:
+    """One curated known-issue signature.
+
+    ``patterns`` are matched (``re.search``, case-insensitive) against the log
+    entry message. A signature only becomes a reported finding once it has
+    matched ``min_count`` times within the scan window — this gates noisy,
+    individually-benign symptoms (e.g. an occasional MQTT reconnect after a
+    Wi-Fi blip) from being surfaced as a problem.
+    """
+
+    id: str
+    patterns: tuple[re.Pattern[str], ...]
+    severity: str  # "error" | "warning"
+    category: str  # "layer8" | "environment" | "bug"
+    wiki_anchor: str  # slug appended to the troubleshooting wiki page URL
+    min_level: str = "WARNING"
+    logger_prefix: str | None = None  # only match entries from this logger tree
+    min_count: int = 1
+
+
+def _compile(*patterns: str) -> tuple[re.Pattern[str], ...]:
+    return tuple(re.compile(p, re.IGNORECASE) for p in patterns)
+
+
+# --- The catalog -----------------------------------------------------------
+# Seeded from the ranked "layer 8" root causes found in the closed-issue triage
+# review. Each id MUST have matching i18n keys: systemHealth.signature.<id>.*
+SIGNATURES: tuple[LogSignature, ...] = (
+    LogSignature(
+        # Wrong/mistyped access code — FTPS login is rejected (530).
+        id="ftp-auth-rejected",
+        patterns=_compile(r"FTP connection permission error"),
+        severity="error",
+        category="layer8",
+        wiki_anchor="wrong-access-code",
+        logger_prefix="backend.app.services.bambu_ftp",
+    ),
+    LogSignature(
+        # FTPS :990 unreachable — port blocked by a firewall, or the printer is
+        # off / on a different subnet.
+        id="ftp-connection-timeout",
+        patterns=_compile(r"FTP connection timed out"),
+        severity="warning",
+        category="layer8",
+        wiki_anchor="ftps-port-990-blocked",
+        logger_prefix="backend.app.services.bambu_ftp",
+        min_count=3,
+    ),
+    LogSignature(
+        # TLS negotiation to the printer's FTPS server failed.
+        id="ftp-ssl-error",
+        patterns=_compile(r"FTP SSL error connecting"),
+        severity="warning",
+        category="layer8",
+        wiki_anchor="ftps-tls-failure",
+        logger_prefix="backend.app.services.bambu_ftp",
+        min_count=3,
+    ),
+    LogSignature(
+        # MQTT connection keeps dropping — typically MQTT :8883 partially
+        # blocked, LAN mode unstable, or a flaky network path to the printer.
+        id="mqtt-connection-flapping",
+        patterns=_compile(r"Forcing MQTT reconnect", r"Hard reset reconnect failed"),
+        severity="warning",
+        category="layer8",
+        wiki_anchor="mqtt-connection-unstable",
+        logger_prefix="backend.app.services.bambu_mqtt",
+        min_count=5,
+    ),
+    LogSignature(
+        # Camera stream unreachable — RTSPS :322 blocked, or the printer
+        # camera / LAN liveview is disabled.
+        id="camera-connection-refused",
+        patterns=_compile(
+            r"Chamber image: connection refused",
+            r"Chamber image: connection timeout",
+            r"Camera connection test failed",
+        ),
+        severity="warning",
+        category="layer8",
+        wiki_anchor="camera-rtsps-port-322",
+        logger_prefix="backend.app.services.camera",
+        min_count=3,
+    ),
+    LogSignature(
+        # SQLite write contention. Surfaces inside exception tracebacks; folded
+        # continuation lines are part of the entry message, so this still
+        # matches. The fix is switching to PostgreSQL under multi-printer load.
+        id="database-locked",
+        patterns=_compile(r"database is locked"),
+        severity="error",
+        category="environment",
+        wiki_anchor="database-is-locked",
+    ),
+)
+
+
+class LogFinding(BaseModel):
+    """An aggregated, sanitized match of one signature against the log."""
+
+    signature_id: str
+    severity: str
+    category: str
+    wiki_anchor: str
+    count: int
+    first_seen: str
+    last_seen: str
+    sample: str
+
+
+class ScanResult(BaseModel):
+    """Result of a log-health scan."""
+
+    findings: list[LogFinding]
+    scanned_entries: int
+    log_available: bool
+    summary: dict[str, int]
+
+
+def _level_ok(entry: LogEntry, min_level: str) -> bool:
+    return _LEVEL_RANK.get(entry.level.upper(), 0) >= _LEVEL_RANK.get(min_level, 30)
+
+
+def _matches(sig: LogSignature, entry: LogEntry) -> bool:
+    if not _level_ok(entry, sig.min_level):
+        return False
+    if sig.logger_prefix and not entry.logger_name.startswith(sig.logger_prefix):
+        return False
+    return any(p.search(entry.message) for p in sig.patterns)
+
+
+def _sample_line(message: str) -> str:
+    """Take the first line of a (possibly multi-line) entry, length-capped."""
+    first_line = message.splitlines()[0] if message else ""
+    if len(first_line) > _SAMPLE_MAX_LEN:
+        return first_line[:_SAMPLE_MAX_LEN] + "…"
+    return first_line
+
+
+def scan_logs(
+    limit: int = DEFAULT_SCAN_LIMIT,
+    sensitive_strings: dict[str, str] | None = None,
+) -> ScanResult:
+    """Scan the recent app log against the signature catalog.
+
+    ``sensitive_strings`` (from :func:`log_reader.collect_sensitive_strings`) is
+    applied to every sample line so printer names, serials, IPs, and access
+    codes never leave the process. Even when it is ``None`` the regex-based
+    redaction passes still run.
+    """
+    log_file = settings.log_dir / "bambuddy.log"
+    log_available = log_file.exists()
+
+    entries, _total = read_log_entries(limit=limit)
+
+    # entry_id -> accumulator. entries arrive newest-first.
+    agg: dict[str, dict] = {}
+    for entry in entries:
+        for sig in SIGNATURES:
+            if not _matches(sig, entry):
+                continue
+            acc = agg.get(sig.id)
+            if acc is None:
+                # First (== newest) occurrence encountered.
+                agg[sig.id] = {
+                    "count": 1,
+                    "sample": entry.message,
+                    "last_seen": entry.timestamp,
+                    "first_seen": entry.timestamp,
+                }
+            else:
+                acc["count"] += 1
+                # Iterating newest-first, so each later hit is older.
+                acc["first_seen"] = entry.timestamp
+
+    findings: list[LogFinding] = []
+    for sig in SIGNATURES:
+        acc = agg.get(sig.id)
+        if acc is None or acc["count"] < sig.min_count:
+            continue
+        sample = sanitize_log_content(_sample_line(acc["sample"]), sensitive_strings)
+        findings.append(
+            LogFinding(
+                signature_id=sig.id,
+                severity=sig.severity,
+                category=sig.category,
+                wiki_anchor=sig.wiki_anchor,
+                count=acc["count"],
+                first_seen=acc["first_seen"],
+                last_seen=acc["last_seen"],
+                sample=sample,
+            )
+        )
+
+    findings.sort(
+        key=lambda f: (
+            _CATEGORY_ORDER.get(f.category, 9),
+            _SEVERITY_ORDER.get(f.severity, 9),
+            -f.count,
+        )
+    )
+
+    summary = {
+        "total": len(findings),
+        "layer8": sum(1 for f in findings if f.category == "layer8"),
+        "environment": sum(1 for f in findings if f.category == "environment"),
+        "bug": sum(1 for f in findings if f.category == "bug"),
+    }
+
+    return ScanResult(
+        findings=findings,
+        scanned_entries=len(entries),
+        log_available=log_available,
+        summary=summary,
+    )

+ 213 - 0
backend/app/services/log_reader.py

@@ -0,0 +1,213 @@
+"""Shared primitives for reading, parsing, and sanitizing the Bambuddy app log.
+
+Extracted from ``routes/support.py`` so service-layer code (e.g. the log-health
+scanner in ``log_health.py``) can reuse log reading and redaction without
+importing from the API layer. ``support.py`` re-imports these helpers and keeps
+its own route handlers.
+"""
+
+import logging
+import re
+
+from pydantic import BaseModel
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.config import settings
+from backend.app.models.printer import Printer
+from backend.app.models.settings import Settings
+from backend.app.models.user import User
+
+logger = logging.getLogger(__name__)
+
+# Log line format: "2024-01-15 10:30:45,123 INFO [module.name] [trace_id] Message"
+# The trace_id is left as part of the message group — callers that need it can
+# parse it out; the log-health scanner does not.
+LOG_LINE_PATTERN = re.compile(r"^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2},\d{3})\s+(\w+)\s+\[([^\]]+)\]\s+(.*)$")
+
+
+class LogEntry(BaseModel):
+    """A single parsed log entry."""
+
+    timestamp: str
+    level: str
+    logger_name: str
+    message: str
+
+
+def parse_log_line(line: str) -> LogEntry | None:
+    """Parse a single log line into a LogEntry, or None if it is not a line start."""
+    match = LOG_LINE_PATTERN.match(line.strip())
+    if match:
+        return LogEntry(
+            timestamp=match.group(1),
+            level=match.group(2),
+            logger_name=match.group(3),
+            message=match.group(4),
+        )
+    return None
+
+
+def read_log_entries(
+    limit: int = 200,
+    level_filter: str | None = None,
+    search: str | None = None,
+) -> tuple[list[LogEntry], int]:
+    """Read and parse log entries from ``bambuddy.log``, newest first.
+
+    Continuation lines (tracebacks etc.) are folded into the message of the
+    entry they belong to. Returns ``(entries, total_lines_in_file)``.
+    """
+    log_file = settings.log_dir / "bambuddy.log"
+    if not log_file.exists():
+        return [], 0
+
+    entries: list[LogEntry] = []
+    total_lines = 0
+
+    try:
+        with open(log_file, encoding="utf-8", errors="replace") as f:
+            lines = f.readlines()
+            total_lines = len(lines)
+
+            # Parse lines in reverse order (newest first)
+            current_entry: LogEntry | None = None
+            multi_line_buffer: list[str] = []
+
+            for line in reversed(lines):
+                parsed = parse_log_line(line)
+                if parsed:
+                    # Found a new log entry start
+                    if current_entry:
+                        # Apply filters and add previous entry (without multi_line_buffer - it belongs to new entry)
+                        should_include = True
+
+                        # Level filter
+                        if level_filter and current_entry.level.upper() != level_filter.upper():
+                            should_include = False
+
+                        # Search filter (case-insensitive)
+                        if search and should_include:
+                            search_lower = search.lower()
+                            if not (
+                                search_lower in current_entry.message.lower()
+                                or search_lower in current_entry.logger_name.lower()
+                            ):
+                                should_include = False
+
+                        if should_include:
+                            entries.append(current_entry)
+
+                            if len(entries) >= limit:
+                                break
+
+                    # Set new entry and attach any accumulated multi-line content to it
+                    # (in reverse order, continuation lines come before their parent entry)
+                    current_entry = parsed
+                    if multi_line_buffer:
+                        current_entry.message += "\n" + "\n".join(reversed(multi_line_buffer))
+                    multi_line_buffer = []
+                elif line.strip():
+                    # Continuation of multi-line log entry (will be attached to next parsed entry)
+                    multi_line_buffer.append(line.rstrip())
+
+            # Don't forget the last (oldest) entry
+            # Note: any remaining multi_line_buffer would be orphaned lines before the first entry
+            if current_entry and len(entries) < limit:
+                should_include = True
+                if level_filter and current_entry.level.upper() != level_filter.upper():
+                    should_include = False
+                if search and should_include:
+                    search_lower = search.lower()
+                    if not (
+                        search_lower in current_entry.message.lower()
+                        or search_lower in current_entry.logger_name.lower()
+                    ):
+                        should_include = False
+                if should_include:
+                    entries.append(current_entry)
+
+    except Exception as e:
+        logger.error("Error reading log file: %s", e)
+        return [], 0
+
+    # Entries are already in newest-first order
+    return entries, total_lines
+
+
+def sanitize_log_content(content: str, sensitive_strings: dict[str, str] | None = None) -> str:
+    """Remove sensitive data from log content.
+
+    ``sensitive_strings`` maps known exact values (printer names, serials, etc.)
+    to replacement labels; pass the result of :func:`collect_sensitive_strings`.
+    Regex passes additionally redact credentials in URLs, emails, serials, and
+    IP addresses that were not captured by exact matching.
+    """
+    # First, replace known sensitive values (database-aware exact matching)
+    # This catches printer names, usernames, and other arbitrary user-chosen strings
+    # that regex patterns cannot detect
+    if sensitive_strings:
+        # Sort by length descending to avoid partial matches (e.g. "My Printer 1" before "My Printer")
+        for value, label in sorted(sensitive_strings.items(), key=lambda x: len(x[0]), reverse=True):
+            if len(value) < 3:
+                continue  # Skip very short strings to prevent over-redaction
+            content = re.sub(re.escape(value), label, content)
+
+    # Replace credentials in URLs (e.g. http://user:pass@host, rtsps://bblp:code@host)
+    content = re.sub(r"((?:https?|rtsps?)://)[^/:@\s]+:[^/@\s]+@", r"\1[CREDENTIALS]@", content)
+
+    # Replace email addresses
+    content = re.sub(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "[EMAIL]", content)
+
+    # Replace Bambu Lab printer serial numbers (format: 00M/01D/01S/01P/03W + alphanumeric, 12-16 chars total)
+    content = re.sub(r"\b0[0-3][A-Z0-9][A-Z0-9]{9,13}\b", "[SERIAL]", content, flags=re.IGNORECASE)
+
+    # Replace IPv4 addresses (skip firmware versions like 01.09.01.00 which have leading zeros)
+    content = re.sub(
+        r"\b(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)\b",
+        "[IP]",
+        content,
+    )
+
+    # Replace paths with usernames
+    content = re.sub(r"/home/[^/\s]+/", "/home/[user]/", content)
+    content = re.sub(r"/Users/[^/\s]+/", "/Users/[user]/", content)
+    content = re.sub(r"/opt/[^/\s]+/", "/opt/[user]/", content)
+
+    return content
+
+
+async def collect_sensitive_strings(db: AsyncSession) -> dict[str, str]:
+    """Collect known sensitive values from the database for log redaction.
+
+    Covers printer names, serial numbers, IP addresses, access codes, auth
+    usernames, and the Bambu Cloud email. Pass the result to
+    :func:`sanitize_log_content`.
+    """
+    sensitive_strings: dict[str, str] = {}
+
+    # Printer names, serial numbers, IP addresses, and access codes
+    result = await db.execute(select(Printer.name, Printer.serial_number, Printer.ip_address, Printer.access_code))
+    for name, serial, ip_address, access_code in result.all():
+        if name:
+            sensitive_strings[name] = "[PRINTER]"
+        if serial:
+            sensitive_strings[serial] = "[SERIAL]"
+        if ip_address:
+            sensitive_strings[ip_address] = "[IP]"
+        if access_code:
+            sensitive_strings[access_code] = "[ACCESS_CODE]"
+
+    # Auth usernames
+    result = await db.execute(select(User.username))
+    for (username,) in result.all():
+        if username:
+            sensitive_strings[username] = "[USER]"
+
+    # Bambu Cloud email
+    result = await db.execute(select(Settings.value).where(Settings.key == "bambu_cloud_email"))
+    cloud_email = result.scalar_one_or_none()
+    if cloud_email:
+        sensitive_strings[cloud_email] = "[EMAIL]"
+
+    return sensitive_strings

+ 77 - 0
backend/app/services/loop_watchdog.py

@@ -0,0 +1,77 @@
+"""Event-loop stall watchdog (#1486).
+
+A frozen asyncio event loop is invisible: it produces no log line and no
+traceback — the HTTP server just goes silent, ``/health`` hangs, and the
+process can stop responding to SIGTERM. Several "container hangs after adding
+a printer" reports had exactly this shape, with nothing in the logs to act on.
+
+This watchdog makes such a freeze diagnosable. An async heartbeat re-arms
+``faulthandler.dump_traceback_later()`` every ``HEARTBEAT_INTERVAL`` seconds,
+always ``STALL_THRESHOLD`` seconds ahead. While the loop keeps ticking the
+timer is cancelled and re-armed before it can fire. If the loop stalls, the
+heartbeat can't re-arm — and faulthandler's timer runs in a dedicated C-level
+thread that fires regardless of the frozen loop, dumping *every* thread's
+stack to stderr. The blocked frame then shows up in ``docker compose logs``.
+"""
+
+import asyncio
+import faulthandler
+import logging
+
+logger = logging.getLogger(__name__)
+
+# How often the heartbeat cancels + re-arms the faulthandler timer. Must be
+# comfortably below STALL_THRESHOLD so a healthy loop always re-arms in time.
+HEARTBEAT_INTERVAL = 10.0
+
+# The loop must be unresponsive for at least this long before thread stacks
+# are dumped. Generous on purpose: no legitimate on-loop operation should
+# block for 30s, so anything that does is itself a bug worth a stack dump.
+STALL_THRESHOLD = 30.0
+
+_watchdog_task: asyncio.Task | None = None
+
+
+async def _heartbeat_loop() -> None:
+    """Re-arm the faulthandler stall timer on every tick."""
+    while True:
+        try:
+            faulthandler.cancel_dump_traceback_later()
+            # repeat=False: one dump pinpoints a hard freeze. If the loop
+            # recovers and stalls again, the next heartbeat re-arms anyway.
+            faulthandler.dump_traceback_later(STALL_THRESHOLD, repeat=False)
+        except Exception as e:  # never let the watchdog itself crash the app
+            logger.warning("Loop watchdog re-arm failed: %s", e)
+        try:
+            await asyncio.sleep(HEARTBEAT_INTERVAL)
+        except asyncio.CancelledError:
+            break
+
+
+def start_loop_watchdog() -> None:
+    """Start the event-loop stall watchdog. Idempotent."""
+    global _watchdog_task
+    if _watchdog_task is not None:
+        return
+    if not faulthandler.is_enabled():
+        # Also installs handlers for fatal signals (SIGSEGV etc.) — harmless
+        # and useful; the dump_traceback_later timer works either way.
+        faulthandler.enable()
+    _watchdog_task = asyncio.create_task(_heartbeat_loop())
+    logger.info(
+        "Event-loop stall watchdog started — dumps all thread stacks to stderr if the loop stalls for more than %.0fs",
+        STALL_THRESHOLD,
+    )
+
+
+def stop_loop_watchdog() -> None:
+    """Stop the watchdog and disarm the pending stall timer."""
+    global _watchdog_task
+    if _watchdog_task is not None:
+        _watchdog_task.cancel()
+        _watchdog_task = None
+    try:
+        faulthandler.cancel_dump_traceback_later()
+    except Exception:
+        pass
+    logger.info("Event-loop stall watchdog stopped")

+ 6 - 2
backend/app/services/obico_detection.py

@@ -320,8 +320,12 @@ class ObicoDetectionService:
 
     # ---- queries ----
 
-    def get_status(self) -> dict:
-        low, high = thresholds("medium")
+    def get_status(self, sensitivity: str = "medium") -> dict:
+        # Report the thresholds for the configured sensitivity, not a hardcoded
+        # "medium" — otherwise the Status panel always shows the medium row
+        # regardless of the user's selection (#1469). thresholds() falls back
+        # to the medium multiplier for any unrecognized value.
+        low, high = thresholds(sensitivity)
         return {
             "is_running": self._task is not None and not self._task.done(),
             "last_error": self._last_error,

+ 273 - 5
backend/app/services/print_scheduler.py

@@ -9,6 +9,7 @@ from pathlib import Path
 
 from sqlalchemy import func, select
 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
@@ -18,6 +19,8 @@ from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
 from backend.app.models.smart_plug import SmartPlug
+from backend.app.models.spool_assignment import SpoolAssignment
+from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.services.bambu_ftp import (
     cache_3mf_download,
     delete_file_async,
@@ -25,6 +28,7 @@ from backend.app.services.bambu_ftp import (
     upload_file_async,
     with_ftp_retry,
 )
+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_drying
 from backend.app.services.smart_plug_manager import smart_plug_manager
@@ -300,6 +304,13 @@ class PrintScheduler:
                             )
                             await db.commit()
 
+                    # Filament-deficit pre-dispatch check (#1496). If the
+                    # assigned spool can't satisfy any required slot grams,
+                    # promote the item to manual_start so the user must
+                    # acknowledge via the ▶ button (which re-checks live).
+                    if await self._block_on_filament_deficit(db, item):
+                        continue
+
                     # Start the print
                     await self._start_print(db, item)
                     busy_printers.add(item.printer_id)
@@ -423,6 +434,10 @@ class PrintScheduler:
                                 )
                                 await db.commit()
 
+                        # Filament-deficit pre-dispatch check (#1496).
+                        if await self._block_on_filament_deficit(db, item):
+                            continue
+
                         await self._start_print(db, item)
                         busy_printers.add(printer_id)
 
@@ -792,6 +807,22 @@ class PrintScheduler:
         # Get filament requirements from source file
         filament_reqs = await self._get_filament_requirements(db, item)
         if not filament_reqs:
+            # When the 3MF can't be read but force-color overrides are present, build a
+            # direct mapping from the overrides so the printer uses the correct AMS slot.
+            if item.filament_overrides:
+                try:
+                    overrides = json.loads(item.filament_overrides)
+                    force_overrides = [o for o in overrides if o.get("force_color_match")]
+                    if force_overrides:
+                        logger.info(
+                            "Queue item %s: No filament reqs from 3MF; building AMS mapping from %d "
+                            "force-color override(s)",
+                            item.id,
+                            len(force_overrides),
+                        )
+                        return self._build_override_direct_mapping(force_overrides, status)
+                except (json.JSONDecodeError, KeyError, TypeError) as e:
+                    logger.warning("Queue item %s: Force-color fallback mapping failed: %s", item.id, e)
             logger.debug("No filament requirements found for queue item %s", item.id)
             return None
 
@@ -827,8 +858,46 @@ class PrintScheduler:
         # Check if user prefers lowest remaining filament when multiple spools match
         prefer_lowest = await self._get_bool_setting(db, "prefer_lowest_filament")
 
+        # When the preference is on, surface Bambuddy's inventory-side
+        # remaining for each slot that's bound to a tracked spool, so the
+        # sort beats the MQTT-only blind spot (#1508). Skip the lookup
+        # entirely when the preference is off — no behaviour change for
+        # users who haven't opted in.
+        inventory_remain_overrides: dict[int, float] | None = None
+        if prefer_lowest:
+            inventory_remain_overrides = await self._build_inventory_remain_overrides(db, printer_id, loaded_filaments)
+
         # Compute mapping: match required filaments to available slots
-        return self._match_filaments_to_slots(filament_reqs, loaded_filaments, prefer_lowest)
+        return self._match_filaments_to_slots(
+            filament_reqs, loaded_filaments, prefer_lowest, inventory_remain_overrides
+        )
+
+    def _build_override_direct_mapping(self, force_overrides: list[dict], status) -> list[int] | None:
+        """Build an AMS mapping directly from force-color overrides without a 3MF.
+
+        Used when ``_get_filament_requirements`` returns nothing (e.g. the 3MF's
+        slice_info is missing or unreadable) but ``force_color_match`` overrides
+        are present. Each override's ``slot_id``, ``type``, and ``color`` are
+        treated as the filament requirement for that slot and matched against the
+        current AMS state of the printer.
+
+        Returns the same format as ``_match_filaments_to_slots``, or None when
+        the AMS has no loaded filaments.
+        """
+        loaded = self._build_loaded_filaments(status)
+        if not loaded:
+            return None
+
+        reqs = [
+            {
+                "slot_id": o["slot_id"],
+                "type": o.get("type", ""),
+                "color": o.get("color", ""),
+                "tray_info_idx": "",
+            }
+            for o in force_overrides
+        ]
+        return self._match_filaments_to_slots(reqs, loaded)
 
     async def _get_filament_requirements(self, db: AsyncSession, item: PrintQueueItem) -> list[dict] | None:
         """Resolve the queue item's source 3MF and parse the per-slot
@@ -960,8 +1029,156 @@ class PrintScheduler:
         except ValueError:
             return False
 
+    async def _build_inventory_remain_overrides(
+        self, db: AsyncSession, printer_id: int, loaded: list[dict]
+    ) -> dict[int, float]:
+        """Return ``{global_tray_id: remaining_grams}`` for AMS slots the user
+        has bound to an inventory spool — Bambuddy-side or Spoolman-side.
+
+        The MQTT ``remain`` field on a tray is the printer firmware's
+        RFID-decremented value, which has two limitations the "Prefer Lowest
+        Remaining Filament" feature has been ignoring (#1508):
+
+        - it's only meaningful for Bambu RFID spools; everything else reports
+          ``-1`` (then clamped to a sentinel), so multiple non-RFID trays
+          compare equal and the sort collapses to AMS-slot order — the user
+          who's curating inventory weights gets the lower-slot pick instead
+          of the lower-remaining pick;
+        - even when set, it's the *printer's* counter, not Bambuddy's
+          ``label_weight - weight_used`` (internal mode) or Spoolman's
+          ``remaining_weight`` (Spoolman mode) — the two diverge any time the
+          user re-spools, swaps cardboard, or runs a print outside Bambuddy.
+
+        When the user has bound a spool to a slot, their own inventory
+        tracking is authoritative; this helper surfaces that value so the
+        sort can prefer it. Slots without a binding are absent from the
+        returned map — the caller then falls back to MQTT ``remain`` for
+        those, preserving the pre-#1508 behaviour for un-tracked spools.
+
+        Returns an empty map on any failure (no inventory bindings, DB
+        error, Spoolman unreachable). A best-effort lookup; "Prefer Lowest"
+        is a preference, not a guarantee.
+        """
+        if not loaded:
+            return {}
+        # External / virtual-tray slots are tracked separately from AMS — skip
+        # them so a VT-loaded spool doesn't accidentally inherit a tracked
+        # AMS binding (the tables use ams_id 254/255 for VT, but the cross
+        # match is fiddly and out of scope for this fix).
+        tracked_slots = [(f["ams_id"], f["tray_id"], f["global_tray_id"]) for f in loaded if not f.get("is_external")]
+        if not tracked_slots:
+            return {}
+
+        is_spoolman = await self._is_spoolman_mode(db)
+        overrides: dict[int, float] = {}
+
+        if is_spoolman:
+            result = await db.execute(
+                select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == printer_id)
+            )
+            assignments = list(result.scalars().all())
+            by_slot = {(a.ams_id, a.tray_id): a.spoolman_spool_id for a in assignments}
+            from backend.app.services.filament_deficit import _spoolman_remaining_grams
+
+            for ams_id, tray_id, gtid in tracked_slots:
+                spoolman_id = by_slot.get((ams_id, tray_id))
+                if spoolman_id is None:
+                    continue
+                grams = await _spoolman_remaining_grams(spoolman_id)
+                if grams is not None:
+                    overrides[gtid] = grams
+            return overrides
+
+        # Internal inventory mode (default). selectinload matches the pattern
+        # used elsewhere (inventory.py, spoolman.py routes) — a single query
+        # plus an eager-loaded relationship rather than an explicit join, so
+        # the row-attribute shape is exactly what those routes already rely on.
+        result = await db.execute(
+            select(SpoolAssignment)
+            .options(selectinload(SpoolAssignment.spool))
+            .where(SpoolAssignment.printer_id == printer_id)
+        )
+        assignments = list(result.scalars().all())
+        by_slot = {(a.ams_id, a.tray_id): a.spool for a in assignments}
+        for ams_id, tray_id, gtid in tracked_slots:
+            spool = by_slot.get((ams_id, tray_id))
+            if spool is None:
+                continue
+            label = float(spool.label_weight or 0)
+            used = float(spool.weight_used or 0)
+            overrides[gtid] = max(0.0, label - used)
+        return overrides
+
+    @staticmethod
+    async def _is_spoolman_mode(db: AsyncSession) -> bool:
+        """Mirror of ``filament_deficit._is_spoolman_mode`` — kept private
+        here to avoid making this module import-dependent on that private
+        helper's signature."""
+        try:
+            from backend.app.api.routes.settings import get_setting
+
+            v = await get_setting(db, "spoolman_enabled")
+            return bool(v) and v.lower() == "true"
+        except Exception:
+            return False
+
+    @staticmethod
+    def _slot_priority(ams_id: int | None, tray_id: int | None) -> int:
+        """Deterministic slot-position tie-breaker for the prefer-lowest sort.
+
+        Three bands, matched to the emission order in ``_build_loaded_filaments``
+        so a tied sort produces the same physical-position order the pre-#1508
+        stable sort did (preserves the regression-free baseline):
+
+        - Regular AMS (``ams_id`` 0..7): ``ams_id * 4 + tray_id`` → 0..31
+        - AMS-HT (``ams_id`` >= 128, single tray): ``1000 + (ams_id - 128) * 4``
+        - External / VT (``ams_id`` < 0, or ``None``): ``10_000``
+
+        Banding ensures regular AMS < AMS-HT < external on ties, regardless of
+        what the raw ``ams_id`` happens to be (in particular, ``ams_id = -1``
+        for VT must NOT sort to a negative number or it would beat AMS slot 0).
+        """
+        if ams_id is None or ams_id < 0:
+            return 10_000
+        if ams_id >= 128:
+            return 1_000 + (ams_id - 128) * 4 + (tray_id or 0)
+        return ams_id * 4 + (tray_id or 0)
+
+    @staticmethod
+    def _prefer_lowest_sort_key(f: dict, overrides: dict[int, float] | None) -> tuple[int, float, int]:
+        """Sort key for the "Prefer Lowest Remaining Filament" preference.
+
+        Two-tier ordering: inventory-tracked spools always sort BEFORE
+        non-tracked spools (the user has told us they care about these
+        specifically), then ascending by remaining within each tier, then
+        ascending by AMS slot position as the deterministic tie-breaker.
+
+        Tiers are flagged by the first tuple element (0 = inventory-tracked,
+        1 = MQTT-only / unknown). Cross-tier value comparisons never run
+        because the tier flag dominates — which is what lets us mix grams
+        (inventory) and percent (MQTT) without a unit conversion.
+
+        Within the MQTT tier ``remain = -1`` (unknown) is mapped to 101 so
+        spools the printer DOES know something about sort ahead of those
+        it knows nothing about — preserves pre-#1508 behaviour for the
+        no-inventory-binding case.
+
+        Slot tie-breaker via ``_slot_priority`` so regular AMS < AMS-HT <
+        external on ties, matching the legacy emission-order stable sort.
+        """
+        gtid = f.get("global_tray_id")
+        slot_order = PrintScheduler._slot_priority(f.get("ams_id"), f.get("tray_id"))
+        if overrides and gtid in overrides:
+            return (0, overrides[gtid], slot_order)
+        remain = f.get("remain", -1)
+        return (1, float(remain) if remain is not None and remain >= 0 else 101.0, slot_order)
+
     def _match_filaments_to_slots(
-        self, required: list[dict], loaded: list[dict], prefer_lowest: bool = False
+        self,
+        required: list[dict],
+        loaded: list[dict],
+        prefer_lowest: bool = False,
+        inventory_remain_overrides: dict[int, float] | None = None,
     ) -> list[int] | None:
         """Match required filaments to loaded filaments and build AMS mapping.
 
@@ -1008,9 +1225,11 @@ class PrintScheduler:
             if req_nozzle_id is not None:
                 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()
+            # Sort by remaining filament (ascending) so lowest-remain spool wins .find().
+            # Inventory-tracked spools sort before MQTT-only ones (#1508); see
+            # _prefer_lowest_sort_key for the full rationale.
             if prefer_lowest:
-                available.sort(key=lambda f: f.get("remain", -1) if f.get("remain", -1) >= 0 else 101)
+                available.sort(key=lambda f: self._prefer_lowest_sort_key(f, inventory_remain_overrides))
 
             # Check if tray_info_idx is unique among available trays
             if req_tray_info_idx:
@@ -1029,7 +1248,7 @@ class PrintScheduler:
                         f"using color matching among trays: {[f['global_tray_id'] for f in idx_matches]}"
                     )
                     if prefer_lowest:
-                        idx_matches.sort(key=lambda f: f.get("remain", -1) if f.get("remain", -1) >= 0 else 101)
+                        idx_matches.sort(key=lambda f: self._prefer_lowest_sort_key(f, inventory_remain_overrides))
                     # Use color matching within this subset
                     for f in idx_matches:
                         f_color = f.get("color", "")
@@ -1605,6 +1824,55 @@ class PrintScheduler:
         result = await db.execute(select(Printer).where(Printer.id == printer_id))
         return result.scalar_one_or_none()
 
+    async def _block_on_filament_deficit(
+        self,
+        db: AsyncSession,
+        item: PrintQueueItem,
+    ) -> bool:
+        """Promote the item to manual_start when the assigned spool is short (#1496).
+
+        Returns True when this dispatch attempt was blocked, False when the
+        item is clear to start. A previously-flagged item whose spool has
+        since been swapped to one with enough material clears the flag here
+        so the next scheduler tick dispatches it.
+        """
+        try:
+            deficit = await compute_deficit_for_queue_item(db, item)
+        except Exception as e:
+            # Never let a flaky deficit check wedge the queue — log and let
+            # dispatch proceed. The PrintModal-side check still runs on the
+            # manual paths.
+            logger.warning("Filament deficit check failed for item %s: %s", item.id, e)
+            return False
+
+        if deficit:
+            item.filament_short = True
+            item.manual_start = True
+            await db.commit()
+            job_name = await self._get_job_name(db, item)
+            printer = await self._get_printer(db, item.printer_id) if item.printer_id else None
+            logger.info(
+                "Queue item %s blocked on filament deficit (%d slot(s)) — promoted to manual_start",
+                item.id,
+                len(deficit),
+            )
+            try:
+                await notification_service.on_queue_job_waiting(
+                    job_name=job_name,
+                    target_model=(printer.model if printer else "") or "",
+                    waiting_reason="filament_short",
+                    db=db,
+                )
+            except Exception as e:
+                logger.debug("filament_short notification failed for item %s: %s", item.id, e)
+            return True
+
+        # No deficit — clear any stale flag from a previous tick.
+        if item.filament_short:
+            item.filament_short = False
+            await db.commit()
+        return False
+
     async def _start_print(self, db: AsyncSession, item: PrintQueueItem):
         """Upload file and start print for a queue item.
 

+ 201 - 0
backend/app/services/printer_diagnostic.py

@@ -0,0 +1,201 @@
+"""Connection diagnostic for Bambu printers.
+
+Runs the checks a maintainer performs by hand when triaging a
+"printer won't connect / won't print" report — port reachability, LAN
+developer mode, Docker network mode, subnet match, and MQTT credentials —
+so users can self-diagnose setup problems instead of opening an issue.
+
+See the 2026-05-21 issue-triage analysis: ~1/3 of closed issues were
+user-side setup errors clustered on exactly these causes.
+"""
+
+import asyncio
+import ipaddress
+import logging
+import socket
+
+from backend.app.models.printer import Printer
+from backend.app.schemas.printer import DiagnosticCheck, PrinterDiagnosticResult
+from backend.app.services.discovery import is_running_in_docker
+from backend.app.services.printer_manager import printer_manager
+
+logger = logging.getLogger(__name__)
+
+# Bambu LAN-mode ports.
+PORT_MQTT = 8883  # MQTT over TLS — control + status. Connection-critical.
+PORT_FTPS = 990  # FTPS — file upload; required to send prints.
+PORT_RTSPS = 322  # RTSPS — camera stream; optional.
+
+_PORT_PROBE_TIMEOUT = 3.0
+
+
+async def _check_port(ip: str, port: int, timeout: float = _PORT_PROBE_TIMEOUT) -> bool:
+    """Test TCP connectivity to ip:port. Returns True if reachable."""
+    try:
+        _reader, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=timeout)
+        writer.close()
+        try:
+            await writer.wait_closed()
+        except Exception:
+            pass
+        return True
+    except Exception:
+        return False
+
+
+def _detect_docker_network_mode() -> str:
+    """Detect Docker network mode.
+
+    In host mode the container shares the host network namespace, so Docker
+    infrastructure interfaces (docker0, br-*, veth*) are visible. In bridge
+    mode the container only sees its own eth0.
+    """
+    try:
+        for _idx, name in socket.if_nameindex():
+            if name.startswith(("docker", "br-", "veth", "virbr")):
+                return "host"
+    except Exception:
+        pass
+    return "bridge"
+
+
+def _get_host_ip() -> str | None:
+    """Best-effort IPv4 address the Bambuddy host routes from."""
+    try:
+        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+        try:
+            # No packets are sent; this just picks the routing-table source IP.
+            s.connect(("10.255.255.255", 1))
+            return s.getsockname()[0]
+        finally:
+            s.close()
+    except Exception:
+        return None
+
+
+def _same_subnet(ip_a: str, ip_b: str) -> bool | None:
+    """True/False if both are IPv4 literals in the same /24; None if undeterminable."""
+    try:
+        addr_a = ipaddress.ip_address(ip_a)
+        addr_b = ipaddress.ip_address(ip_b)
+    except ValueError:
+        return None
+    if addr_a.version != 4 or addr_b.version != 4:
+        return None
+    net_a = ipaddress.ip_network(f"{addr_a}/24", strict=False)
+    net_b = ipaddress.ip_network(f"{addr_b}/24", strict=False)
+    return net_a == net_b
+
+
+async def run_connection_diagnostic(
+    ip_address: str,
+    *,
+    printer: Printer | None = None,
+    serial_number: str | None = None,
+    access_code: str | None = None,
+) -> PrinterDiagnosticResult:
+    """Run connection checks for a printer.
+
+    Works for an existing saved printer (pass ``printer``) and for the
+    pre-save Add-Printer flow (pass ``serial_number`` + ``access_code``).
+
+    Each check carries a stable ``id`` and a ``status`` of
+    pass / fail / warn / skip; the frontend renders the human-readable
+    title and fix text (localized) keyed on that id + status.
+    """
+    checks: list[DiagnosticCheck] = []
+
+    # --- Port reachability (probed in parallel) ---
+    mqtt_ok, ftps_ok, rtsps_ok = await asyncio.gather(
+        _check_port(ip_address, PORT_MQTT),
+        _check_port(ip_address, PORT_FTPS),
+        _check_port(ip_address, PORT_RTSPS),
+    )
+    # MQTT is connection-critical; FTPS/RTSPS only degrade printing/camera.
+    checks.append(DiagnosticCheck(id="port_mqtt", status="pass" if mqtt_ok else "fail"))
+    checks.append(DiagnosticCheck(id="port_ftps", status="pass" if ftps_ok else "warn"))
+    checks.append(DiagnosticCheck(id="port_rtsps", status="pass" if rtsps_ok else "warn"))
+
+    # --- Docker network mode ---
+    network_mode: str | None = None
+    if is_running_in_docker():
+        network_mode = _detect_docker_network_mode()
+        checks.append(
+            DiagnosticCheck(
+                id="network_mode",
+                status="pass" if network_mode == "host" else "warn",
+                params={"mode": network_mode},
+            )
+        )
+    else:
+        checks.append(DiagnosticCheck(id="network_mode", status="skip"))
+
+    # --- Subnet match ---
+    # Skipped in bridge mode: the container IP is the bridge IP, not the host's,
+    # so the comparison is meaningless and the network_mode check already covers it.
+    if network_mode == "bridge":
+        checks.append(DiagnosticCheck(id="subnet", status="skip"))
+    else:
+        host_ip = _get_host_ip()
+        same = _same_subnet(ip_address, host_ip) if host_ip else None
+        if same is None:
+            checks.append(DiagnosticCheck(id="subnet", status="skip"))
+        else:
+            checks.append(
+                DiagnosticCheck(
+                    id="subnet",
+                    status="pass" if same else "warn",
+                    params={"printer_ip": ip_address, "host_ip": host_ip},
+                )
+            )
+
+    # --- MQTT credentials / connection ---
+    state = printer_manager.get_status(printer.id) if printer else None
+    if not mqtt_ok:
+        # Can't reach the broker at all — the port check already reported it.
+        checks.append(DiagnosticCheck(id="mqtt_auth", status="skip"))
+    elif serial_number and access_code:
+        # Pre-add flow: actively probe with the credentials the user entered.
+        try:
+            result = await printer_manager.test_connection(
+                ip_address=ip_address,
+                serial_number=serial_number,
+                access_code=access_code,
+            )
+            checks.append(DiagnosticCheck(id="mqtt_auth", status="pass" if result.get("success") else "fail"))
+        except Exception:
+            logger.debug("test_connection failed during diagnostic", exc_info=True)
+            checks.append(DiagnosticCheck(id="mqtt_auth", status="fail"))
+    elif state is not None:
+        # Existing printer: trust the live MQTT state rather than opening a
+        # second connection (Bambu printers tolerate few concurrent sessions).
+        checks.append(DiagnosticCheck(id="mqtt_auth", status="pass" if state.connected else "fail"))
+    else:
+        checks.append(DiagnosticCheck(id="mqtt_auth", status="skip"))
+
+    # --- LAN developer mode (only readable over a live MQTT connection) ---
+    if state is not None and state.connected:
+        if state.developer_mode is True:
+            dev_status = "pass"
+        elif state.developer_mode is False:
+            dev_status = "fail"
+        else:
+            dev_status = "skip"
+        checks.append(DiagnosticCheck(id="developer_mode", status=dev_status))
+    else:
+        checks.append(DiagnosticCheck(id="developer_mode", status="skip"))
+
+    statuses = {c.status for c in checks}
+    if "fail" in statuses:
+        overall = "problems"
+    elif "warn" in statuses:
+        overall = "warnings"
+    else:
+        overall = "ok"
+
+    return PrinterDiagnosticResult(
+        printer_id=printer.id if printer else None,
+        ip_address=ip_address,
+        overall=overall,
+        checks=checks,
+    )

+ 40 - 3
backend/app/services/printer_manager.py

@@ -169,6 +169,7 @@ class PrinterManager:
         self._printer_info: dict[int, PrinterInfo] = {}  # Cache printer name/serial for callbacks
         self._on_print_start: Callable[[int, dict], None] | None = None
         self._on_print_complete: Callable[[int, dict], None] | None = None
+        self._on_print_running_observed: Callable[[int, dict], None] | None = None
         self._on_status_change: Callable[[int, PrinterState], None] | None = None
         self._on_ams_change: Callable[[int, list], None] | None = None
         self._on_layer_change: Callable[[int, int], None] | None = None
@@ -309,6 +310,15 @@ class PrinterManager:
         """Set callback for print completion events."""
         self._on_print_complete = callback
 
+    def set_print_running_observed_callback(self, callback: Callable[[int, dict], None]):
+        """Set callback for restart-recovery RUNNING-state observations (#1485
+        follow-up). Fires the first time we see ``state == RUNNING`` for a
+        printer that started its print before Bambuddy came up — the #1304
+        guard suppresses ``on_print_start`` for these, so anything that
+        normally hangs off it (e.g. timelapse baseline capture) needs this
+        hook to recover."""
+        self._on_print_running_observed = callback
+
     def set_status_change_callback(self, callback: Callable[[int, PrinterState], None]):
         """Set callback for status change events."""
         self._on_status_change = callback
@@ -372,6 +382,10 @@ class PrinterManager:
             if self._on_print_complete:
                 self._schedule_async(self._on_print_complete(printer_id, data))
 
+        def on_print_running_observed(data: dict):
+            if self._on_print_running_observed:
+                self._schedule_async(self._on_print_running_observed(printer_id, data))
+
         def on_ams_change(ams_data: list):
             if self._on_ams_change:
                 self._schedule_async(self._on_ams_change(printer_id, ams_data))
@@ -400,6 +414,7 @@ class PrinterManager:
             on_layer_change=on_layer_change,
             on_bed_temp_update=on_bed_temp_update,
             on_drying_complete=on_drying_complete,
+            on_print_running_observed=on_print_running_observed,
         )
 
         client.connect()
@@ -616,13 +631,31 @@ class PrinterManager:
             return self._clients[printer_id].request_status_update()
         return False
 
+    # Probe budget for test_connection (#1445). Was a fixed 2s sleep, which was
+    # too short for P1S firmware whose broker / TLS handshake routinely takes
+    # 3–5s to surface a CONNACK on a cold MQTT session. We now poll up to
+    # PROBE_TIMEOUT_SECONDS and early-return the moment we see connected=True,
+    # so happy-path connections still finish in ~1–2s and slow brokers get the
+    # headroom they need instead of getting falsely rejected.
+    PROBE_TIMEOUT_SECONDS = 8.0
+    PROBE_POLL_INTERVAL_SECONDS = 0.2
+
     async def test_connection(
         self,
         ip_address: str,
         serial_number: str,
         access_code: str,
     ) -> dict:
-        """Test connection to a printer without persisting."""
+        """Test connection to a printer without persisting.
+
+        Polls for up to PROBE_TIMEOUT_SECONDS and tears the probe client down
+        off-loop. The teardown matters: `client.disconnect()` ends in paho's
+        `loop_stop()` which `join()`s the network thread — if the thread is
+        still mid-TLS-handshake to a slow printer, that join blocks the
+        asyncio event loop and every other HTTP request queues behind it. The
+        original synchronous teardown produced the #1445 "Docker container
+        hangs" symptom on P1S when called from POST /printers/.
+        """
         client = BambuMQTTClient(
             ip_address=ip_address,
             serial_number=serial_number,
@@ -631,7 +664,9 @@ class PrinterManager:
 
         try:
             client.connect()
-            await asyncio.sleep(2)
+            deadline = asyncio.get_running_loop().time() + self.PROBE_TIMEOUT_SECONDS
+            while not client.state.connected and asyncio.get_running_loop().time() < deadline:
+                await asyncio.sleep(self.PROBE_POLL_INTERVAL_SECONDS)
 
             result = {
                 "success": client.state.connected,
@@ -639,7 +674,9 @@ class PrinterManager:
                 "model": client.state.raw_data.get("device_model"),
             }
         finally:
-            client.disconnect()
+            # Off-loop teardown — see docstring. paho's loop_stop() joins the
+            # network thread which may still be in a slow TLS handshake.
+            await asyncio.to_thread(client.disconnect)
 
         return result
 

+ 296 - 0
backend/app/services/slicer_3mf_convert.py

@@ -0,0 +1,296 @@
+"""Per-slice 3MF input normalisation for the slicer pipeline.
+
+This module currently exposes one helper, :func:`substitute_unused_plate_filaments`,
+which rewrites the user's filament list so unused-slot entries don't trip
+BambuStudio's loaded-filament temperature validator. The original goal of
+this module — a two-pass cross-nozzle-class config-splice (#1493) — was
+replaced by a simpler approach: forwarding the sidecar's existing
+``--arrange`` flag (see ``slicer_api.SlicerApiService.slice_with_profiles``
+and ``_run_slicer_with_fallback`` in ``api/routes/library.py``). BambuStudio
+itself reconciles the embedded ``project_settings.config`` against the
+target printer when ``--arrange`` is on, so Bambuddy never has to reproduce
+that schema logic locally.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import re
+import zipfile
+from io import BytesIO
+
+logger = logging.getLogger(__name__)
+
+_PROJECT_SETTINGS_PATH = "Metadata/project_settings.config"
+_MODEL_SETTINGS_PATH = "Metadata/model_settings.config"
+_SLICE_INFO_PATH = "Metadata/slice_info.config"
+
+
+def count_plates_in_3mf(zip_bytes: bytes) -> int:
+    """Return the number of plates the source 3MF defines, or ``0`` if the
+    file isn't a parseable 3MF / has no plate metadata. Used by the
+    cross-class slice-all loop (#1493) to know how many ``--slice N``
+    calls to dispatch before merging the per-plate outputs back into one
+    multi-plate 3MF.
+    """
+    try:
+        with zipfile.ZipFile(BytesIO(zip_bytes), "r") as zf:
+            if _MODEL_SETTINGS_PATH not in zf.namelist():
+                return 0
+            xml = zf.read(_MODEL_SETTINGS_PATH).decode("utf-8", errors="replace")
+    except (zipfile.BadZipFile, OSError, KeyError):
+        return 0
+    # Count ``<metadata key="plater_id" value="..."/>`` entries — each
+    # ``<plate>`` element carries exactly one. Cheap and tolerant of the
+    # full schema (no need to parse the whole XML, which is large and may
+    # contain CDATA quirks).
+    return len(re.findall(r'<metadata key="plater_id" value="(\d+)"', xml))
+
+
+def extract_source_printer_model(zip_bytes: bytes) -> str | None:
+    """Return the canonical short model code (e.g. ``"X1C"``, ``"H2D"``) for
+    the 3MF's embedded ``printer_model`` field, or ``None`` if the input
+    isn't a 3MF, has no embedded settings, the field is missing, or the
+    model isn't recognised. Canonicalisation goes through
+    :func:`normalize_printer_model`, which strips the ``"Bambu Lab "``
+    vendor prefix and maps long display names to the short codes that
+    :func:`is_dual_nozzle_model` matches against (the raw field is
+    ``"Bambu Lab H2D"``, not ``"H2D"``).
+    """
+    from backend.app.utils.printer_models import normalize_printer_model
+
+    try:
+        with zipfile.ZipFile(BytesIO(zip_bytes), "r") as zf:
+            if _PROJECT_SETTINGS_PATH not in zf.namelist():
+                return None
+            cfg = json.loads(zf.read(_PROJECT_SETTINGS_PATH).decode("utf-8"))
+    except (zipfile.BadZipFile, json.JSONDecodeError, UnicodeDecodeError, OSError, KeyError):
+        return None
+    if not isinstance(cfg, dict):
+        return None
+    raw = cfg.get("printer_model")
+    if not raw:
+        return None
+    canonical = normalize_printer_model(str(raw))
+    return canonical or None
+
+
+_PLATE_BLOCK_RE = re.compile(r"<plate>.*?</plate>", re.DOTALL)
+
+
+def merge_plate_3mfs(
+    plate_outputs: list[tuple[int, bytes]],
+    source_3mf_bytes: bytes | None = None,
+) -> bytes:
+    """Combine N single-plate sliced 3MFs into one multi-plate 3MF.
+
+    Used by the cross-class slice-all loop (#1493) where Bambuddy slices
+    each plate independently against the target printer (BS CLI's
+    ``--arrange`` is project-wide so a single ``--slice 0`` call would
+    consolidate every plate's objects onto one bed — the bug this whole
+    path exists to work around). Each input is a single-plate 3MF whose
+    ``Metadata/plate_N.gcode`` / ``plate_N.json`` / ``plate_N.png``
+    entries already carry the right plate index because the BS CLI
+    preserves the requested plate number in the output filenames.
+
+    The merge strategy:
+    - The first plate's 3MF is the base — its ``project_settings.config``
+      (target printer), ``3D/3dmodel.model``, and Auxiliaries images
+      carry forward.
+    - Per-plate artifacts from the other inputs (``plate_N.gcode``,
+      ``plate_N.gcode.md5``, ``plate_N.json``, ``plate_N.png``,
+      ``plate_N_small.png``, ``plate_no_light_N.png``, ``top_N.png``,
+      ``pick_N.png``) are overlaid into the base.
+    - ``slice_info.config`` is re-assembled from each input's single
+      ``<plate>`` block so the resulting file lists all N plates.
+    - ``source_3mf_bytes``, when supplied, is used as a fallback source
+      of per-plate thumbnails (``plate_N.png`` and ``plate_N_small.png``)
+      when the sliced outputs don't carry them — BS CLI with ``--arrange``
+      regenerates the plate gcode but rarely writes a fresh per-plate
+      preview, so without this fallback the merged 3MF would only have
+      a cover image for plate 1 (the base 3MF) and the archive page's
+      per-plate previews would be blank.
+
+    Returns the merged 3MF bytes. Single-element input is a passthrough.
+    Empty input raises ``ValueError``.
+    """
+    if not plate_outputs:
+        raise ValueError("merge_plate_3mfs: at least one plate output required")
+    ordered = sorted(plate_outputs, key=lambda p: p[0])
+
+    if len(ordered) == 1:
+        return ordered[0][1]
+
+    # Collect each plate's <plate>...</plate> block out of its
+    # slice_info.config. The single-plate slice output puts exactly one
+    # such block; if a plate's output is missing the section (shouldn't
+    # happen on a successful slice, but stay defensive) skip it — better
+    # to ship a partial multi-plate 3MF than to fail the whole merge.
+    plate_blocks: list[str] = []
+    for plate_num, plate_bytes in ordered:
+        try:
+            with zipfile.ZipFile(BytesIO(plate_bytes), "r") as zf:
+                if _SLICE_INFO_PATH not in zf.namelist():
+                    continue
+                xml = zf.read(_SLICE_INFO_PATH).decode("utf-8", errors="replace")
+        except (zipfile.BadZipFile, OSError, KeyError) as exc:
+            logger.warning("merge_plate_3mfs: couldn't read plate %d slice_info (%s)", plate_num, exc)
+            continue
+        match = _PLATE_BLOCK_RE.search(xml)
+        if match:
+            plate_blocks.append(match.group(0))
+
+    combined_slice_info = (
+        '<?xml version="1.0" encoding="UTF-8"?>\n'
+        "<config>\n"
+        "  <header>\n"
+        '    <header_item key="X-BBL-Client-Type" value="slicer"/>\n'
+        '    <header_item key="X-BBL-Client-Version" value="02.06.00.51"/>\n'
+        "  </header>\n" + "\n".join(f"  {block}" for block in plate_blocks) + "\n</config>\n"
+    ).encode("utf-8")
+
+    # Per-plate artifact filenames we lift from each input into the base.
+    def _per_plate_entries(n: int) -> set[str]:
+        return {
+            f"Metadata/plate_{n}.gcode",
+            f"Metadata/plate_{n}.gcode.md5",
+            f"Metadata/plate_{n}.json",
+            f"Metadata/plate_{n}.png",
+            f"Metadata/plate_{n}_small.png",
+            f"Metadata/plate_no_light_{n}.png",
+            f"Metadata/top_{n}.png",
+            f"Metadata/pick_{n}.png",
+        }
+
+    # When the per-plate slices skip writing ``plate_N.png`` (BS CLI with
+    # ``--arrange`` does this — the gcode is fresh but the preview slot
+    # is empty), fall back to the source 3MF's stored render of the same
+    # plate. The visual layout will differ from the arranged H2D version
+    # but a recognisable preview is much better than a blank card.
+    def _source_thumbnail_fallback(plate_num: int) -> dict[str, bytes]:
+        if source_3mf_bytes is None:
+            return {}
+        wanted = {
+            f"Metadata/plate_{plate_num}.png",
+            f"Metadata/plate_{plate_num}_small.png",
+        }
+        found: dict[str, bytes] = {}
+        try:
+            with zipfile.ZipFile(BytesIO(source_3mf_bytes), "r") as src_zf:
+                for name in src_zf.namelist():
+                    if name in wanted:
+                        found[name] = src_zf.read(name)
+        except (zipfile.BadZipFile, OSError) as exc:
+            logger.warning("merge_plate_3mfs: source thumbnail fallback failed (%s)", exc)
+        return found
+
+    base_num, base_bytes = ordered[0]
+    out_buf = BytesIO()
+    base_zip_names: set[str] = set()
+    with (
+        zipfile.ZipFile(BytesIO(base_bytes), "r") as base_zf,
+        zipfile.ZipFile(out_buf, "w", zipfile.ZIP_DEFLATED) as out_zf,
+    ):
+        # Pass 1: emit base entries. Track which per-plate-N thumbnails
+        # the base actually had so the fallback pass below can fill in
+        # the ones that are missing.
+        for item in base_zf.infolist():
+            base_zip_names.add(item.filename)
+            if item.filename == _SLICE_INFO_PATH:
+                out_zf.writestr(item, combined_slice_info)
+            else:
+                out_zf.writestr(item, base_zf.read(item.filename))
+
+        # Source-thumbnail fallback for the base plate when the slicer
+        # didn't write its own preview.
+        for name, payload in _source_thumbnail_fallback(base_num).items():
+            if name not in base_zip_names:
+                out_zf.writestr(name, payload)
+                base_zip_names.add(name)
+
+        # Pass 2: overlay per-plate artifacts from the other plates'
+        # 3MFs, falling back to the source for any plate-N thumbnails
+        # the slicer didn't write.
+        for plate_num, plate_bytes in ordered[1:]:
+            wanted = _per_plate_entries(plate_num)
+            written: set[str] = set()
+            try:
+                with zipfile.ZipFile(BytesIO(plate_bytes), "r") as plate_zf:
+                    for name in plate_zf.namelist():
+                        if name in wanted:
+                            out_zf.writestr(name, plate_zf.read(name))
+                            written.add(name)
+            except (zipfile.BadZipFile, OSError) as exc:
+                logger.warning(
+                    "merge_plate_3mfs: couldn't read plate %d artifacts (%s); skipping",
+                    plate_num,
+                    exc,
+                )
+                continue
+            for name, payload in _source_thumbnail_fallback(plate_num).items():
+                if name not in written and name not in base_zip_names:
+                    out_zf.writestr(name, payload)
+
+    return out_buf.getvalue()
+
+
+def substitute_unused_plate_filaments(source_3mf_bytes: bytes, plate_id: int | None, items: list[str]) -> list[str]:
+    """Replace any filament-list entry whose 1-indexed slot isn't used by
+    ``plate_id`` with the entry at slot 1 (index 0).
+
+    Why: the slice modal lets the user pick a filament profile per slot,
+    but each plate in a multi-plate project only uses a subset of those
+    slots. The modal labels the unused rows "not used by this plate" yet
+    still submits their dropdown values. BambuStudio then validates every
+    loaded filament for material compatibility — PLA in a used slot +
+    ABS defaulted into an unused slot trips
+    "the temperature difference of the filaments used is too large"
+    (exit 194), even though the plate's G-code never touches the ABS
+    slot. Substituting unused entries with slot 1's filament keeps the
+    per-filament array length intact (so the source 3MF's per-slot
+    references stay valid) while making the loaded-filament set
+    materially homogeneous, so the validator passes.
+
+    The substitution is a no-op when:
+    - ``plate_id`` is None (we can't determine which slots are unused),
+    - the source isn't a valid 3MF / zip,
+    - the source doesn't carry plate-extruder metadata (parse returns
+      empty set — treat as "every slot is used", same fallback the
+      SliceModal uses),
+    - ``items`` has fewer than 2 entries (nothing to substitute).
+    """
+    if plate_id is None or len(items) < 2:
+        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
+
+    try:
+        with zipfile.ZipFile(BytesIO(source_3mf_bytes), "r") as zf:
+            used = extract_plate_extruder_set_from_3mf(zf, plate_id)
+    except (zipfile.BadZipFile, OSError) as exc:
+        logger.warning("Plate-filament parse failed (%s); leaving filament list unchanged", exc)
+        return items
+    if not used:
+        # Empty result usually means the source 3MF has no per-object
+        # extruder metadata (single-filament unsliced project). Treating
+        # "no info" as "every slot is used" matches the SliceModal's
+        # fail-open default — better to send the user's picks through
+        # than to silently rewrite them.
+        return items
+    out = list(items)
+    substituted = []
+    for idx in range(len(out)):
+        slot = idx + 1
+        if slot not in used:
+            substituted.append(slot)
+            out[idx] = out[0]
+    if substituted:
+        logger.info(
+            "Substituted slot-1 filament for unused slot(s) %s on plate %s "
+            "(avoids loaded-filament temp-spread validator)",
+            substituted,
+            plate_id,
+        )
+    return out

+ 20 - 0
backend/app/services/slicer_api.py

@@ -337,6 +337,7 @@ class SlicerApiService:
         filament_profile_jsons: list[str],
         plate: int | None = None,
         export_3mf: bool = False,
+        arrange: bool = False,
         request_id: str | None = None,
         on_progress: Callable[[dict], None] | None = None,
     ) -> SliceResult:
@@ -349,6 +350,14 @@ class SlicerApiService:
         slicing service joins them as semicolon-separated
         ``--load-filaments`` for the OrcaSlicer / BambuStudio CLI.
 
+        ``arrange`` forwards the sidecar's ``--arrange`` flag to BambuStudio.
+        When True the slicer auto-repositions objects on the target bed,
+        which Bambuddy uses for cross-nozzle-class re-slices (#1493) where
+        the source's X1C-coordinate layout would otherwise drop into an H2D
+        dead zone or trigger the multi-extruder geometry pipeline's polygon
+        clipping crash. Default off so single-printer slices preserve the
+        user's deliberate layout.
+
         ``request_id``: when supplied, the sidecar wires --pipe to a
         per-request FIFO and publishes structured JSON progress events to
         its in-memory ProgressStore under this id. Bambuddy's slice
@@ -380,6 +389,11 @@ class SlicerApiService:
             data["plate"] = str(plate)
         if export_3mf:
             data["exportType"] = "3mf"
+        if arrange:
+            # Sidecar reads non-empty truthy strings as True; only send the
+            # field when we want the flag on, so default-off callers exactly
+            # match the previous wire payload.
+            data["arrange"] = "true"
         if request_id is not None:
             data["requestId"] = request_id
 
@@ -435,6 +449,7 @@ class SlicerApiService:
         filament_names: list[str],
         plate: int | None = None,
         export_3mf: bool = False,
+        arrange: bool = False,
         bed_type: str | None = None,
         request_id: str | None = None,
         on_progress: Callable[[dict], None] | None = None,
@@ -477,6 +492,11 @@ class SlicerApiService:
             data["plate"] = str(plate)
         if export_3mf:
             data["exportType"] = "3mf"
+        if arrange:
+            # See slice_with_profiles for the rationale: cross-class re-slices
+            # (#1493) need --arrange so BS repositions objects for the target
+            # bed instead of inheriting the source printer's coordinate layout.
+            data["arrange"] = "true"
         if bed_type is not None:
             # #1337: bed-plate override flows through to the sidecar as a
             # standalone field. The sidecar wraps this as --curr_bed_type on

+ 17 - 5
backend/app/services/spool_assignment_notifications.py

@@ -4,6 +4,7 @@ from backend.app.core.database import async_session
 from backend.app.core.websocket import ws_manager
 from backend.app.models.printer import Printer
 from backend.app.models.spool_assignment import SpoolAssignment
+from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.services.bambu_mqtt import PrinterState
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import printer_manager
@@ -127,12 +128,23 @@ async def notify_missing_spool_assignments_on_print_start(
             printer = await db.get(Printer, printer_id)
             printer_name = printer.name if printer else f"Printer {printer_id}"
 
-            assignments_result = await db.execute(
-                SpoolAssignment.__table__.select().where(SpoolAssignment.printer_id == printer_id)
-            )
-            assignments = assignments_result.fetchall()
+            # A tray is "assigned" if it has a row in EITHER table: the legacy
+            # spool_assignment table (internal-inventory mode) or
+            # spoolman_slot_assignments (Spoolman mode — the binding
+            # source-of-truth since #1119). Querying only the legacy table
+            # flagged every used tray as missing on every Spoolman-mode print
+            # (#1473). Both tables expose printer_id / ams_id / tray_id in the
+            # same shape, so _global_tray_from_assignment works on either.
+            legacy_rows = (
+                await db.execute(SpoolAssignment.__table__.select().where(SpoolAssignment.printer_id == printer_id))
+            ).fetchall()
+            spoolman_rows = (
+                await db.execute(
+                    SpoolmanSlotAssignment.__table__.select().where(SpoolmanSlotAssignment.printer_id == printer_id)
+                )
+            ).fetchall()
             assigned_global_trays = {
-                _global_tray_from_assignment(assignment.ams_id, assignment.tray_id) for assignment in assignments
+                _global_tray_from_assignment(row.ams_id, row.tray_id) for row in (*legacy_rows, *spoolman_rows)
             }
 
             missing_global = sorted(used_global_trays - assigned_global_trays)

+ 167 - 12
backend/app/services/spoolman_tracking.py

@@ -67,6 +67,17 @@ def _get_fallback_spool_tag(printer_serial: str, global_tray_id: int) -> str:
     if not printer_serial:
         return ""
     ams_id, tray_id = _global_tray_id_to_ams_slot(global_tray_id)
+    return get_fallback_spool_tag_for_slot(printer_serial, ams_id, tray_id)
+
+
+def get_fallback_spool_tag_for_slot(printer_serial: str, ams_id: int, tray_id: int) -> str:
+    """Public helper matching frontend getFallbackSpoolTag(serial, amsId, trayId).
+
+    Used by stale-tag cleanup (#1457) to detect Spoolman spools still holding
+    this slot's deterministic fallback tag in extra.tag.
+    """
+    if not printer_serial:
+        return ""
     return f"{_hash_serial_to_hex32(printer_serial)}{_to_fixed_hex(ams_id, 4)}{_to_fixed_hex(tray_id, 4)}"
 
 
@@ -344,6 +355,28 @@ async def _get_spoolman_client_with_fallback():
     return client
 
 
+async def _resolve_spool_id_via_slot_assignment(printer_id: int, ams_id: int, tray_id: int) -> int | None:
+    """Look up the Spoolman spool ID locally bound to (printer, ams, tray).
+
+    Fallback path for #1459: when a tag-less spool was assigned via the
+    Bambuddy UI, the user's deterministic fallback tag is intentionally NOT
+    written to Spoolman's extra.tag (kept clean per #1457), so
+    find_spool_by_tag misses. The local spoolman_slot_assignments table is
+    the authoritative binding for those spools.
+    """
+    from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+
+    async with async_session() as db:
+        result = await db.execute(
+            select(SpoolmanSlotAssignment.spoolman_spool_id).where(
+                SpoolmanSlotAssignment.printer_id == printer_id,
+                SpoolmanSlotAssignment.ams_id == ams_id,
+                SpoolmanSlotAssignment.tray_id == tray_id,
+            )
+        )
+        return result.scalar_one_or_none()
+
+
 async def _report_spool_usage_for_slots(
     client,
     filament_usage_items: list[tuple[int, float]],
@@ -351,9 +384,23 @@ async def _report_spool_usage_for_slots(
     slot_to_tray: list | None,
     method_label: str,
     printer_serial: str = "",
+    printer_id: int | None = None,
+    slot_colors_out: dict[int, str] | None = None,
 ) -> int:
     """Report usage to Spoolman for a list of (slot_id, grams) pairs.
 
+    Resolution order per slot: (1) Spoolman extra.tag match against the
+    tray's RFID or deterministic fallback tag, (2) #1459 fallback —
+    local spoolman_slot_assignments table keyed by (printer_id, ams_id,
+    tray_id). Without (2), tag-less spools assigned via the Bambuddy UI
+    never get their weight decremented because their extra.tag is empty
+    on the Spoolman side.
+
+    When ``slot_colors_out`` is provided it is populated with
+    ``{slot_id: color_hex}`` for every resolved spool — used by
+    :func:`report_usage` to stamp the archive's filament colour from the
+    Spoolman spool rather than the slicer's 3MF value (#1494).
+
     Returns number of spools successfully updated.
     """
     spools_updated = 0
@@ -377,22 +424,62 @@ async def _report_spool_usage_for_slots(
             is_external,
         )
 
+        spool_id_to_use: int | None = None
+        resolution_path = ""
+        # color_hex of the resolved spool's filament, for the #1494 archive
+        # colour rewrite. The tag path already has the full spool object;
+        # the slot-assignment path only yields an id and is fetched below.
+        spool_color_hex: str | None = None
+
         spool_tag = _resolve_spool_tag(tray_info, printer_serial, global_tray_id)
-        if not spool_tag:
-            logger.debug("[SPOOLMAN] Slot %s: no identifier for tray %s", slot_id, global_tray_id)
+        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 and printer_id is not None:
+            ams_id, tray_id = _global_tray_id_to_ams_slot(global_tray_id)
+            spool_id_to_use = await _resolve_spool_id_via_slot_assignment(printer_id, ams_id, tray_id)
+            if spool_id_to_use is not None:
+                resolution_path = "slot-assignment"
+
+        if spool_id_to_use is None:
+            logger.debug(
+                "[SPOOLMAN] Slot %s: no spool resolved (tag=%s, no slot-assignment)",
+                slot_id,
+                spool_tag[:16] if spool_tag else "none",
+            )
             continue
 
-        spool = await client.find_spool_by_tag(spool_tag)
-        if not spool:
-            logger.debug("[SPOOLMAN] Slot %s: no spool for tag %s...", slot_id, spool_tag[:16])
-            continue
+        # Record the spool's filament colour for the archive rewrite (#1494).
+        # The slot-assignment path resolved only an id, so fetch the spool.
+        # Strictly best-effort: a colour-fetch failure must never abort the
+        # weight reporting for the remaining slots, so the catch is broad.
+        if slot_colors_out is not None:
+            if spool_color_hex is None:
+                try:
+                    full_spool = await client.get_spool(spool_id_to_use)
+                    spool_color_hex = (full_spool.get("filament") or {}).get("color_hex")
+                except Exception as exc:  # noqa: BLE001 — colour is non-critical
+                    logger.debug("[SPOOLMAN] Slot %s: could not fetch spool colour: %s", slot_id, exc)
+            if spool_color_hex:
+                slot_colors_out[slot_id] = spool_color_hex
 
         try:
-            await client.use_spool(spool["id"], grams_used)
-            logger.info("[SPOOLMAN] %s: slot %s: %sg -> spool %s", method_label, slot_id, grams_used, spool["id"])
+            await client.use_spool(spool_id_to_use, grams_used)
+            logger.info(
+                "[SPOOLMAN] %s: slot %s: %sg -> spool %s (via %s)",
+                method_label,
+                slot_id,
+                grams_used,
+                spool_id_to_use,
+                resolution_path,
+            )
             spools_updated += 1
         except (SpoolmanNotFoundError, SpoolmanClientError, SpoolmanUnavailableError) as exc:
-            logger.warning("[SPOOLMAN] Failed to record usage for spool %s: %s", spool["id"], exc)
+            logger.warning("[SPOOLMAN] Failed to record usage for spool %s: %s", spool_id_to_use, exc)
 
     return spools_updated
 
@@ -515,7 +602,13 @@ async def _report_partial_usage(
                 usage_items.append((slot_id, grams_used))
 
             spools_updated = await _report_spool_usage_for_slots(
-                client, usage_items, ams_trays, slot_to_tray, "Partial (G-code)", printer_serial
+                client,
+                usage_items,
+                ams_trays,
+                slot_to_tray,
+                "Partial (G-code)",
+                printer_serial,
+                printer_id=printer_id,
             )
             if spools_updated > 0:
                 logger.info("[SPOOLMAN] Reported partial usage to %s spool(s) using G-code data", spools_updated)
@@ -547,7 +640,13 @@ async def _report_partial_usage(
             usage_items.append((slot_id, partial_used_g))
 
     spools_updated = await _report_spool_usage_for_slots(
-        client, usage_items, ams_trays, slot_to_tray, "Partial (linear)", printer_serial
+        client,
+        usage_items,
+        ams_trays,
+        slot_to_tray,
+        "Partial (linear)",
+        printer_serial,
+        printer_id=printer_id,
     )
     if spools_updated > 0:
         logger.info("[SPOOLMAN] Reported partial usage to %s spool(s) using linear interpolation", spools_updated)
@@ -601,11 +700,67 @@ async def report_usage(printer_id: int, archive_id: int):
         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]
+        slot_colors: dict[int, str] = {}
         spools_updated = await _report_spool_usage_for_slots(
-            client, usage_items, ams_trays, slot_to_tray, f"Archive {archive_id}", printer_serial
+            client,
+            usage_items,
+            ams_trays,
+            slot_to_tray,
+            f"Archive {archive_id}",
+            printer_serial,
+            printer_id=printer_id,
+            slot_colors_out=slot_colors,
         )
 
         if spools_updated == 0:
             logger.info("[SPOOLMAN] Archive %s: no spools updated", archive_id)
         else:
             logger.info("[SPOOLMAN] Archive %s: updated %s spool(s)", archive_id, spools_updated)
+
+        # Stamp the archive's filament colour from the matched Spoolman spools
+        # so it reflects the curated inventory colour, not the slicer's 3MF
+        # value (#1494) — mirrors the built-in inventory path in usage_tracker.
+        await _apply_spool_colors_to_archive(db, archive_id, filament_usage, slot_colors)
+
+
+async def _apply_spool_colors_to_archive(
+    db,
+    archive_id: int,
+    filament_usage: list[dict],
+    slot_colors: dict[int, str],
+) -> None:
+    """Overwrite an archive's ``filament_color`` with the colours of the
+    Spoolman spools that fed the print (#1494).
+
+    All-or-nothing, exactly like the built-in inventory path: the colour is
+    only rewritten when every used slot resolved to a spool that carries a
+    colour, so a partial match never drops slots from the archive.
+    """
+    if not slot_colors:
+        return
+
+    from backend.app.models.archive import PrintArchive
+    from backend.app.services.usage_tracker import (
+        _archive_colors_from_spools,
+        _spool_color_to_hex,
+    )
+
+    results = [{"slot_id": sid, "color": _spool_color_to_hex(hex_)} for sid, hex_ in slot_colors.items()]
+    colors = _archive_colors_from_spools(filament_usage, results)
+    if not colors:
+        return
+
+    archive = (await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))).scalar_one_or_none()
+    if archive is None:
+        return
+
+    joined = ",".join(colors)
+    if joined != archive.filament_color:
+        logger.info(
+            "[SPOOLMAN] Archive %s filament_color %r -> %r (from Spoolman spools)",
+            archive_id,
+            archive.filament_color,
+            joined,
+        )
+        archive.filament_color = joined
+        await db.commit()

+ 6 - 1
backend/app/services/stl_thumbnail.py

@@ -142,5 +142,10 @@ def generate_stl_thumbnail(
         logger.warning("STL thumbnail generation unavailable (missing dependencies): %s", e)
         return None
     except Exception as e:
-        logger.warning("Failed to generate STL thumbnail for %s: %s", stl_path, e)
+        # Log the traceback, not just the message: a bare
+        # "unsupported operand type(s) for /: 'str' and 'str'" gives no clue
+        # which line failed, and the fault is data-/environment-specific
+        # enough that it can't be reproduced from a clean STL — the traceback
+        # in the next support bundle is what pinpoints it (#1480).
+        logger.warning("Failed to generate STL thumbnail for %s: %s", stl_path, e, exc_info=True)
         return None

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

@@ -63,6 +63,60 @@ def _decode_mqtt_mapping(mapping_raw: list | None) -> list[int] | None:
     return result
 
 
+def _spool_color_to_hex(rgba: str | None) -> str | None:
+    """Normalise a ``Spool.rgba`` value (``RRGGBBAA`` hex, no ``#``) to the
+    ``#RRGGBB`` form archives store in ``filament_color``.
+
+    Alpha is dropped — the archive colour list and the Color Distribution
+    graph treat filament colour as opaque. Returns ``None`` for a missing or
+    too-short value so the caller can fall back to the 3MF colour.
+    """
+    if not rgba:
+        return None
+    h = rgba.strip().lstrip("#")
+    if len(h) < 6:
+        return None
+    return "#" + h[:6].upper()
+
+
+def _archive_colors_from_spools(filament_usage: list[dict], results: list[dict]) -> list[str] | None:
+    """Slot-ordered, de-duplicated hex colours for an archive's ``filament_color``,
+    taken from the inventory spools that actually fed the print (#1494).
+
+    The slicer's 3MF carries its own ``filament_colour`` per slot — a value
+    picked independently of the colour the user curates on the matched
+    inventory spool. So an archive printed from a ``#000000`` inventory spool
+    would otherwise show the slicer's near-black ``#161616``. Once usage
+    tracking has resolved the used slots to spools, the spool colours are the
+    authoritative source and replace the 3MF values.
+
+    Returns ``None`` — leave the 3MF colour untouched — unless *every* slot
+    with non-zero usage was matched to a spool that carries a colour. A
+    partial rewrite would silently drop the unmatched slots' colours from the
+    archive (and the Color Distribution graph), so it is all-or-nothing.
+    """
+    used_slots = {u["slot_id"] for u in filament_usage if u.get("used_g", 0) > 0 and u.get("slot_id") is not None}
+    if not used_slots:
+        return None
+
+    slot_color: dict[int, str] = {}
+    for r in results:
+        slot_id = r.get("slot_id")
+        color = r.get("color")
+        if slot_id is not None and color:
+            slot_color.setdefault(slot_id, color)
+
+    if not used_slots.issubset(slot_color):
+        return None
+
+    ordered: list[str] = []
+    for slot_id in sorted(used_slots):
+        color = slot_color[slot_id]
+        if color not in ordered:
+            ordered.append(color)
+    return ordered
+
+
 def _match_slots_by_color(
     filament_usage: list[dict],
     ams_raw: dict | list | None,
@@ -589,6 +643,10 @@ async def on_print_complete(
                         "tray_id": assign_tray_id,
                         "material": spool.material,
                         "cost": cost,
+                        # AMS remain%-delta fallback has no 3MF slot — slot_id
+                        # stays None so it is excluded from the colour rewrite.
+                        "slot_id": None,
+                        "color": _spool_color_to_hex(spool.rgba),
                     }
                 )
 
@@ -823,6 +881,7 @@ async def _track_from_3mf(
     from backend.app.utils.threemf_tools import extract_filament_usage_from_3mf
 
     file_path: Path | None = threemf_path
+    archive: PrintArchive | None = None
 
     if file_path is None and archive_id:
         result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -1134,6 +1193,8 @@ async def _track_from_3mf(
                         "tray_id": seg_tray_id,
                         "material": spool.material,
                         "cost": cost,
+                        "slot_id": slot_id,
+                        "color": _spool_color_to_hex(spool.rgba),
                     }
                 )
 
@@ -1263,6 +1324,8 @@ async def _track_from_3mf(
                 "tray_id": tray_id,
                 "material": spool.material,
                 "cost": cost,
+                "slot_id": slot_id,
+                "color": _spool_color_to_hex(spool.rgba),
             }
         )
 
@@ -1285,4 +1348,22 @@ async def _track_from_3mf(
             status,
         )
 
+    # --- Adopt the matched inventory spools' colours for the archive (#1494) ---
+    # The archive's filament_color was set from the slicer's 3MF at creation
+    # time; now that every used slot has been resolved to an inventory spool,
+    # the curated spool colour is authoritative. Committed by the caller's
+    # `if results: await db.commit()`.
+    if archive is not None:
+        spool_colors = _archive_colors_from_spools(filament_usage, results)
+        if spool_colors:
+            joined = ",".join(spool_colors)
+            if joined != archive.filament_color:
+                logger.info(
+                    "[UsageTracker] 3MF: archive %s filament_color %r -> %r (from inventory spools)",
+                    archive_id,
+                    archive.filament_color,
+                    joined,
+                )
+                archive.filament_color = joined
+
     return results

+ 22 - 0
backend/app/services/virtual_printer/certificate.py

@@ -330,6 +330,28 @@ class CertificateService:
         logger.info("  Printer: CN=%s", self.serial)
         return self.cert_path, self.key_path
 
+    def get_ca_certificate_info(self) -> dict:
+        """Return the shared CA certificate as PEM text plus identifying metadata.
+
+        Generates the CA if it does not exist yet. Safe to expose over the
+        API: this is the *public* CA certificate users import into their
+        slicer's trust store. The CA private key (``bbl_ca.key``) is never
+        included and never leaves the backend.
+
+        Returns:
+            Dict with ``pem`` (PEM-encoded certificate), ``fingerprint_sha256``
+            (colon-separated uppercase hex) and ``not_valid_after`` (ISO 8601).
+        """
+        _ca_key, ca_cert = self._get_or_create_ca()
+        pem = ca_cert.public_bytes(serialization.Encoding.PEM).decode("ascii")
+        digest = ca_cert.fingerprint(hashes.SHA256()).hex().upper()
+        fingerprint = ":".join(digest[i : i + 2] for i in range(0, len(digest), 2))
+        return {
+            "pem": pem,
+            "fingerprint_sha256": fingerprint,
+            "not_valid_after": ca_cert.not_valid_after_utc.isoformat(),
+        }
+
     def delete_printer_certificate(self) -> None:
         """Delete only the printer certificate (preserves CA)."""
         for path in [self.cert_path, self.key_path]:

+ 170 - 0
backend/app/services/virtual_printer/diagnostic.py

@@ -0,0 +1,170 @@
+"""Setup diagnostic for a virtual printer.
+
+A virtual printer fails for the user in ways a real printer never does: the
+bind IP no longer exists after a host/network change, a service silently
+failed to bind its port, the access code was never set, the slicer was never
+told to trust the CA. The manager swallows per-service start errors
+(``run_with_logging`` in ``start_server``), so a service object can exist
+while nothing is actually listening — the only reliable signal is probing the
+bind IP's ports from the outside, which is what this does.
+
+Each check carries a stable ``id`` and a ``status`` of pass / fail / warn /
+skip; the frontend renders the localized title and fix text keyed on that
+id + status.
+"""
+
+import asyncio
+import logging
+
+from backend.app.models.virtual_printer import VirtualPrinter
+from backend.app.schemas.printer import DiagnosticCheck
+from backend.app.schemas.virtual_printer import VPDiagnosticResult
+
+logger = logging.getLogger(__name__)
+
+# Server-mode listening ports — see virtual_printer/manager.py start_server().
+PORT_FTPS = 990  # implicit FTPS — slicer file upload
+PORT_MQTT = 8883  # MQTT over TLS — control + status
+PORT_BIND = 3002  # bind/detect (TLS) — slicer discovery handshake
+
+_PORT_PROBE_TIMEOUT = 2.0
+
+
+async def _check_port(ip: str, port: int, timeout: float = _PORT_PROBE_TIMEOUT) -> bool:
+    """Test TCP connectivity to ip:port. Returns True if something is listening."""
+    try:
+        _reader, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=timeout)
+        writer.close()
+        try:
+            await writer.wait_closed()
+        except Exception:
+            pass
+        return True
+    except Exception:
+        return False
+
+
+async def run_vp_diagnostic(vp: VirtualPrinter, instance) -> VPDiagnosticResult:
+    """Run setup checks for a virtual printer.
+
+    Args:
+        vp: The virtual printer DB row.
+        instance: The running ``VirtualPrinterInstance`` from the manager, or
+            ``None`` if the VP is not currently instantiated.
+    """
+    checks: list[DiagnosticCheck] = []
+    is_proxy = vp.mode == "proxy"
+    running = bool(instance and instance.is_running)
+
+    # --- VP enabled ---
+    checks.append(DiagnosticCheck(id="enabled", status="pass" if vp.enabled else "fail"))
+
+    # --- Instance running ---
+    if not vp.enabled:
+        checks.append(DiagnosticCheck(id="running", status="skip"))
+    else:
+        checks.append(DiagnosticCheck(id="running", status="pass" if running else "fail"))
+
+    # --- Bind interface still exists ---
+    # A bind IP picked weeks ago can vanish after a Docker restart or a router
+    # handing out a different lease — the VP then binds nothing and is invisible.
+    if not vp.bind_ip:
+        checks.append(DiagnosticCheck(id="bind_interface", status="fail"))
+    else:
+        from backend.app.services.network_utils import find_interface_for_ip
+
+        iface = find_interface_for_ip(vp.bind_ip)
+        checks.append(
+            DiagnosticCheck(
+                id="bind_interface",
+                status="pass" if iface else "fail",
+                params={"bind_ip": vp.bind_ip},
+            )
+        )
+
+    # --- Access code (non-proxy modes only) ---
+    if is_proxy:
+        checks.append(DiagnosticCheck(id="access_code", status="skip"))
+    else:
+        checks.append(DiagnosticCheck(id="access_code", status="pass" if vp.access_code else "fail"))
+
+    # --- Target printer (proxy mode only) ---
+    if not is_proxy:
+        checks.append(DiagnosticCheck(id="target_printer", status="skip"))
+    elif not vp.target_printer_id:
+        checks.append(DiagnosticCheck(id="target_printer", status="fail"))
+    else:
+        from backend.app.services.printer_manager import printer_manager
+
+        state = printer_manager.get_status(vp.target_printer_id)
+        online = bool(state and state.connected)
+        # A configured-but-offline target degrades proxying but isn't a setup
+        # error on the VP's side — warn rather than fail.
+        checks.append(DiagnosticCheck(id="target_printer", status="pass" if online else "warn"))
+
+    # --- Service ports actually listening on the bind IP ---
+    # The decisive check: a service object can exist while its socket never
+    # bound (port already in use, permission denied) because start errors are
+    # logged and swallowed. Probe the bind IP directly.
+    bind_ip = vp.bind_ip
+    if not running or not bind_ip:
+        for cid, port in (("port_ftps", PORT_FTPS), ("port_mqtt", PORT_MQTT), ("port_bind", PORT_BIND)):
+            checks.append(DiagnosticCheck(id=cid, status="skip", params={"port": port}))
+    elif is_proxy:
+        # Proxy mode listens on dynamic ports reported by the proxy manager,
+        # and runs no bind/detect server.
+        proxy_status = instance.get_status().get("proxy", {})
+        ftp_port = proxy_status.get("ftp_port")
+        mqtt_port = proxy_status.get("mqtt_port")
+        ftp_ok = await _check_port(bind_ip, ftp_port) if ftp_port else False
+        mqtt_ok = await _check_port(bind_ip, mqtt_port) if mqtt_port else False
+        checks.append(
+            DiagnosticCheck(
+                id="port_ftps",
+                status="pass" if ftp_ok else "fail",
+                params={"port": ftp_port or PORT_FTPS},
+            )
+        )
+        checks.append(
+            DiagnosticCheck(
+                id="port_mqtt",
+                status="pass" if mqtt_ok else "fail",
+                params={"port": mqtt_port or PORT_MQTT},
+            )
+        )
+        checks.append(DiagnosticCheck(id="port_bind", status="skip", params={"port": PORT_BIND}))
+    else:
+        ftp_ok, mqtt_ok, bind_ok = await asyncio.gather(
+            _check_port(bind_ip, PORT_FTPS),
+            _check_port(bind_ip, PORT_MQTT),
+            _check_port(bind_ip, PORT_BIND),
+        )
+        checks.append(DiagnosticCheck(id="port_ftps", status="pass" if ftp_ok else "fail", params={"port": PORT_FTPS}))
+        checks.append(DiagnosticCheck(id="port_mqtt", status="pass" if mqtt_ok else "fail", params={"port": PORT_MQTT}))
+        checks.append(DiagnosticCheck(id="port_bind", status="pass" if bind_ok else "fail", params={"port": PORT_BIND}))
+
+    # --- TLS certificate ---
+    # When running, the cert chain must exist on disk for the slicer's TLS
+    # handshake to succeed. This is a pass/fail on the file; the localized
+    # detail text reminds the user to import the CA into the slicer.
+    if not running:
+        checks.append(DiagnosticCheck(id="certificate", status="skip"))
+    else:
+        cert_ok = bool(instance and instance.cert_path.exists())
+        checks.append(DiagnosticCheck(id="certificate", status="pass" if cert_ok else "fail"))
+
+    statuses = {c.status for c in checks}
+    if "fail" in statuses:
+        overall = "problems"
+    elif "warn" in statuses:
+        overall = "warnings"
+    else:
+        overall = "ok"
+
+    return VPDiagnosticResult(
+        vp_id=vp.id,
+        vp_name=vp.name,
+        mode=vp.mode,
+        overall=overall,
+        checks=checks,
+    )

+ 12 - 0
backend/app/services/virtual_printer/manager.py

@@ -877,6 +877,18 @@ class VirtualPrinterManager:
         """Inject the global printer_manager so non-proxy VPs can mirror their target's MQTT stream."""
         self._printer_manager = printer_manager
 
+    def get_ca_certificate_info(self) -> dict:
+        """Return the shared virtual-printer CA certificate for slicer-trust import.
+
+        The CA is shared by every VP (one import covers all of them). It is
+        generated on demand here if no VP has triggered cert generation yet,
+        so the "copy/download certificate" UI works even before the first VP
+        is enabled.
+        """
+        certs_dir = self._base_dir / "certs"
+        cert_service = CertificateService(cert_dir=certs_dir, shared_ca_dir=certs_dir)
+        return cert_service.get_ca_certificate_info()
+
     @property
     def is_enabled(self) -> bool:
         """Check if any virtual printer is running."""

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

@@ -137,6 +137,31 @@ ETHERNET_MODELS = frozenset(
 )
 
 
+# Dual-nozzle (dual-extruder) printers. Single source of truth for nozzle
+# class — consumed by ``BambuMQTTClient.start_print``, the K-profile routes,
+# and the re-slice nozzle-class guard (previously an inline model tuple
+# duplicated across all three). Re-slicing a model laid out for a single-nozzle
+# printer onto one of these — or vice versa — is not yet supported: the source
+# 3MF's embedded single-nozzle filament/extruder layout is not a valid
+# dual-nozzle project and BambuStudio's multi-extruder validator rejects it.
+DUAL_NOZZLE_MODELS = frozenset(
+    [
+        # Display names (uppercase, no spaces)
+        "H2D",
+        "H2DPRO",
+        "H2C",
+        "X2D",
+        # Internal codes
+        "O1D",  # H2D
+        "O1E",  # H2D Pro
+        "O2D",  # H2D Pro (alternate)
+        "O1C",  # H2C
+        "O1C2",  # H2C (dual nozzle variant)
+        "N6",  # X2D
+    ]
+)
+
+
 def has_ethernet(model: str | None) -> bool:
     """Return True if the printer model has an ethernet port."""
     if not model:
@@ -145,6 +170,14 @@ def has_ethernet(model: str | None) -> bool:
     return normalized in ETHERNET_MODELS
 
 
+def is_dual_nozzle_model(model: str | None) -> bool:
+    """Return True if the printer model has two nozzles (H2D family / X2D)."""
+    if not model:
+        return False
+    normalized = model.strip().upper().replace(" ", "").replace("-", "")
+    return normalized in DUAL_NOZZLE_MODELS
+
+
 def get_rod_type(model: str | None) -> str | None:
     """Return the rod/rail type for a printer model.
 

+ 39 - 32
backend/app/utils/threemf_tools.py

@@ -267,6 +267,45 @@ def extract_filament_properties_from_3mf(file_path: Path) -> dict[int, dict]:
     return properties
 
 
+def _first_settings_id(value: object) -> str | None:
+    """A ``*_settings_id`` value is usually a string, occasionally a list (one
+    entry per extruder). Return the first non-empty string, else None."""
+    if isinstance(value, str):
+        return value.strip() or None
+    if isinstance(value, list):
+        for item in value:
+            if isinstance(item, str) and item.strip():
+                return item.strip()
+    return None
+
+
+def extract_embedded_presets_from_3mf(zf: zipfile.ZipFile) -> dict[str, str | None]:
+    """Read the printer / process preset names a 3MF project was prepared with.
+
+    BambuStudio / OrcaSlicer write the chosen preset names into
+    ``Metadata/project_settings.config`` (``printer_settings_id`` and
+    ``print_settings_id``). The SliceModal uses them to default its printer
+    and process dropdowns to what the file was sliced for (#1325) instead of
+    blindly taking the first listed preset.
+
+    Returns ``{"printer": <name|None>, "process": <name|None>}``. Every failure
+    mode (missing config, malformed JSON, unexpected shape) yields ``None``
+    values so the modal falls back to its own defaults.
+    """
+    result: dict[str, str | None] = {"printer": None, "process": None}
+    try:
+        if "Metadata/project_settings.config" not in zf.namelist():
+            return result
+        data = json.loads(zf.read("Metadata/project_settings.config").decode())
+    except (KeyError, ValueError, OSError):
+        return result
+    if not isinstance(data, dict):
+        return result
+    result["printer"] = _first_settings_id(data.get("printer_settings_id"))
+    result["process"] = _first_settings_id(data.get("print_settings_id"))
+    return result
+
+
 def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | None:
     """Extract per-slot nozzle/extruder mapping from a 3MF file.
 
@@ -609,38 +648,6 @@ def inject_gcode_into_3mf(
         return None
 
 
-def extract_source_printer_model_from_3mf(zf: zipfile.ZipFile) -> str | None:
-    """Source 3MF's bound printer model from ``Metadata/project_settings.config``.
-
-    Returns e.g. ``"Bambu Lab A1"`` when the project was built for an A1, or
-    ``None`` when the file lacks the metadata or the field is absent. The
-    SliceModal uses this to warn the user before slicing if the chosen
-    printer profile targets a different model — the slicer CLI rejects
-    cross-printer slicing with rc=-16 and the result, when the strip + load
-    fallback masks it, is a misleadingly-tagged archive.
-    """
-    if "Metadata/project_settings.config" not in zf.namelist():
-        return None
-    try:
-        proj = json.loads(zf.read("Metadata/project_settings.config").decode())
-    except (ValueError, OSError):
-        return None
-    if not isinstance(proj, dict):
-        return None
-    model = proj.get("printer_model")
-    if isinstance(model, str) and model.strip():
-        return model.strip()
-    # Some older Bambu Studio exports stored the model under
-    # ``printer_settings_id`` (e.g. "Bambu Lab A1 0.4 nozzle"); strip the
-    # nozzle suffix to get the canonical model name. Best-effort — if the
-    # field doesn't follow the convention we leave it as-is.
-    settings_id = proj.get("printer_settings_id")
-    if isinstance(settings_id, str) and settings_id.strip():
-        # Drop trailing " 0.4 nozzle" / " 0.2 nozzle" / etc.
-        return re.sub(r"\s+0\.\d+\s+nozzle$", "", settings_id.strip())
-    return None
-
-
 def extract_project_filaments_from_3mf(zf: zipfile.ZipFile) -> list[dict]:
     """Project-wide AMS slot config from ``Metadata/project_settings.config``.
 

+ 96 - 0
backend/tests/integration/test_archives_api.py

@@ -168,6 +168,102 @@ class TestArchivesAPI:
         assert response.status_code == 200
         assert response.json()["external_url"] is None
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_archive_failure_reason_mirrors_to_print_log_entry(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """#1444: PATCH /archives/{id} with failure_reason must mirror to the
+        latest PrintLogEntry so the Stats page Failure Analysis widget
+        (which reads PrintLogEntry.failure_reason) reflects the user's
+        reclassification instead of showing "Unknown" forever.
+        """
+        from sqlalchemy import select
+
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        # archive_factory auto-creates a matching PrintLogEntry (failure_reason
+        # carried from the archive, which is NULL here — same shape as the bug
+        # repro: print completed → log entry written with NULL → user goes to
+        # classify the failure afterwards).
+        archive = await archive_factory(printer.id, print_name="Failed Print", status="failed", run_status="failed")
+
+        response = await async_client.patch(
+            f"/api/v1/archives/{archive.id}",
+            json={"failure_reason": "Adhesion failure"},
+        )
+        assert response.status_code == 200
+
+        result = await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
+        mirrored = result.scalar_one()
+        assert mirrored.failure_reason == "Adhesion failure"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_archive_status_mirrors_to_print_log_entry(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """#1444: PATCH /archives/{id} with status must mirror to the latest
+        PrintLogEntry so stats that filter on PrintLogEntry.status see the
+        user's reclassification.
+        """
+        from sqlalchemy import select
+
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, run_status="completed")
+
+        response = await async_client.patch(
+            f"/api/v1/archives/{archive.id}",
+            json={"status": "failed"},
+        )
+        assert response.status_code == 200
+
+        result = await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
+        mirrored = result.scalar_one()
+        assert mirrored.status == "failed"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_archive_failure_reason_only_touches_latest_entry(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """#1444: For an archive with multiple runs (reprints), only the
+        latest PrintLogEntry should receive the reclassification. Earlier
+        runs were classified at their own time and must not be retroactively
+        overwritten.
+        """
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        # First run — created by the factory's auto-run with its own reason.
+        archive = await archive_factory(printer.id, status="failed", run_status="failed")
+        from sqlalchemy import select
+
+        first_run = (
+            await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
+        ).scalar_one()
+        first_run.failure_reason = "Filament tangle"
+        await db_session.commit()
+
+        # Second run — the reprint that just finished with NULL classification.
+        latest_run = PrintLogEntry(archive_id=archive.id, status="failed", failure_reason=None)
+        db_session.add(latest_run)
+        await db_session.commit()
+
+        response = await async_client.patch(
+            f"/api/v1/archives/{archive.id}",
+            json={"failure_reason": "Adhesion failure"},
+        )
+        assert response.status_code == 200
+
+        await db_session.refresh(first_run)
+        await db_session.refresh(latest_run)
+        assert first_run.failure_reason == "Filament tangle"
+        assert latest_run.failure_reason == "Adhesion failure"
+
     # ========================================================================
     # Delete endpoints
     # ========================================================================

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

@@ -22,6 +22,7 @@ import httpx
 import pytest
 from httpx import AsyncClient
 
+from backend.app.api.routes.library import _slicer_rejection_message
 from backend.app.core.config import settings as app_settings
 from backend.app.models.library import LibraryFile
 from backend.app.models.local_preset import LocalPreset
@@ -775,3 +776,948 @@ class TestSliceJobs:
         slice_dispatch._jobs.clear()
         r = await async_client.get("/api/v1/slice-jobs/999999")
         assert r.status_code == 404
+
+
+# ---------------------------------------------------------------------------
+# POST /archives/{id}/slice — re-sliced archive reflects the target printer
+# ---------------------------------------------------------------------------
+
+
+def _make_sliced_3mf(printer_model_id: str, bed_type: str | None = None) -> bytes:
+    """A minimal sliced-output 3MF that embeds a printer_model_id in
+    slice_info.config, the way a real Bambu Studio / OrcaSlicer export does.
+    ThreeMFParser reads this into metadata['sliced_for_model']. When
+    ``bed_type`` is set, also embed ``curr_bed_type`` so the parser surfaces
+    ``metadata['bed_type']`` — needed for the bed-type lift assertion in
+    TestSliceArchiveReslicedBedType."""
+    extra_meta = f"<metadata key='curr_bed_type' value='{bed_type}'/>" if bed_type else ""
+    buf = io.BytesIO()
+    with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+        zf.writestr("3D/3dmodel.model", "<model/>")
+        zf.writestr(
+            "Metadata/slice_info.config",
+            (
+                "<config><plate>"
+                f"<metadata key='printer_model_id' value='{printer_model_id}'/>"
+                f"{extra_meta}"
+                "</plate></config>"
+            ),
+        )
+    return buf.getvalue()
+
+
+class TestCrossClassSliceAllLoop:
+    """#1493: when the user picks "Slice all plates" on a cross-class source
+    (X1C → H2D), Bambuddy must NOT send a single ``--slice 0 --arrange 1``
+    call — that consolidates every plate's objects onto one bed via BS's
+    project-wide arrange. Instead it loops per plate (``plate=N, arrange=true``)
+    and merges the N single-plate outputs into one multi-plate 3MF locally.
+    This test mocks the sidecar to assert (a) N calls happen, one per plate,
+    each with arrange=true, and (b) the resulting archive's stored 3MF
+    contains plate_1..plate_N.gcode entries."""
+
+    @staticmethod
+    def _make_multi_plate_x1c_source(plate_count: int = 3) -> bytes:
+        """Source 3MF: X1C-stamped, N plates declared via model_settings."""
+        plate_blocks = "\n".join(
+            f'<plate><metadata key="plater_id" value="{i}"/></plate>' for i in range(1, plate_count + 1)
+        )
+        buf = io.BytesIO()
+        with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("3D/3dmodel.model", "<model/>")
+            zf.writestr(
+                "Metadata/project_settings.config",
+                json.dumps({"printer_model": "Bambu Lab X1 Carbon"}),
+            )
+            zf.writestr(
+                "Metadata/model_settings.config",
+                f"<?xml version='1.0'?>\n<config>\n{plate_blocks}\n</config>\n",
+            )
+        return buf.getvalue()
+
+    @staticmethod
+    def _make_single_plate_sliced_output(plate_num: int) -> bytes:
+        """Mock per-plate output: looks like what BS CLI returns for
+        --slice N. Carries an H2D project_settings (target), a one-line
+        slice_info <plate> block, and a per-plate gcode + thumbnail."""
+        buf = io.BytesIO()
+        with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("3D/3dmodel.model", "<model/>")
+            zf.writestr(
+                "Metadata/project_settings.config",
+                json.dumps({"printer_model": "Bambu Lab H2D"}),
+            )
+            zf.writestr("Metadata/model_settings.config", "<config/>")
+            zf.writestr(
+                "Metadata/slice_info.config",
+                f"<config><plate><metadata key='index' value='{plate_num}'/>"
+                f"<metadata key='printer_model_id' value='O1D'/></plate></config>",
+            )
+            zf.writestr(f"Metadata/plate_{plate_num}.gcode", f"G{plate_num}".encode())
+            zf.writestr(f"Metadata/plate_{plate_num}.gcode.md5", b"deadbeef")
+            zf.writestr(f"Metadata/plate_{plate_num}.json", b"{}")
+            zf.writestr(f"Metadata/plate_{plate_num}.png", f"P{plate_num}".encode())
+        return buf.getvalue()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_loops_per_plate_when_cross_class_with_plate_zero(
+        self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
+    ):
+        from backend.app.models.archive import PrintArchive
+
+        tmp_path = slice_test_setup["tmp_path"]
+        monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
+
+        src_dir = tmp_path / "archives" / "src"
+        src_dir.mkdir(parents=True, exist_ok=True)
+        src_3mf = src_dir / "mewtwo.3mf"
+        src_3mf.write_bytes(self._make_multi_plate_x1c_source(plate_count=3))
+        printer = await printer_factory()
+        source = await archive_factory(
+            printer.id,
+            filename="mewtwo.3mf",
+            file_path=str(src_3mf.relative_to(tmp_path)),
+            sliced_for_model="X1C",
+            with_run=False,
+        )
+
+        # H2D target preset — the cross-class detector reads the
+        # ``printer_model`` field off the resolved JSON.
+        h2d = LocalPreset(
+            name="# Bambu Lab H2D 0.4 nozzle",
+            preset_type="printer",
+            source="orcaslicer",
+            setting=json.dumps({"name": "Bambu Lab H2D 0.4 nozzle", "printer_model": "Bambu Lab H2D"}),
+        )
+        db_session.add(h2d)
+        await db_session.commit()
+        await db_session.refresh(h2d)
+
+        # Mock sidecar: capture every request and respond with that
+        # plate's single-plate output. We expect one request per plate
+        # in the source (3 here).
+        captured_requests: list[dict] = []
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            # Multipart bodies aren't trivially parseable here; pull
+            # the plate field by string search since the helper sends
+            # ``name="plate"`` immediately followed by the value.
+            body = request.content
+            plate = None
+            marker = b'name="plate"\r\n\r\n'
+            idx = body.find(marker)
+            if idx != -1:
+                # Find the next CRLF after the value start.
+                start = idx + len(marker)
+                end = body.find(b"\r\n", start)
+                try:
+                    plate = int(body[start:end].decode("utf-8"))
+                except (UnicodeDecodeError, ValueError):
+                    plate = None
+            arrange_in_body = b'name="arrange"' in body
+            captured_requests.append({"plate": plate, "arrange": arrange_in_body})
+
+            return httpx.Response(
+                status_code=200,
+                content=self._make_single_plate_sliced_output(plate or 1),
+                headers={
+                    "x-print-time-seconds": "600",
+                    "x-filament-used-g": "5.0",
+                    "x-filament-used-mm": "1600.0",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+
+        # plate=0 + cross-class triplet → backend should enter the
+        # per-plate loop, slice each of the 3 plates with arrange=True,
+        # and merge into one archive.
+        resp = await async_client.post(
+            f"/api/v1/archives/{source.id}/slice",
+            json={
+                "printer_preset": {"source": "local", "id": str(h2d.id)},
+                "process_preset": {"source": "local", "id": str(slice_test_setup["process_id"])},
+                "filament_presets": [{"source": "local", "id": str(slice_test_setup["filament_id"])}],
+                "plate": 0,
+            },
+        )
+        assert resp.status_code == 202, resp.text
+
+        final = await _wait_for_job(async_client, resp.json()["job_id"], timeout=15.0)
+        assert final["status"] == "completed", final
+
+        # Exactly one sidecar call per plate, in plate order. The
+        # ``--arrange 1`` flag travels with every per-plate sub-slice
+        # (it's what fixes the cross-class boundary error).
+        plates_called = [c["plate"] for c in captured_requests]
+        arrange_used = [c["arrange"] for c in captured_requests]
+        assert plates_called == [1, 2, 3], plates_called
+        assert all(arrange_used), arrange_used
+
+        # The merged archive has plate_1..plate_3.gcode inside its one
+        # output 3MF (single Bambuddy archive, three plates).
+        new_archive = await db_session.get(PrintArchive, final["result"]["archive_id"])
+        archive_path = tmp_path / new_archive.file_path
+        with zipfile.ZipFile(archive_path, "r") as zf:
+            entries = set(zf.namelist())
+        assert "Metadata/plate_1.gcode" in entries
+        assert "Metadata/plate_2.gcode" in entries
+        assert "Metadata/plate_3.gcode" in entries
+        # Per-plate-result totals are summed onto the merged archive.
+        assert new_archive.print_time_seconds == 600 * 3
+        assert new_archive.filament_used_grams == pytest.approx(5.0 * 3)
+
+
+class TestSliceArchiveResliceModel:
+    """Re-slicing an archive for a different printer must stamp the new
+    archive with the printer it was sliced FOR, not the source's printer."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reslice_uses_target_model_not_source_model(
+        self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
+    ):
+        from backend.app.models.archive import PrintArchive
+
+        tmp_path = slice_test_setup["tmp_path"]
+        # archive_dir is a static path off the real data dir; point it under
+        # base_dir (= tmp_path) so the new archive's file resolves there.
+        monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
+
+        # Source archive: a 3MF that was sliced for an X1C.
+        src_dir = tmp_path / "archives" / "src"
+        src_dir.mkdir(parents=True, exist_ok=True)
+        src_3mf = src_dir / "cube.3mf"
+        src_3mf.write_bytes(_make_3mf_with_settings())
+        printer = await printer_factory()
+        source = await archive_factory(
+            printer.id,
+            filename="cube.3mf",
+            file_path=str(src_3mf.relative_to(tmp_path)),
+            sliced_for_model="X1C",
+            with_run=False,
+        )
+        source_id = source.id
+
+        # The slicer returns a 3MF whose embedded printer_model_id is O1D (H2D).
+        def handler(request: httpx.Request) -> httpx.Response:
+            return httpx.Response(
+                status_code=200,
+                content=_make_sliced_3mf("O1D"),
+                headers={
+                    "x-print-time-seconds": "600",
+                    "x-filament-used-g": "5.0",
+                    "x-filament-used-mm": "1600.0",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+
+        resp = await async_client.post(
+            f"/api/v1/archives/{source_id}/slice",
+            json={
+                "printer_preset_id": slice_test_setup["printer_id"],
+                "process_preset_id": slice_test_setup["process_id"],
+                "filament_preset_id": slice_test_setup["filament_id"],
+            },
+        )
+        assert resp.status_code == 202, resp.text
+
+        final = await _wait_for_job(async_client, resp.json()["job_id"])
+        assert final["status"] == "completed", final
+
+        new_id = final["result"]["archive_id"]
+        assert new_id != source_id
+
+        new_archive = await db_session.get(PrintArchive, new_id)
+        # The fix: the re-sliced archive reflects H2D — the printer it was
+        # sliced for — instead of inheriting X1C from the source archive.
+        assert new_archive.sliced_for_model == "H2D"
+
+        # Source archive is untouched.
+        source_reloaded = await db_session.get(PrintArchive, source_id)
+        assert source_reloaded.sliced_for_model == "X1C"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cross_model_reslice_drops_source_printer_id(
+        self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
+    ):
+        """A cross-model re-slice (source's X1C → target's H2D) must not carry
+        over ``source.printer_id``. The archive card and reprint modal both
+        read ``printer_id`` first and only fall back to ``sliced_for_model``
+        when it's None, so leaving the inherited id makes the H2D-sliced card
+        display the source's X1C printer name (the "Workshop H2C" bug)."""
+        from backend.app.models.archive import PrintArchive
+
+        tmp_path = slice_test_setup["tmp_path"]
+        monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
+
+        src_dir = tmp_path / "archives" / "src"
+        src_dir.mkdir(parents=True, exist_ok=True)
+        src_3mf = src_dir / "cube.3mf"
+        src_3mf.write_bytes(_make_3mf_with_settings())
+        source_printer = await printer_factory()
+        source = await archive_factory(
+            source_printer.id,
+            filename="cube.3mf",
+            file_path=str(src_3mf.relative_to(tmp_path)),
+            sliced_for_model="X1C",
+            with_run=False,
+        )
+        source_id = source.id
+        source_printer_id = source_printer.id
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            return httpx.Response(
+                status_code=200,
+                content=_make_sliced_3mf("O1D"),  # H2D
+                headers={
+                    "x-print-time-seconds": "600",
+                    "x-filament-used-g": "5.0",
+                    "x-filament-used-mm": "1600.0",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+
+        resp = await async_client.post(
+            f"/api/v1/archives/{source_id}/slice",
+            json={
+                "printer_preset_id": slice_test_setup["printer_id"],
+                "process_preset_id": slice_test_setup["process_id"],
+                "filament_preset_id": slice_test_setup["filament_id"],
+            },
+        )
+        assert resp.status_code == 202, resp.text
+        final = await _wait_for_job(async_client, resp.json()["job_id"])
+        assert final["status"] == "completed", final
+
+        new_archive = await db_session.get(PrintArchive, final["result"]["archive_id"])
+        assert new_archive.sliced_for_model == "H2D"
+        # Card / reprint modal will now fall back to the sliced_for_model
+        # badge instead of showing the source printer's name.
+        assert new_archive.printer_id is None
+
+        # Source untouched: still bound to its original printer.
+        source_reloaded = await db_session.get(PrintArchive, source_id)
+        assert source_reloaded.printer_id == source_printer_id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_same_model_reslice_preserves_source_printer_id(
+        self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
+    ):
+        """Same-model re-slice (X1C → X1C, e.g. just swapped a process preset)
+        keeps ``printer_id`` so the reprint modal pre-selects the original
+        printer. Only cross-model re-slices null it out."""
+        from backend.app.models.archive import PrintArchive
+
+        tmp_path = slice_test_setup["tmp_path"]
+        monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
+
+        src_dir = tmp_path / "archives" / "src"
+        src_dir.mkdir(parents=True, exist_ok=True)
+        src_3mf = src_dir / "cube.3mf"
+        src_3mf.write_bytes(_make_3mf_with_settings())
+        source_printer = await printer_factory()
+        source = await archive_factory(
+            source_printer.id,
+            filename="cube.3mf",
+            file_path=str(src_3mf.relative_to(tmp_path)),
+            sliced_for_model="X1C",
+            with_run=False,
+        )
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            return httpx.Response(
+                status_code=200,
+                content=_make_sliced_3mf("C11"),  # X1C — same model as source
+                headers={
+                    "x-print-time-seconds": "600",
+                    "x-filament-used-g": "5.0",
+                    "x-filament-used-mm": "1600.0",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+
+        resp = await async_client.post(
+            f"/api/v1/archives/{source.id}/slice",
+            json={
+                "printer_preset_id": slice_test_setup["printer_id"],
+                "process_preset_id": slice_test_setup["process_id"],
+                "filament_preset_id": slice_test_setup["filament_id"],
+            },
+        )
+        assert resp.status_code == 202, resp.text
+        final = await _wait_for_job(async_client, resp.json()["job_id"])
+        assert final["status"] == "completed", final
+
+        new_archive = await db_session.get(PrintArchive, final["result"]["archive_id"])
+        assert new_archive.sliced_for_model == "X1C"
+        # Same-model: keep the source's printer assignment so reprint pre-selects it.
+        assert new_archive.printer_id == source_printer.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reslice_with_unknown_source_model_preserves_printer_id(
+        self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
+    ):
+        """When ``source.sliced_for_model`` is None (older archive that
+        predates that column being populated), the backend can't tell whether
+        this is a cross-model re-slice. Fail open and preserve ``printer_id``
+        rather than spuriously nulling it — current pre-fix behaviour, kept
+        as a deliberate edge case."""
+        from backend.app.models.archive import PrintArchive
+
+        tmp_path = slice_test_setup["tmp_path"]
+        monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
+
+        src_dir = tmp_path / "archives" / "src"
+        src_dir.mkdir(parents=True, exist_ok=True)
+        src_3mf = src_dir / "cube.3mf"
+        src_3mf.write_bytes(_make_3mf_with_settings())
+        source_printer = await printer_factory()
+        source = await archive_factory(
+            source_printer.id,
+            filename="cube.3mf",
+            file_path=str(src_3mf.relative_to(tmp_path)),
+            sliced_for_model=None,
+            with_run=False,
+        )
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            return httpx.Response(
+                status_code=200,
+                content=_make_sliced_3mf("O1D"),
+                headers={
+                    "x-print-time-seconds": "600",
+                    "x-filament-used-g": "5.0",
+                    "x-filament-used-mm": "1600.0",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+
+        resp = await async_client.post(
+            f"/api/v1/archives/{source.id}/slice",
+            json={
+                "printer_preset_id": slice_test_setup["printer_id"],
+                "process_preset_id": slice_test_setup["process_id"],
+                "filament_preset_id": slice_test_setup["filament_id"],
+            },
+        )
+        assert resp.status_code == 202, resp.text
+        final = await _wait_for_job(async_client, resp.json()["job_id"])
+        assert final["status"] == "completed", final
+
+        new_archive = await db_session.get(PrintArchive, final["result"]["archive_id"])
+        # Insufficient info to decide cross-model → preserve printer_id.
+        assert new_archive.printer_id == source_printer.id
+
+
+class TestSliceArchiveReslicedThumbnail:
+    """#1493 follow-up: the re-sliced archive's cover image preference order is
+    source's per-plate render > sliced output's per-plate render >
+    Auxiliaries marketing thumbnail. BS CLI rarely writes a fresh
+    ``Metadata/plate_N.png`` on the sliced output, so the source's render
+    of the same plate (closer to what's actually printing) wins over the
+    project-wide marketing image."""
+
+    @staticmethod
+    def _make_source_with_plate_png(plate_png_bytes: bytes) -> bytes:
+        buf = io.BytesIO()
+        with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("3D/3dmodel.model", "<model/>")
+            zf.writestr("Metadata/plate_1.png", plate_png_bytes)
+            # Project-wide marketing image — the unwanted fallback target.
+            zf.writestr("Auxiliaries/.thumbnails/thumbnail_middle.png", b"COVER_ART")
+        return buf.getvalue()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_uses_source_plate_png_when_sliced_output_lacks_one(
+        self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
+    ):
+        """Sliced output has no per-plate PNG (typical of BS CLI output
+        with --arrange). The source's plate_1.png must win over the
+        sliced output's Auxiliaries fallback."""
+        from backend.app.models.archive import PrintArchive
+
+        tmp_path = slice_test_setup["tmp_path"]
+        monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
+
+        # Source has its own plate_1.png AND a project-wide cover.
+        source_plate_marker = b"SOURCE_PLATE_RENDER"
+        src_dir = tmp_path / "archives" / "src"
+        src_dir.mkdir(parents=True, exist_ok=True)
+        src_3mf = src_dir / "cube.3mf"
+        src_3mf.write_bytes(self._make_source_with_plate_png(source_plate_marker))
+        printer = await printer_factory()
+        source = await archive_factory(
+            printer.id,
+            filename="cube.3mf",
+            file_path=str(src_3mf.relative_to(tmp_path)),
+            sliced_for_model="X1C",
+            with_run=False,
+        )
+
+        # Mock slicer returns a 3MF with NO Metadata/plate_1.png — only
+        # the Auxiliaries cover, mimicking BS CLI output with --arrange.
+        def handler(request: httpx.Request) -> httpx.Response:
+            sliced_buf = io.BytesIO()
+            with zipfile.ZipFile(sliced_buf, "w") as zf:
+                zf.writestr("3D/3dmodel.model", "<model/>")
+                zf.writestr("Metadata/slice_info.config", "<config/>")
+                zf.writestr("Auxiliaries/.thumbnails/thumbnail_middle.png", b"SLICED_COVER_ART")
+            return httpx.Response(
+                status_code=200,
+                content=sliced_buf.getvalue(),
+                headers={"x-print-time-seconds": "60", "x-filament-used-g": "1", "x-filament-used-mm": "100"},
+            )
+
+        _install_mock_sidecar(handler)
+
+        resp = await async_client.post(
+            f"/api/v1/archives/{source.id}/slice",
+            json={
+                "printer_preset_id": slice_test_setup["printer_id"],
+                "process_preset_id": slice_test_setup["process_id"],
+                "filament_preset_id": slice_test_setup["filament_id"],
+            },
+        )
+        assert resp.status_code == 202, resp.text
+        final = await _wait_for_job(async_client, resp.json()["job_id"])
+        assert final["status"] == "completed", final
+
+        new = await db_session.get(PrintArchive, final["result"]["archive_id"])
+        assert new.thumbnail_path is not None
+        thumb_full = tmp_path / new.thumbnail_path
+        assert thumb_full.read_bytes() == source_plate_marker, (
+            "Re-sliced archive's thumbnail should be the source's per-plate render, not the Auxiliaries cover art."
+        )
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_falls_back_to_auxiliaries_when_source_lacks_plate_png(
+        self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
+    ):
+        """When the source has no per-plate render (unsliced library upload),
+        the Auxiliaries marketing image from the sliced output is the
+        next-best preview — better than no card thumbnail at all."""
+        from backend.app.models.archive import PrintArchive
+
+        tmp_path = slice_test_setup["tmp_path"]
+        monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
+
+        # Source has no Metadata/plate_1.png at all.
+        bare_buf = io.BytesIO()
+        with zipfile.ZipFile(bare_buf, "w") as zf:
+            zf.writestr("3D/3dmodel.model", "<model/>")
+        src_dir = tmp_path / "archives" / "src"
+        src_dir.mkdir(parents=True, exist_ok=True)
+        src_3mf = src_dir / "bare.3mf"
+        src_3mf.write_bytes(bare_buf.getvalue())
+        printer = await printer_factory()
+        source = await archive_factory(
+            printer.id,
+            filename="bare.3mf",
+            file_path=str(src_3mf.relative_to(tmp_path)),
+            sliced_for_model="X1C",
+            with_run=False,
+        )
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            sliced_buf = io.BytesIO()
+            with zipfile.ZipFile(sliced_buf, "w") as zf:
+                zf.writestr("3D/3dmodel.model", "<model/>")
+                zf.writestr("Metadata/slice_info.config", "<config/>")
+                zf.writestr("Auxiliaries/.thumbnails/thumbnail_middle.png", b"COVER_ART_FALLBACK")
+            return httpx.Response(
+                status_code=200,
+                content=sliced_buf.getvalue(),
+                headers={"x-print-time-seconds": "60", "x-filament-used-g": "1", "x-filament-used-mm": "100"},
+            )
+
+        _install_mock_sidecar(handler)
+
+        resp = await async_client.post(
+            f"/api/v1/archives/{source.id}/slice",
+            json={
+                "printer_preset_id": slice_test_setup["printer_id"],
+                "process_preset_id": slice_test_setup["process_id"],
+                "filament_preset_id": slice_test_setup["filament_id"],
+            },
+        )
+        assert resp.status_code == 202, resp.text
+        final = await _wait_for_job(async_client, resp.json()["job_id"])
+        assert final["status"] == "completed", final
+
+        new = await db_session.get(PrintArchive, final["result"]["archive_id"])
+        assert new.thumbnail_path is not None
+        thumb_full = tmp_path / new.thumbnail_path
+        assert thumb_full.read_bytes() == b"COVER_ART_FALLBACK"
+
+
+class TestSliceArchiveReslicedBedType:
+    """#1493 follow-up: the re-sliced archive's ``bed_type`` column must be
+    set from the produced 3MF's ``curr_bed_type`` so the frontend's archive
+    card shows the right build-plate badge (the card reads the column, not
+    extra_data, so the value was previously invisible after a re-slice)."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bed_type_lifted_from_sliced_output(
+        self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
+    ):
+        from backend.app.models.archive import PrintArchive
+
+        tmp_path = slice_test_setup["tmp_path"]
+        monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
+
+        src_dir = tmp_path / "archives" / "src"
+        src_dir.mkdir(parents=True, exist_ok=True)
+        src_3mf = src_dir / "cube.3mf"
+        src_3mf.write_bytes(_make_3mf_with_settings())
+        printer = await printer_factory()
+        source = await archive_factory(
+            printer.id,
+            filename="cube.3mf",
+            file_path=str(src_3mf.relative_to(tmp_path)),
+            sliced_for_model="X1C",
+            bed_type="Cool Plate",
+            with_run=False,
+        )
+
+        # Mock slicer: produced 3MF declares a different plate type than
+        # the source archive's ``Cool Plate``. The new column must reflect
+        # the slicer's value (the user picked a different plate in the
+        # SliceModal) instead of inheriting the source's.
+        def handler(request: httpx.Request) -> httpx.Response:
+            return httpx.Response(
+                status_code=200,
+                content=_make_sliced_3mf("O1D", bed_type="Textured PEI Plate"),
+                headers={
+                    "x-print-time-seconds": "600",
+                    "x-filament-used-g": "5.0",
+                    "x-filament-used-mm": "1600.0",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+
+        resp = await async_client.post(
+            f"/api/v1/archives/{source.id}/slice",
+            json={
+                "printer_preset_id": slice_test_setup["printer_id"],
+                "process_preset_id": slice_test_setup["process_id"],
+                "filament_preset_id": slice_test_setup["filament_id"],
+            },
+        )
+        assert resp.status_code == 202, resp.text
+
+        final = await _wait_for_job(async_client, resp.json()["job_id"])
+        assert final["status"] == "completed", final
+
+        new = await db_session.get(PrintArchive, final["result"]["archive_id"])
+        assert new.bed_type == "Textured PEI Plate"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bed_type_falls_back_to_source_when_missing_from_output(
+        self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
+    ):
+        """An older sidecar or sparse slice profile may produce a 3MF without
+        ``curr_bed_type``. The source archive's ``bed_type`` is the right
+        default in that case — better than leaving the badge blank."""
+        from backend.app.models.archive import PrintArchive
+
+        tmp_path = slice_test_setup["tmp_path"]
+        monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
+
+        src_dir = tmp_path / "archives" / "src"
+        src_dir.mkdir(parents=True, exist_ok=True)
+        src_3mf = src_dir / "cube.3mf"
+        src_3mf.write_bytes(_make_3mf_with_settings())
+        printer = await printer_factory()
+        source = await archive_factory(
+            printer.id,
+            filename="cube.3mf",
+            file_path=str(src_3mf.relative_to(tmp_path)),
+            sliced_for_model="X1C",
+            bed_type="Cool Plate",
+            with_run=False,
+        )
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            return httpx.Response(
+                status_code=200,
+                # No bed_type embedded — simulates a sidecar that drops it.
+                content=_make_sliced_3mf("O1D"),
+                headers={
+                    "x-print-time-seconds": "600",
+                    "x-filament-used-g": "5.0",
+                    "x-filament-used-mm": "1600.0",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+
+        resp = await async_client.post(
+            f"/api/v1/archives/{source.id}/slice",
+            json={
+                "printer_preset_id": slice_test_setup["printer_id"],
+                "process_preset_id": slice_test_setup["process_id"],
+                "filament_preset_id": slice_test_setup["filament_id"],
+            },
+        )
+        assert resp.status_code == 202, resp.text
+
+        final = await _wait_for_job(async_client, resp.json()["job_id"])
+        assert final["status"] == "completed", final
+
+        new = await db_session.get(PrintArchive, final["result"]["archive_id"])
+        assert new.bed_type == "Cool Plate"
+
+
+# ---------------------------------------------------------------------------
+# Slicer content rejections surface instead of silently falling back
+# ---------------------------------------------------------------------------
+
+
+class TestSlicerRejectionMessage:
+    """_slicer_rejection_message distinguishes a real slicer content rejection
+    (surface it to the user) from a CLI crash (fall back to embedded)."""
+
+    def test_extracts_bed_boundary_reason(self):
+        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)\nstdout: trace ..."
+        )
+        assert _slicer_rejection_message(text) == "Some objects are located over the boundary of the heated bed."
+
+    def test_extracts_filament_temp_reason(self):
+        text = (
+            "Slicer CLI failed (500): Slicing failed with error from slicer: "
+            "The temperature difference of the filaments used is too large.: "
+            "Slicer process failed (exit code 194)"
+        )
+        assert _slicer_rejection_message(text) == "The temperature difference of the filaments used is too large."
+
+    def test_generic_cli_failure_is_not_a_rejection(self):
+        # The #1201 CLI-crash signature carries no slicer error_string, so it
+        # must still fall through to the embedded-settings fallback.
+        assert _slicer_rejection_message("Slicer CLI failed (500): Failed to slice the model") is None
+
+    def test_empty_or_unrelated_text(self):
+        assert _slicer_rejection_message("") is None
+        assert _slicer_rejection_message("Slicer sidecar unreachable: connection reset") is None
+
+
+class TestSliceSlicerRejection:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_3mf_surfaces_slicer_rejection_instead_of_falling_back(
+        self, async_client: AsyncClient, db_session, slice_test_setup
+    ):
+        """A real slicer content rejection (e.g. re-slicing for a printer with
+        a smaller bed) must surface as a 400 — not silently fall back to the
+        source 3MF's embedded settings, which would re-slice for the original
+        printer and hide the problem."""
+        src_3mf_path = slice_test_setup["tmp_path"] / "library" / "files" / "toobig.3mf"
+        src_3mf_path.write_bytes(_make_3mf_with_settings())
+        threemf = LibraryFile(
+            filename="toobig.3mf",
+            file_path=str(src_3mf_path.relative_to(slice_test_setup["tmp_path"])),
+            file_type="3mf",
+            file_size=src_3mf_path.stat().st_size,
+        )
+        db_session.add(threemf)
+        await db_session.commit()
+        await db_session.refresh(threemf)
+
+        call_count = {"n": 0}
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            call_count["n"] += 1
+            return httpx.Response(
+                status_code=500,
+                json={
+                    "message": (
+                        "Slicing failed with error from slicer: Some objects are "
+                        "located over the boundary of the heated bed."
+                    ),
+                    "details": "Slicer process failed (exit code 204)",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+        response = await async_client.post(
+            f"/api/v1/library/files/{threemf.id}/slice",
+            json={
+                "printer_preset_id": slice_test_setup["printer_id"],
+                "process_preset_id": slice_test_setup["process_id"],
+                "filament_preset_id": slice_test_setup["filament_id"],
+            },
+        )
+        assert response.status_code == 202
+
+        final = await _wait_for_job(async_client, response.json()["job_id"])
+        assert final["status"] == "failed", final
+        assert final["error_status"] == 400
+        assert "boundary of the heated bed" in (final["error_detail"] or "")
+        # The slicer rejection must NOT trigger the embedded-settings retry.
+        assert call_count["n"] == 1
+
+
+# ---------------------------------------------------------------------------
+# Nozzle-class re-slice guard — single-nozzle <-> dual-nozzle (H2D) is blocked
+# ---------------------------------------------------------------------------
+
+from fastapi import HTTPException  # noqa: E402
+
+from backend.app.api.routes.library import (  # noqa: E402
+    _canonical_printer_model,
+    guard_nozzle_class_reslice,
+)
+
+
+class TestCanonicalPrinterModel:
+    """_canonical_printer_model strips the '# ' clone prefix and the
+    ' 0.4 nozzle' variant suffix so preset names resolve to a model code."""
+
+    def test_strips_nozzle_suffix(self):
+        assert _canonical_printer_model("Bambu Lab H2D 0.4 nozzle") == "H2D"
+
+    def test_strips_clone_prefix_and_suffix(self):
+        assert _canonical_printer_model("# Bambu Lab X1 Carbon 0.4 nozzle") == "X1C"
+
+    def test_bare_model_and_empty(self):
+        assert _canonical_printer_model("Bambu Lab H2D") == "H2D"
+        assert _canonical_printer_model(None) is None
+        assert _canonical_printer_model("") is None
+
+
+class TestNozzleClassGuard:
+    """guard_nozzle_class_reslice is now a no-op (#1493). Cross-class re-slicing
+    is handled by the two-pass conversion in _run_slicer_with_fallback for
+    both preset and bundle dispatch — so the guard never blocks. The function
+    is kept (and these tests with it) so external forks / pinned versions
+    that call it still link, and so a future regression that re-introduces a
+    raise inside the helper gets caught here."""
+
+    @staticmethod
+    def _bundle_request() -> object:
+        return type("_Req", (), {"bundle": object()})()
+
+    @staticmethod
+    def _preset_request() -> object:
+        return type("_Req", (), {"bundle": None})()
+
+    @pytest.mark.asyncio
+    async def test_single_to_dual_bundle_is_allowed(self, monkeypatch):
+        """Bundle-mode cross-class: handled by the two-pass converter via
+        slice_with_bundle on the cube, so the guard does NOT raise."""
+        import backend.app.api.routes.library as lib
+
+        async def _target(_db, _user, _request):
+            return "H2D"
+
+        monkeypatch.setattr(lib, "_resolve_target_printer_model", _target)
+        # No raise — the converter handles this case now.
+        await guard_nozzle_class_reslice(None, None, self._bundle_request(), "X1C")
+
+    @pytest.mark.asyncio
+    async def test_dual_to_single_bundle_is_allowed(self, monkeypatch):
+        import backend.app.api.routes.library as lib
+
+        async def _target(_db, _user, _request):
+            return "X1C"
+
+        monkeypatch.setattr(lib, "_resolve_target_printer_model", _target)
+        await guard_nozzle_class_reslice(None, None, self._bundle_request(), "H2D")
+
+    @pytest.mark.asyncio
+    async def test_preset_path_is_not_blocked(self, monkeypatch):
+        """Preset path cross-class is also handled by the two-pass converter."""
+        import backend.app.api.routes.library as lib
+
+        async def _target(_db, _user, _request):
+            return "H2D"
+
+        monkeypatch.setattr(lib, "_resolve_target_printer_model", _target)
+        await guard_nozzle_class_reslice(None, None, self._preset_request(), "X1C")
+
+    @pytest.mark.asyncio
+    async def test_same_nozzle_class_is_allowed(self, monkeypatch):
+        import backend.app.api.routes.library as lib
+
+        async def _target(_db, _user, _request):
+            return "P1S"
+
+        monkeypatch.setattr(lib, "_resolve_target_printer_model", _target)
+        await guard_nozzle_class_reslice(None, None, self._bundle_request(), "X1C")
+
+    @pytest.mark.asyncio
+    async def test_no_source_model_is_a_noop(self, monkeypatch):
+        import backend.app.api.routes.library as lib
+
+        async def _target(_db, _user, _request):
+            return "H2D"
+
+        monkeypatch.setattr(lib, "_resolve_target_printer_model", _target)
+        await guard_nozzle_class_reslice(None, None, self._bundle_request(), None)
+
+    @pytest.mark.asyncio
+    async def test_null_request_is_a_noop(self):
+        await guard_nozzle_class_reslice(None, None, None, "X1C")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_archive_reslice_x1c_to_h2d_preset_path_is_not_400(
+        self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
+    ):
+        """End to end: the preset-driven archive re-slice from X1C to H2D no
+        longer gets a synchronous 400 from the guard. It may still fail
+        downstream (no sidecar in test env), but it must not be rejected by
+        the nozzle-class guard's old "isn't supported yet" message."""
+        tmp_path = slice_test_setup["tmp_path"]
+        monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
+
+        src_dir = tmp_path / "archives" / "src"
+        src_dir.mkdir(parents=True, exist_ok=True)
+        src_3mf = src_dir / "cube.3mf"
+        src_3mf.write_bytes(_make_3mf_with_settings())
+        printer = await printer_factory()
+        source = await archive_factory(
+            printer.id,
+            filename="cube.3mf",
+            file_path=str(src_3mf.relative_to(tmp_path)),
+            sliced_for_model="X1C",
+            with_run=False,
+        )
+
+        h2d = LocalPreset(
+            name="# Bambu Lab H2D 0.4 nozzle",
+            preset_type="printer",
+            source="orcaslicer",
+            setting=json.dumps({"name": "Bambu Lab H2D 0.4 nozzle", "printer_model": "Bambu Lab H2D"}),
+        )
+        db_session.add(h2d)
+        await db_session.commit()
+        await db_session.refresh(h2d)
+
+        resp = await async_client.post(
+            f"/api/v1/archives/{source.id}/slice",
+            json={
+                "printer_preset": {"source": "local", "id": str(h2d.id)},
+                "process_preset": {"source": "local", "id": str(slice_test_setup["process_id"])},
+                "filament_presets": [{"source": "local", "id": str(slice_test_setup["filament_id"])}],
+            },
+        )
+        if resp.status_code == 400:
+            detail = resp.json().get("detail", "")
+            assert "isn't supported" not in detail, f"guard still firing on preset path: {detail!r}"

+ 87 - 0
backend/tests/integration/test_print_queue_api.py

@@ -496,6 +496,93 @@ class TestQueueStartEndpoint:
         response = await async_client.post(f"/api/v1/queue/{item.id}/start")
         assert response.status_code == 400
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_start_returns_409_on_filament_deficit(
+        self,
+        async_client: AsyncClient,
+        queue_item_factory,
+        db_session,
+        monkeypatch,
+    ):
+        """Filament deficit must surface as 409 + structured payload (#1496)."""
+        from backend.app.services import filament_deficit as fd_module
+
+        item = await queue_item_factory(manual_start=True)
+
+        async def _fake_deficit(_db, _item):
+            return [
+                fd_module.FilamentDeficit(
+                    slot_id=1,
+                    ams_id=0,
+                    tray_id=0,
+                    filament_type="PLA",
+                    required_grams=270.0,
+                    remaining_grams=200.0,
+                ),
+            ]
+
+        monkeypatch.setattr(
+            "backend.app.api.routes.print_queue.compute_deficit_for_queue_item",
+            _fake_deficit,
+        )
+
+        response = await async_client.post(f"/api/v1/queue/{item.id}/start")
+        assert response.status_code == 409
+        body = response.json()
+        assert body["detail"]["code"] == "insufficient_filament"
+        assert len(body["detail"]["deficit"]) == 1
+        assert body["detail"]["deficit"][0]["slot_id"] == 1
+        assert body["detail"]["deficit"][0]["required_grams"] == 270.0
+        assert body["detail"]["deficit"][0]["remaining_grams"] == 200.0
+
+        # Item still pending, manual_start unchanged.
+        await db_session.refresh(item)
+        assert item.status == "pending"
+        assert item.manual_start is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_start_with_skip_flag_bypasses_deficit_check(
+        self,
+        async_client: AsyncClient,
+        queue_item_factory,
+        db_session,
+        monkeypatch,
+    ):
+        """With skip_filament_check=true the route dispatches even when short (#1496)."""
+        from backend.app.services import filament_deficit as fd_module
+
+        item = await queue_item_factory(manual_start=True, filament_short=True)
+        called_with = {}
+
+        async def _fake_deficit(_db, _item):
+            called_with["called"] = True
+            return [
+                fd_module.FilamentDeficit(
+                    slot_id=1,
+                    ams_id=0,
+                    tray_id=0,
+                    filament_type="PLA",
+                    required_grams=270.0,
+                    remaining_grams=200.0,
+                ),
+            ]
+
+        monkeypatch.setattr(
+            "backend.app.api.routes.print_queue.compute_deficit_for_queue_item",
+            _fake_deficit,
+        )
+
+        response = await async_client.post(f"/api/v1/queue/{item.id}/start?skip_filament_check=true")
+        assert response.status_code == 200
+        body = response.json()
+        assert body["manual_start"] is False
+        assert body["filament_short"] is False
+        # Helper not called on the bypass path — we trust the operator's
+        # decision to print anyway.
+        assert called_with == {}
+
 
 class TestQueueCancelEndpoint:
     """Tests for the /queue/{item_id}/cancel endpoint."""

+ 75 - 0
backend/tests/integration/test_security_headers.py

@@ -193,3 +193,78 @@ async def test_other_security_headers_unchanged(async_client: AsyncClient, monke
         resp = await async_client.get("/api/v1/auth/status")
         assert resp.headers.get("X-Content-Type-Options") == "nosniff"
         assert resp.headers.get("Referrer-Policy") == "strict-origin-when-cross-origin"
+
+
+# ─── #1460: nonce-based script-src so Cloudflare-injected scripts pass ────
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_spa_csp_includes_per_request_script_nonce(async_client: AsyncClient):
+    """SPA CSP must stamp a fresh `'nonce-…'` token into script-src (#1460).
+
+    Cloudflare's bot-detection inline script is injected after our response
+    leaves the app, with a per-load hash that defeats hash allowlisting. When
+    a nonce is present in the CSP header, Cloudflare clones it onto its
+    injected `<script>` and the CSP passes without `'unsafe-inline'`.
+    """
+    import re
+
+    resp = await async_client.get("/api/v1/auth/status")
+    csp = resp.headers.get("Content-Security-Policy", "")
+    # Pull out the script-src directive (split on ';' so neighbours don't confuse us).
+    script_src = next(
+        (d.strip() for d in csp.split(";") if d.strip().startswith("script-src")),
+        "",
+    )
+    assert script_src, f"script-src directive missing: {csp!r}"
+    assert "'self'" in script_src, f"script-src must still allow 'self': {script_src!r}"
+    # Nonce token is `'nonce-<base64url>'` where the inner value is
+    # secrets.token_urlsafe(16) — about 22 url-safe chars.
+    assert re.search(r"'nonce-[A-Za-z0-9_-]{16,}'", script_src), (
+        f"script-src must include a 'nonce-…' token: {script_src!r}"
+    )
+    # We deliberately did NOT add 'unsafe-inline' alongside the nonce — that
+    # would defeat the purpose of using a nonce in the first place.
+    assert "'unsafe-inline'" not in script_src, (
+        f"script-src must not relax to 'unsafe-inline' on the SPA route: {script_src!r}"
+    )
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_spa_csp_nonce_changes_per_request(async_client: AsyncClient):
+    """A nonce is only useful if it's fresh per request (#1460)."""
+    import re
+
+    nonce_re = re.compile(r"'nonce-([A-Za-z0-9_-]+)'")
+
+    nonces = set()
+    for _ in range(5):
+        resp = await async_client.get("/api/v1/auth/status")
+        csp = resp.headers.get("Content-Security-Policy", "")
+        m = nonce_re.search(csp)
+        assert m, f"no nonce in CSP: {csp!r}"
+        nonces.add(m.group(1))
+    # 5 random 16-byte tokens collide with probability ~0 — anything less
+    # than all-5-distinct means we're handing out a stale/global nonce.
+    assert len(nonces) == 5, f"nonces should be per-request, got {nonces!r}"
+
+
+# ─── #1460: HEAD on PWA bootstrap routes (manifest / sw / sw-register) ───
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+@pytest.mark.parametrize("path", ["/manifest.json", "/sw.js", "/sw-register.js"])
+async def test_pwa_bootstrap_routes_accept_head(async_client: AsyncClient, path: str):
+    """Scanners and `curl -I` HEAD-probe these — must not 405 (#1460).
+
+    Previously these were `@app.get` only, so HEAD returned 405 Method Not
+    Allowed and looked like a manifest/SW server-side bug when debugging
+    Cloudflare-fronted deployments.
+    """
+    resp = await async_client.head(path)
+    # 200 if static asset is present in the test environment, 404 if it's
+    # not packaged in this checkout — but never 405.
+    assert resp.status_code != 405, f"HEAD {path} returned 405 — route must accept HEAD as well as GET"

+ 27 - 15
backend/tests/integration/test_spoolman_api.py

@@ -205,13 +205,18 @@ class TestSpoolmanAPI:
     async def test_get_unlinked_spools_success(
         self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
     ):
-        """Verify get unlinked spools returns spools without tags."""
-        # Mock spool without extra.tag (unlinked)
+        """A spool with no slot assignment is assignable even when extra.tag is set.
+
+        #1122 — extra.tag is only an RFID/NFC matching key (OpenSpoolman writes
+        its own NFC tag value there too); it must NOT gate assignability. A spool
+        with a non-empty extra.tag but no spoolman_slot_assignments row still
+        appears in the picker.
+        """
         mock_spool = {
             "id": 1,
             "remaining_weight": 800,
             "used_weight": 200,
-            "extra": {},  # No tag = unlinked
+            "extra": {"tag": '"04A1B2C3D4E5F6"'},  # OpenSpoolman-style NFC tag value
             "filament": {
                 "id": 1,
                 "name": "PLA Basic",
@@ -232,35 +237,40 @@ class TestSpoolmanAPI:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_get_unlinked_spools_excludes_linked(
-        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    async def test_get_unlinked_spools_excludes_slot_assigned(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client, printer_factory, db_session
     ):
-        """Verify linked spools (with tag) are excluded."""
-        # Mock spool with extra.tag (linked)
-        mock_spool_linked = {
+        """Verify spools that currently occupy an AMS slot are excluded."""
+        from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+
+        printer = await printer_factory()
+
+        # Spool 1 occupies a slot; spool 2 has an extra.tag but no slot row.
+        db_session.add(SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=1, spoolman_spool_id=1))
+        await db_session.commit()
+
+        mock_spool_assigned = {
             "id": 1,
             "remaining_weight": 800,
             "used_weight": 200,
-            "extra": {"tag": '"ABC123"'},  # Has tag = linked
+            "extra": {"tag": '"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"'},
             "filament": {"id": 1, "name": "PLA Red", "material": "PLA", "color_hex": "FF0000"},
         }
-
-        # Mock spool without tag (unlinked)
-        mock_spool_unlinked = {
+        mock_spool_unassigned = {
             "id": 2,
             "remaining_weight": 900,
             "used_weight": 100,
-            "extra": {},  # No tag = unlinked
+            "extra": {"tag": '"04DEADBEEF1122"'},  # tagged but not slot-assigned
             "filament": {"id": 2, "name": "PLA Blue", "material": "PLA", "color_hex": "0000FF"},
         }
 
-        mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool_linked, mock_spool_unlinked])
+        mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool_assigned, mock_spool_unassigned])
 
         response = await async_client.get("/api/v1/spoolman/spools/unlinked")
         assert response.status_code == 200
         data = response.json()
         assert len(data) == 1
-        assert data[0]["id"] == 2  # Only unlinked spool
+        assert data[0]["id"] == 2  # Only the spool not occupying a slot
 
     # =========================================================================
     # Linked Spools Tests
@@ -1159,6 +1169,8 @@ class TestLinkSpoolMqttConfigure:
         mock_client.merge_spool_extra = AsyncMock(
             return_value={"id": 5, "extra": {"tag": '"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"'}}
         )
+        # #1457 stale-tag cleanup enumerates spools; default to empty so it's a no-op.
+        mock_client.get_spools = AsyncMock(return_value=[])
         mock_client.get_spool = AsyncMock(
             return_value={
                 "id": 5,

+ 3 - 0
backend/tests/integration/test_spoolman_slot_assignment_mqtt.py

@@ -65,6 +65,9 @@ def mock_spoolman_client():
     client.base_url = "http://localhost:7912"
     client.health_check = AsyncMock(return_value=True)
     client.get_spool = AsyncMock(return_value=SAMPLE_SPOOL)
+    # #1457: assign route enumerates spools to clear stale fallback-tag links.
+    client.get_spools = AsyncMock(return_value=[])
+    client.merge_spool_extra = AsyncMock(return_value={"id": 0, "extra": {}})
 
     with patch(
         "backend.app.api.routes.spoolman_inventory._get_client",

+ 34 - 0
backend/tests/integration/test_spoolman_slot_assignments.py

@@ -70,6 +70,10 @@ def mock_client():
     client.base_url = "http://localhost:7912"
     client.health_check = AsyncMock(return_value=True)
     client.get_spool = AsyncMock(return_value=SAMPLE_SPOOL)
+    # #1457: assign route enumerates spools to clear stale fallback-tag links.
+    # Default to empty so the cleanup is a no-op for tests that don't exercise it.
+    client.get_spools = AsyncMock(return_value=[])
+    client.merge_spool_extra = AsyncMock(return_value={"id": 0, "extra": {}})
 
     with patch(
         "backend.app.api.routes.spoolman_inventory._get_client",
@@ -573,3 +577,33 @@ class TestCascadeDeletePrinter:
             select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == test_printer.id)
         )
         assert post.scalars().all() == []
+
+
+class TestModeSwitchClearsAssignments:
+    """#1473 follow-up — the Spoolman mode toggle clears the other mode's
+    slot-assignment table so stale rows can't bleed across a mode switch."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_switch_to_internal_mode_clears_spoolman_slot_assignments(
+        self, async_client: AsyncClient, db_session, test_printer
+    ):
+        """Switching Spoolman OFF deletes spoolman_slot_assignments rows — the
+        symmetric counterpart of clearing legacy spool_assignment rows when
+        switching ON. Stale rows would otherwise wrongly count as 'assigned'
+        in mode-agnostic checks (e.g. the missing-spool-assignment notification,
+        which unions both tables)."""
+        from backend.app.models.settings import Settings
+        from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+
+        db_session.add(Settings(key="spoolman_enabled", value="true"))
+        db_session.add(SpoolmanSlotAssignment(printer_id=test_printer.id, ams_id=0, tray_id=0, spoolman_spool_id=1))
+        await db_session.commit()
+
+        resp = await async_client.put("/api/v1/settings/spoolman", json={"spoolman_enabled": "false"})
+        assert resp.status_code == 200
+
+        rows = await db_session.execute(
+            select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == test_printer.id)
+        )
+        assert rows.scalars().all() == []

+ 3 - 0
backend/tests/integration/test_spoolman_slot_concurrency.py

@@ -61,6 +61,9 @@ def mock_client():
     client.base_url = "http://localhost:7912"
     client.health_check = AsyncMock(return_value=True)
     client.get_spool = AsyncMock(return_value=SAMPLE_SPOOL)
+    # #1457: assign route enumerates spools to clear stale fallback-tag links.
+    client.get_spools = AsyncMock(return_value=[])
+    client.merge_spool_extra = AsyncMock(return_value={"id": 0, "extra": {}})
 
     with patch(
         "backend.app.api.routes.spoolman_inventory._get_client",

+ 182 - 0
backend/tests/integration/test_spoolman_tracking_slot_fallback.py

@@ -0,0 +1,182 @@
+"""Integration tests for #1459 — per-print weight tracker falls back to the
+local spoolman_slot_assignments table when Spoolman's extra.tag is empty.
+
+Without this, tag-less spools assigned via the Bambuddy UI never get their
+weight decremented because the Assign route intentionally leaves extra.tag
+unset (per #1457 — fallback tags must not pollute Spoolman).
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+from backend.app.services.spoolman_tracking import _report_spool_usage_for_slots
+
+
+@pytest.fixture
+def mock_spoolman_client():
+    client = MagicMock()
+    # Default: every tag-lookup returns None (the bug case — no extra.tag on Spoolman side).
+    client.find_spool_by_tag = AsyncMock(return_value=None)
+    client.use_spool = AsyncMock(return_value={"id": 0})
+    return client
+
+
+@pytest.fixture
+def patch_async_session(test_engine):
+    """Route the tracker's async_session() to the test engine so the slot-assignment
+    fallback lookup sees rows committed via db_session in the same test."""
+    test_async_session = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+    with patch("backend.app.services.spoolman_tracking.async_session", test_async_session):
+        yield
+
+
+@pytest.fixture
+async def test_printer(db_session):
+    from backend.app.models.printer import Printer
+
+    printer = Printer(
+        name="Tracking Test",
+        serial_number="TRACKTEST123456",
+        ip_address="192.168.0.99",
+        access_code="12345678",
+        model="P1S",
+        is_active=True,
+        auto_archive=True,
+    )
+    db_session.add(printer)
+    await db_session.commit()
+    await db_session.refresh(printer)
+    return printer
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+@pytest.mark.usefixtures("patch_async_session")
+class TestSlotAssignmentFallback:
+    async def test_falls_back_to_slot_assignment_when_tag_missing(self, test_printer, mock_spoolman_client, db_session):
+        """Tag-less spool assigned via Bambuddy UI: extra.tag is empty (find_spool_by_tag
+        returns None) but the local spoolman_slot_assignments row says spool 42 lives in
+        AMS 0 tray 2 — the tracker must still report usage to spool 42.
+
+        slot_id is 1-based; ams_trays is keyed by global_tray_id. For AMS 0 tray 2,
+        global_tray_id = 2, so we hand the tracker slot_id=3 (since slot_id-1=global=2).
+        """
+        db_session.add(SpoolmanSlotAssignment(printer_id=test_printer.id, ams_id=0, tray_id=2, spoolman_spool_id=42))
+        await db_session.commit()
+
+        ams_trays = {2: {"tray_uuid": "", "tag_uid": "", "tray_type": "PLA"}}
+        usage_items = [(3, 15.5)]
+
+        spools_updated = await _report_spool_usage_for_slots(
+            mock_spoolman_client,
+            usage_items,
+            ams_trays,
+            slot_to_tray=None,
+            method_label="Test",
+            printer_serial=test_printer.serial_number,
+            printer_id=test_printer.id,
+        )
+
+        assert spools_updated == 1
+        mock_spoolman_client.use_spool.assert_awaited_once_with(42, 15.5)
+
+    async def test_tag_match_wins_over_slot_assignment(self, test_printer, mock_spoolman_client, db_session):
+        """When both paths could resolve a spool, the tag-match wins — RFID is the
+        authoritative binding when present. Order matters so RFID auto-sync continues
+        to bind to the spool whose extra.tag literally holds that RFID, even if the
+        slot-assignment table happens to point at a different spool."""
+        db_session.add(SpoolmanSlotAssignment(printer_id=test_printer.id, ams_id=0, tray_id=0, spoolman_spool_id=999))
+        await db_session.commit()
+
+        mock_spoolman_client.find_spool_by_tag = AsyncMock(return_value={"id": 7})
+
+        ams_trays = {0: {"tray_uuid": "A" * 32, "tag_uid": "", "tray_type": "PLA"}}
+        # slot_id=1 → global_tray_id=0 (AMS 0 tray 0).
+        usage_items = [(1, 10.0)]
+
+        spools_updated = await _report_spool_usage_for_slots(
+            mock_spoolman_client,
+            usage_items,
+            ams_trays,
+            slot_to_tray=None,
+            method_label="Test",
+            printer_serial=test_printer.serial_number,
+            printer_id=test_printer.id,
+        )
+
+        assert spools_updated == 1
+        mock_spoolman_client.use_spool.assert_awaited_once_with(7, 10.0)
+
+    async def test_skips_when_neither_path_resolves(self, test_printer, mock_spoolman_client, db_session):
+        """No tag in Spoolman AND no slot-assignment row → tracker skips the slot
+        rather than crashing or reporting against the wrong spool."""
+        ams_trays = {0: {"tray_uuid": "", "tag_uid": "", "tray_type": "PLA"}}
+        # slot_id=1 → global_tray_id=0 (AMS 0 tray 0); no assignment row exists.
+        usage_items = [(1, 5.0)]
+
+        spools_updated = await _report_spool_usage_for_slots(
+            mock_spoolman_client,
+            usage_items,
+            ams_trays,
+            slot_to_tray=None,
+            method_label="Test",
+            printer_serial=test_printer.serial_number,
+            printer_id=test_printer.id,
+        )
+
+        assert spools_updated == 0
+        mock_spoolman_client.use_spool.assert_not_called()
+
+    async def test_skips_when_printer_id_not_supplied(self, test_printer, mock_spoolman_client, db_session):
+        """Slot-assignment fallback requires printer_id to look up the binding —
+        when callers don't supply it (legacy call shape) the lookup is skipped
+        and the slot is reported as unresolved, matching pre-#1459 behaviour for
+        those callers."""
+        db_session.add(SpoolmanSlotAssignment(printer_id=test_printer.id, ams_id=0, tray_id=0, spoolman_spool_id=42))
+        await db_session.commit()
+
+        ams_trays = {0: {"tray_uuid": "", "tag_uid": "", "tray_type": "PLA"}}
+        usage_items = [(1, 5.0)]
+
+        spools_updated = await _report_spool_usage_for_slots(
+            mock_spoolman_client,
+            usage_items,
+            ams_trays,
+            slot_to_tray=None,
+            method_label="Test",
+            printer_serial=test_printer.serial_number,
+            # printer_id omitted on purpose
+        )
+
+        assert spools_updated == 0
+        mock_spoolman_client.use_spool.assert_not_called()
+
+    async def test_external_slot_falls_back_via_correct_ams_tray_pair(
+        self, test_printer, mock_spoolman_client, db_session
+    ):
+        """External spool slots use global_tray_id 254/255 which map to ams_id=255,
+        tray_id=0/1. The slot-assignment lookup must use that translated pair, not the
+        raw global id, otherwise the row is never found."""
+        db_session.add(SpoolmanSlotAssignment(printer_id=test_printer.id, ams_id=255, tray_id=0, spoolman_spool_id=88))
+        await db_session.commit()
+
+        # Position-based default with ams_trays={254: ...}: sorted_tray_ids=[254],
+        # slot_id=1 → sorted_tray_ids[0] = 254 (global) → ams_id=255 tray_id=0.
+        ams_trays = {254: {"tray_uuid": "", "tag_uid": "", "tray_type": "PLA"}}
+        usage_items = [(1, 25.0)]
+
+        spools_updated = await _report_spool_usage_for_slots(
+            mock_spoolman_client,
+            usage_items,
+            ams_trays,
+            slot_to_tray=None,
+            method_label="Test",
+            printer_serial=test_printer.serial_number,
+            printer_id=test_printer.id,
+        )
+
+        assert spools_updated == 1
+        mock_spoolman_client.use_spool.assert_awaited_once_with(88, 25.0)

+ 10 - 10
backend/tests/integration/test_support_api.py

@@ -22,7 +22,7 @@ class TestSupportLogsAPI:
     @pytest.mark.integration
     async def test_get_logs_empty_file(self, async_client: AsyncClient):
         """Verify get logs returns empty list when log file doesn't exist."""
-        with patch("backend.app.api.routes.support.settings") as mock_settings:
+        with patch("backend.app.services.log_reader.settings") as mock_settings:
             mock_settings.log_dir = Path("/nonexistent/path")
 
             response = await async_client.get("/api/v1/support/logs")
@@ -46,7 +46,7 @@ class TestSupportLogsAPI:
             log_file = Path(tmpdir) / "bambuddy.log"
             log_file.write_text(log_content)
 
-            with patch("backend.app.api.routes.support.settings") as mock_settings:
+            with patch("backend.app.services.log_reader.settings") as mock_settings:
                 mock_settings.log_dir = Path(tmpdir)
 
                 response = await async_client.get("/api/v1/support/logs")
@@ -76,7 +76,7 @@ class TestSupportLogsAPI:
             log_file = Path(tmpdir) / "bambuddy.log"
             log_file.write_text(log_content)
 
-            with patch("backend.app.api.routes.support.settings") as mock_settings:
+            with patch("backend.app.services.log_reader.settings") as mock_settings:
                 mock_settings.log_dir = Path(tmpdir)
 
                 response = await async_client.get("/api/v1/support/logs?level=ERROR")
@@ -100,7 +100,7 @@ class TestSupportLogsAPI:
             log_file = Path(tmpdir) / "bambuddy.log"
             log_file.write_text(log_content)
 
-            with patch("backend.app.api.routes.support.settings") as mock_settings:
+            with patch("backend.app.services.log_reader.settings") as mock_settings:
                 mock_settings.log_dir = Path(tmpdir)
 
                 response = await async_client.get("/api/v1/support/logs?search=printer")
@@ -124,7 +124,7 @@ class TestSupportLogsAPI:
             log_file = Path(tmpdir) / "bambuddy.log"
             log_file.write_text(log_content)
 
-            with patch("backend.app.api.routes.support.settings") as mock_settings:
+            with patch("backend.app.services.log_reader.settings") as mock_settings:
                 mock_settings.log_dir = Path(tmpdir)
 
                 response = await async_client.get("/api/v1/support/logs?limit=2")
@@ -153,7 +153,7 @@ ValueError: test error
             log_file = Path(tmpdir) / "bambuddy.log"
             log_file.write_text(log_content)
 
-            with patch("backend.app.api.routes.support.settings") as mock_settings:
+            with patch("backend.app.services.log_reader.settings") as mock_settings:
                 mock_settings.log_dir = Path(tmpdir)
 
                 response = await async_client.get("/api/v1/support/logs")
@@ -214,7 +214,7 @@ class TestLogParsingHelpers:
 
     def test_parse_log_line_valid(self):
         """Verify _parse_log_line handles valid log lines."""
-        from backend.app.api.routes.support import _parse_log_line
+        from backend.app.services.log_reader import parse_log_line as _parse_log_line
 
         line = "2024-01-15 10:30:45,123 INFO [backend.app.main] Server started"
         entry = _parse_log_line(line)
@@ -227,7 +227,7 @@ class TestLogParsingHelpers:
 
     def test_parse_log_line_invalid(self):
         """Verify _parse_log_line returns None for invalid lines."""
-        from backend.app.api.routes.support import _parse_log_line
+        from backend.app.services.log_reader import parse_log_line as _parse_log_line
 
         line = "This is not a valid log line"
         entry = _parse_log_line(line)
@@ -236,7 +236,7 @@ class TestLogParsingHelpers:
 
     def test_parse_log_line_with_brackets_in_message(self):
         """Verify _parse_log_line handles messages with brackets."""
-        from backend.app.api.routes.support import _parse_log_line
+        from backend.app.services.log_reader import parse_log_line as _parse_log_line
 
         line = "2024-01-15 10:30:45,123 INFO [backend.app.main] Processing [item 1] and [item 2]"
         entry = _parse_log_line(line)
@@ -246,7 +246,7 @@ class TestLogParsingHelpers:
 
     def test_parse_log_line_all_levels(self):
         """Verify _parse_log_line handles all log levels."""
-        from backend.app.api.routes.support import _parse_log_line
+        from backend.app.services.log_reader import parse_log_line as _parse_log_line
 
         levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
         for level in levels:

+ 45 - 0
backend/tests/integration/test_system_api.py

@@ -308,3 +308,48 @@ class TestSystemHelperFunctions:
 
         result = format_uptime(30)  # 30 seconds
         assert result == "< 1m"
+
+
+class TestSystemHealthAPI:
+    """Integration tests for GET /api/v1/system/health (log-health scan)."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_health_clean_log(self, async_client: AsyncClient, tmp_path, monkeypatch):
+        """A log with no known issues returns an empty, healthy result."""
+        from backend.app.core.config import settings
+
+        (tmp_path / "bambuddy.log").write_text(
+            "2026-05-22 10:00:00,000 INFO [backend.app.main] Application startup complete\n",
+            encoding="utf-8",
+        )
+        monkeypatch.setattr(settings, "log_dir", tmp_path)
+
+        response = await async_client.get("/api/v1/system/health")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["log_available"] is True
+        assert result["findings"] == []
+        assert result["summary"]["total"] == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_health_detects_known_issue(self, async_client: AsyncClient, tmp_path, monkeypatch):
+        """A known signature in the log surfaces as a finding."""
+        from backend.app.core.config import settings
+
+        (tmp_path / "bambuddy.log").write_text(
+            "2026-05-22 10:00:00,000 WARNING [backend.app.services.bambu_ftp] "
+            "FTP connection permission error to 10.0.0.9: 530\n",
+            encoding="utf-8",
+        )
+        monkeypatch.setattr(settings, "log_dir", tmp_path)
+
+        response = await async_client.get("/api/v1/system/health")
+
+        assert response.status_code == 200
+        result = response.json()
+        ids = [f["signature_id"] for f in result["findings"]]
+        assert "ftp-auth-rejected" in ids
+        assert result["summary"]["layer8"] >= 1

+ 55 - 0
backend/tests/integration/test_virtual_printer_api.py

@@ -384,3 +384,58 @@ class TestVirtualPrinterTailscaleToggleAPI:
         assert enable_resp.json()["tailscale_disabled"] is False
         assert disable_resp.status_code == 200
         assert disable_resp.json()["tailscale_disabled"] is True
+
+
+class TestVirtualPrinterCaCertificateAPI:
+    """Integration tests for GET /api/v1/virtual-printers/ca-certificate."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_ca_certificate_returns_pem(self, async_client: AsyncClient):
+        """The shared CA certificate is returned as PEM with identifying metadata."""
+        response = await async_client.get("/api/v1/virtual-printers/ca-certificate")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["pem"].startswith("-----BEGIN CERTIFICATE-----")
+        assert "PRIVATE KEY" not in result["pem"]  # never expose the CA key
+        assert len(result["fingerprint_sha256"].split(":")) == 32
+        assert result["not_valid_after"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ca_certificate_route_precedes_vp_id_route(self, async_client: AsyncClient):
+        """'ca-certificate' must not be swallowed by the /{vp_id} int route."""
+        response = await async_client.get("/api/v1/virtual-printers/ca-certificate")
+        # A 200 (not 422 from int-parsing "ca-certificate") proves route ordering.
+        assert response.status_code == 200
+
+
+class TestVirtualPrinterDiagnosticAPI:
+    """Integration tests for GET /api/v1/virtual-printers/{vp_id}/diagnostic."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_diagnose_unknown_vp_returns_404(self, async_client: AsyncClient):
+        response = await async_client.get("/api/v1/virtual-printers/999999/diagnostic")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_diagnose_disabled_vp_reports_problems(self, async_client: AsyncClient):
+        """A freshly created (disabled) VP fails the 'enabled' check."""
+        create_resp = await async_client.post(
+            "/api/v1/virtual-printers",
+            json={"name": "TestDiagVP", "mode": "immediate", "access_code": "12345678"},
+        )
+        assert create_resp.status_code == 200
+        vp_id = create_resp.json()["id"]
+
+        response = await async_client.get(f"/api/v1/virtual-printers/{vp_id}/diagnostic")
+        assert response.status_code == 200
+        result = response.json()
+        assert result["vp_id"] == vp_id
+        assert result["overall"] == "problems"
+        by_id = {c["id"]: c["status"] for c in result["checks"]}
+        assert by_id["enabled"] == "fail"
+        assert by_id["running"] == "skip"

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

@@ -212,6 +212,48 @@ class TestArchiveThumbnails:
         for path in expected_thumbnail_paths:
             assert "png" in path.lower()
 
+    def test_extract_thumbnail_falls_back_to_auxiliaries(self, tmp_path):
+        """#1493 follow-up: when BambuStudio's CLI runs with --arrange it
+        rearranges objects but doesn't always emit a fresh
+        ``Metadata/plate_N.png`` for the rearranged plate. The project-wide
+        thumbnail under ``Auxiliaries/.thumbnails/`` survives though, and
+        we use it as a cover-image fallback so re-sliced archive cards
+        still render with a thumbnail."""
+        import zipfile
+
+        from backend.app.services.archive import ThreeMFParser
+
+        threemf_path = tmp_path / "sliced.3mf"
+        with zipfile.ZipFile(threemf_path, "w") as zf:
+            zf.writestr("3D/3dmodel.model", "<model/>")
+            # No Metadata/plate_1.png / thumbnail.png — only the
+            # Auxiliaries project-wide thumbnail (what arranged slices
+            # carry in practice).
+            zf.writestr("Auxiliaries/.thumbnails/thumbnail_middle.png", b"PNGMIDDLE")
+
+        parser = ThreeMFParser(str(threemf_path), plate_number=1)
+        parsed = parser.parse()
+        assert parsed.get("_thumbnail_data") == b"PNGMIDDLE"
+        assert parsed.get("_thumbnail_ext") == ".png"
+
+    def test_per_plate_png_wins_over_auxiliaries_fallback(self, tmp_path):
+        """Order matters: when BOTH the per-plate preview and the
+        Auxiliaries fallback are present, the per-plate one wins because
+        it reflects the actual sliced layout."""
+        import zipfile
+
+        from backend.app.services.archive import ThreeMFParser
+
+        threemf_path = tmp_path / "sliced.3mf"
+        with zipfile.ZipFile(threemf_path, "w") as zf:
+            zf.writestr("3D/3dmodel.model", "<model/>")
+            zf.writestr("Metadata/plate_1.png", b"PLATE1")
+            zf.writestr("Auxiliaries/.thumbnails/thumbnail_middle.png", b"PROJECT_WIDE")
+
+        parser = ThreeMFParser(str(threemf_path), plate_number=1)
+        parsed = parser.parse()
+        assert parsed.get("_thumbnail_data") == b"PLATE1"
+
 
 class TestPrintableObjectsExtraction:
     """Tests for extracting printable objects count from 3MF files."""
@@ -643,3 +685,47 @@ class TestReprintCostCalculation:
         # After 3 prints (1 original + 2 reprints)
         total_after_3_prints = round(single_print_cost * 3, 2)
         assert total_after_3_prints == 6.0
+
+
+class TestGcodeHeaderFilamentUsage:
+    """ThreeMFParser pulls total filament usage from the produced 3MF's G-code
+    header. Some slicer-sidecar builds leave the X-Filament-Used-* response
+    headers unset, so the slice would otherwise report "0 g" for a real
+    multi-hour print."""
+
+    @staticmethod
+    def _make_3mf(gcode_header: str) -> str:
+        import tempfile
+        import zipfile
+
+        fd, path = tempfile.mkstemp(suffix=".3mf")
+        import os
+
+        os.close(fd)
+        with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("3D/3dmodel.model", "<model/>")
+            zf.writestr("Metadata/plate_1.gcode", gcode_header + "\nG1 X0 Y0\n")
+        return path
+
+    def test_extracts_filament_weight_and_length_from_header(self):
+        from backend.app.services.archive import ThreeMFParser
+
+        header = (
+            "; HEADER_BLOCK_START\n"
+            "; BambuStudio 02.06.00.51\n"
+            "; total layer number: 503\n"
+            "; total filament length [mm] : 41661.40\n"
+            "; total filament volume [cm^3] : 100207.42\n"
+            "; total filament weight [g] : 126.26\n"
+        )
+        meta = ThreeMFParser(self._make_3mf(header)).parse()
+        assert meta.get("filament_used_grams") == 126.26
+        assert meta.get("filament_used_mm") == 41661.40
+        assert meta.get("total_layers") == 503
+
+    def test_no_filament_keys_when_header_lacks_them(self):
+        from backend.app.services.archive import ThreeMFParser
+
+        meta = ThreeMFParser(self._make_3mf("; total layer number: 10\n")).parse()
+        assert "filament_used_grams" not in meta
+        assert "filament_used_mm" not in meta

+ 304 - 28
backend/tests/unit/services/test_bambu_mqtt.py

@@ -3730,13 +3730,13 @@ class TestStartPrintAmsMapping:
             {"ams_id": 255, "slot_id": 0},
         ]
 
-    def test_x2d_uses_integer_format_for_calibration_fields(self, mqtt_client):
-        """X2D must use H2D-style integer (0/1) format for calibration fields (#988).
+    def test_x2d_uses_boolean_format_for_calibration_fields(self, mqtt_client):
+        """X2D sends calibration fields as JSON booleans, like every model (#1478).
 
-        The reporter's support bundle showed X2D running firmware in the same
-        family as H2D. Booleans in these fields are interpreted as nozzle
-        indexes by H2D firmware; X2D is treated identically until proven
-        otherwise.
+        An earlier revision integer-encoded these for the H2 family on the
+        belief that H2 firmware required 0/1. A BambuStudio request-topic
+        capture from a real H2D disproved it — BambuStudio sends plain
+        booleans — so X2D follows the same boolean format.
         """
         mqtt_client.model = "X2D"
         mqtt_client.start_print(
@@ -3749,24 +3749,24 @@ class TestStartPrintAmsMapping:
         )
 
         cmd = self._get_published_command(mqtt_client)
-        assert cmd["timelapse"] == 1
-        assert cmd["bed_leveling"] == 0
-        assert cmd["flow_cali"] == 1
-        assert cmd["vibration_cali"] == 0
-        assert cmd["layer_inspect"] == 1
-
-    def test_p2s_still_uses_boolean_format(self, mqtt_client):
-        """Regression guard: P2S is NOT in the H-family firmware gate — must still use booleans.
-
-        Adding X2D to the H-family set must not accidentally affect P2S, which
-        is single-nozzle and uses boolean format like X1C/A1/P1.
-        """
+        assert cmd["timelapse"] is True
+        assert cmd["bed_leveling"] is False
+        assert cmd["flow_cali"] is True
+        assert cmd["vibration_cali"] is False
+        assert cmd["layer_inspect"] is True
+        # flow_cali on → extrude_cali_flag must request the calibration pass.
+        assert cmd["extrude_cali_flag"] == 1
+
+    def test_p2s_uses_boolean_format(self, mqtt_client):
+        """P2S sends calibration fields as JSON booleans (single-nozzle, like X1C/A1/P1)."""
         mqtt_client.model = "P2S"
         mqtt_client.start_print("test.3mf", timelapse=True, flow_cali=False)
 
         cmd = self._get_published_command(mqtt_client)
         assert cmd["timelapse"] is True
         assert cmd["flow_cali"] is False
+        # flow_cali off → extrude_cali_flag=2 (skip, reuse stored PA value).
+        assert cmd["extrude_cali_flag"] == 2
 
     def test_h2s_single_external_spool_uses_main_id(self, mqtt_client):
         """H2S is single-nozzle (#1386): external spool (254) → ams_id=255.
@@ -3797,11 +3797,14 @@ class TestStartPrintAmsMapping:
         cmd = self._get_published_command(mqtt_client)
         assert cmd["use_ams"] is False
 
-    def test_h2s_keeps_integer_format_for_calibration_fields(self, mqtt_client):
-        """H2S shares the H-family firmware (int 0/1 for calibration fields)
-        even though it's single-nozzle. Verified empirically against H2S
-        bundles: the print command structure was always accepted, only the
-        AMS routing failed (#1386).
+    def test_h2s_uses_boolean_format_for_calibration_fields(self, mqtt_client):
+        """H2S sends calibration fields as JSON booleans (#1478).
+
+        The H2S was previously integer-encoded as part of the H2 family. That
+        made it accept the print command but silently skip flow-dynamics
+        calibration — the reporter saw poor corner quality from a stale K
+        value. BambuStudio sends booleans for these fields and pairs flow_cali
+        with extrude_cali_flag=1 to actually run the calibration pass.
         """
         mqtt_client.model = "H2S"
         mqtt_client.start_print(
@@ -3814,11 +3817,14 @@ class TestStartPrintAmsMapping:
         )
 
         cmd = self._get_published_command(mqtt_client)
-        assert cmd["timelapse"] == 1
-        assert cmd["bed_leveling"] == 0
-        assert cmd["flow_cali"] == 1
-        assert cmd["vibration_cali"] == 0
-        assert cmd["layer_inspect"] == 1
+        assert cmd["timelapse"] is True
+        assert cmd["bed_leveling"] is False
+        assert cmd["flow_cali"] is True
+        assert cmd["vibration_cali"] is False
+        assert cmd["layer_inspect"] is True
+        # flow_cali on → extrude_cali_flag=1 so the printer runs the
+        # flow-dynamics calibration instead of reusing the stored PA value.
+        assert cmd["extrude_cali_flag"] == 1
 
 
 class TestStartPrintUniqueIdentityFields:
@@ -3900,6 +3906,24 @@ class TestStartPrintUniqueIdentityFields:
         assert int(cmd["task_id"]) > 0
         assert len(cmd["task_id"]) <= 64
 
+    def test_last_dispatch_subtask_id_records_the_minted_id(self, mqtt_client):
+        """#1485: start_print records the minted id on the client so
+        on_print_start can persist it on the archive before the printer
+        echoes subtask_id back — letting a later restart resume by id."""
+        assert mqtt_client.last_dispatch_subtask_id is None
+        mqtt_client.start_print("test.3mf")
+        cmd = self._get_published_command(mqtt_client)
+        assert mqtt_client.last_dispatch_subtask_id == cmd["subtask_id"]
+
+    def test_last_dispatch_subtask_id_updates_per_submission(self, mqtt_client):
+        """Each dispatch overwrites the recorded id with the new submission's."""
+        mqtt_client.start_print("test.3mf")
+        first = mqtt_client.last_dispatch_subtask_id
+        time.sleep(0.002)
+        mqtt_client.start_print("test.3mf")
+        assert mqtt_client.last_dispatch_subtask_id != first
+        assert mqtt_client.last_dispatch_subtask_id == self._get_published_command(mqtt_client)["subtask_id"]
+
     def test_submission_id_fits_signed_int32(self, mqtt_client):
         """Regression for #1042: P1S firmware clamps oversized task identity
         fields to signed int32 max (2**31-1 = 2147483647). If we send raw
@@ -4084,6 +4108,47 @@ class TestStaleReconnect:
         assert mqtt_client.state.connected is True
         assert mqtt_client._stale_reconnecting is False
 
+    def test_check_staleness_logs_serial_hint_when_no_reports(self, mqtt_client, caplog):
+        """#1465 — a stale connection that never received a status report logs
+        an actionable serial-number hint, exactly once."""
+        import logging
+        import time
+
+        mqtt_client.state.connected = True
+        mqtt_client._last_message_time = time.time() - 120
+        mqtt_client._report_messages_since_connect = 0
+
+        with caplog.at_level(logging.WARNING):
+            mqtt_client.check_staleness()
+
+        assert mqtt_client._zero_report_hint_logged is True
+        assert any("zero status reports" in r.getMessage() for r in caplog.records)
+
+        # Re-arm staleness — the hint must not log a second time.
+        caplog.clear()
+        mqtt_client.state.connected = True
+        mqtt_client._last_message_time = time.time() - 120
+        mqtt_client._last_stale_reconnect = 0.0  # bypass the reconnect cooldown
+        with caplog.at_level(logging.WARNING):
+            mqtt_client.check_staleness()
+        assert not any("zero status reports" in r.getMessage() for r in caplog.records)
+
+    def test_check_staleness_no_serial_hint_when_reports_received(self, mqtt_client, caplog):
+        """A stale connection that DID receive reports (a normal mid-session
+        quiet gap) must not log the serial-number hint."""
+        import logging
+        import time
+
+        mqtt_client.state.connected = True
+        mqtt_client._last_message_time = time.time() - 120
+        mqtt_client._report_messages_since_connect = 5
+
+        with caplog.at_level(logging.WARNING):
+            mqtt_client.check_staleness()
+
+        assert mqtt_client._zero_report_hint_logged is False
+        assert not any("zero status reports" in r.getMessage() for r in caplog.records)
+
     def test_on_disconnect_skipped_during_stale_reconnect(self, mqtt_client):
         """_on_disconnect should not broadcast state when _stale_reconnecting is set."""
         state_changes = []
@@ -5132,3 +5197,214 @@ class TestDryingCompleteCallback:
         # And finishes.
         mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
         assert mqtt_client._drying_events == [0, 0]
+
+    def test_tray_only_partial_does_not_fake_completion(self, mqtt_client):
+        """#1462 — a tray-bearing partial update that omits dry_time must not
+        be read as dry_time=0. The pre-fix merge dropped dry_time on such
+        partials, so the falling-edge detector saw a 60→0 edge and fired a
+        false 'drying complete' seconds after drying started — which armed
+        smart-plug auto-off and killed the printer mid-cycle."""
+        # Drying active, 60 minutes remaining.
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 60, "tray": []}]})
+        assert mqtt_client._drying_events == []
+
+        # Printer sends a tray-bearing partial carrying NO dry_time field.
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "tray": []}]})
+        assert mqtt_client._drying_events == []
+        # dry_time survived the partial in the merged AMS state.
+        assert mqtt_client.state.raw_data["ams"][0]["dry_time"] == 60
+
+        # Drying genuinely finishes → the real edge still fires exactly once.
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
+        assert mqtt_client._drying_events == [0]
+
+
+class TestPrintRunningObservedCallback:
+    """#1485 follow-up: on_print_running_observed fires the FIRST time we
+    see ``state == RUNNING`` for a printer whose print started before
+    Bambuddy came up. It lets main.py capture a timelapse baseline at
+    restart-recovery time — when on_print_start was suppressed by the
+    #1304 first-push guard. Must NOT fire when on_print_start handles the
+    transition (avoids double-capture), and must NOT fire again after
+    the first observation in the same session.
+    """
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST123",
+            access_code="12345678",
+        )
+
+    def test_fires_on_first_running_push_after_startup(self, mqtt_client):
+        """First push the client sees has _previous_gcode_state=None, so the
+        #1304 guard suppresses on_print_start. on_print_running_observed
+        must fire instead so the consumer can recover."""
+        start_calls: list[dict] = []
+        running_observed_calls: list[dict] = []
+        mqtt_client.on_print_start = lambda data: start_calls.append(data)
+        mqtt_client.on_print_running_observed = lambda data: running_observed_calls.append(data)
+
+        # Pristine state — exactly what we have right after BambuMQTTClient
+        # construction following a Bambuddy restart.
+        mqtt_client._was_running = False
+        mqtt_client._previous_gcode_state = None
+
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "RUNNING",
+                    "gcode_file": "/data/Metadata/test_print.gcode",
+                    "subtask_name": "Test_Print",
+                }
+            }
+        )
+
+        assert start_calls == [], "on_print_start must be suppressed by the #1304 guard"
+        assert len(running_observed_calls) == 1
+        assert running_observed_calls[0]["filename"] == "/data/Metadata/test_print.gcode"
+        assert running_observed_calls[0]["subtask_name"] == "Test_Print"
+
+    def test_does_not_fire_when_print_start_fires(self, mqtt_client):
+        """Normal print start (a real state transition from non-RUNNING to
+        RUNNING) goes through on_print_start; on_print_running_observed
+        must stay quiet so the consumer doesn't capture the baseline twice."""
+        start_calls: list[dict] = []
+        running_observed_calls: list[dict] = []
+        mqtt_client.on_print_start = lambda data: start_calls.append(data)
+        mqtt_client.on_print_running_observed = lambda data: running_observed_calls.append(data)
+
+        mqtt_client._was_running = False
+        mqtt_client._previous_gcode_state = "IDLE"  # Not None — past the #1304 guard
+
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "RUNNING",
+                    "gcode_file": "/data/Metadata/test_print.gcode",
+                    "subtask_name": "Test_Print",
+                }
+            }
+        )
+
+        assert len(start_calls) == 1, "on_print_start should fire on a real start transition"
+        assert running_observed_calls == [], "on_print_running_observed must not double up with on_print_start"
+
+    def test_fires_only_once_per_session(self, mqtt_client):
+        """Subsequent RUNNING pushes in the same session must not re-fire the
+        callback — the baseline only needs to be captured once, the consumer
+        treats repeat calls as a hint to skip via the in-memory dict guard."""
+        running_observed_calls: list[dict] = []
+        mqtt_client.on_print_running_observed = lambda data: running_observed_calls.append(data)
+
+        mqtt_client._was_running = False
+        mqtt_client._previous_gcode_state = None
+
+        msg = {
+            "print": {
+                "gcode_state": "RUNNING",
+                "gcode_file": "/data/Metadata/test_print.gcode",
+                "subtask_name": "Test_Print",
+            }
+        }
+        mqtt_client._process_message(msg)
+        mqtt_client._process_message(msg)
+        mqtt_client._process_message(msg)
+
+        assert len(running_observed_calls) == 1
+
+    def test_does_not_fire_when_not_running(self, mqtt_client):
+        """An IDLE / PREPARE / FINISH first-push must not trigger the
+        restart-recovery path — there's no print to baseline."""
+        running_observed_calls: list[dict] = []
+        mqtt_client.on_print_running_observed = lambda data: running_observed_calls.append(data)
+
+        mqtt_client._was_running = False
+        mqtt_client._previous_gcode_state = None
+
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "IDLE",
+                    "gcode_file": "",
+                    "subtask_name": "",
+                }
+            }
+        )
+
+        assert running_observed_calls == []
+
+    def test_does_not_fire_without_current_file(self, mqtt_client):
+        """RUNNING with no file is ill-formed (firmware glitch / transient).
+        We need ``current_file`` to find the right archive, so skip the
+        callback rather than fire it with a meaningless payload."""
+        running_observed_calls: list[dict] = []
+        mqtt_client.on_print_running_observed = lambda data: running_observed_calls.append(data)
+
+        mqtt_client._was_running = False
+        mqtt_client._previous_gcode_state = None
+
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "RUNNING",
+                    "gcode_file": "",
+                    "subtask_name": "",
+                }
+            }
+        )
+
+        assert running_observed_calls == []
+
+    def test_safe_when_callback_not_set(self, mqtt_client):
+        """No callback configured → silently skip; no AttributeError on the
+        firing branch."""
+        mqtt_client.on_print_running_observed = None
+        mqtt_client._was_running = False
+        mqtt_client._previous_gcode_state = None
+
+        # Should not raise.
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "RUNNING",
+                    "gcode_file": "/data/Metadata/test_print.gcode",
+                    "subtask_name": "Test_Print",
+                }
+            }
+        )
+
+        assert mqtt_client._was_running is True
+
+    def test_payload_shape_matches_print_start(self, mqtt_client):
+        """The payload shape must mirror on_print_start so main.py's
+        consumer can reuse the same dict fields (filename / subtask_name /
+        remaining_time / raw_data / ams_mapping). Test pins the keys."""
+        running_observed_calls: list[dict] = []
+        mqtt_client.on_print_running_observed = lambda data: running_observed_calls.append(data)
+        mqtt_client._was_running = False
+        mqtt_client._previous_gcode_state = None
+
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "RUNNING",
+                    "gcode_file": "/data/Metadata/test_print.gcode",
+                    "subtask_name": "Test_Print",
+                    "mc_remaining_time": 42,
+                }
+            }
+        )
+
+        assert len(running_observed_calls) == 1
+        payload = running_observed_calls[0]
+        assert set(payload.keys()) == {
+            "filename",
+            "subtask_name",
+            "remaining_time",
+            "raw_data",
+            "ams_mapping",
+        }

+ 18 - 0
backend/tests/unit/services/test_camera_profiles.py

@@ -50,6 +50,24 @@ class TestGetCameraProfile:
         assert profile.probesize >= 1_000_000
         assert profile.analyzeduration >= 500_000
 
+    def test_p2s_regenerates_timestamps_from_wallclock(self):
+        """P2S firmware 01.02.00.00 sends non-advancing RTP timestamps;
+        ffmpeg's default CFR conversion (`-r 15`) then freezes the output
+        clock after frame 1 and drops everything else (#1395). The profile
+        must splice `-use_wallclock_as_timestamps 1` into the input args so
+        ffmpeg rebuilds PTS from arrival time. Order matters — the flag and
+        its value must be adjacent so they reach ffmpeg as a pair."""
+        args = get_camera_profile("P2S").extra_ffmpeg_input_args
+        assert "-use_wallclock_as_timestamps" in args
+        idx = args.index("-use_wallclock_as_timestamps")
+        assert args[idx + 1] == "1"
+
+    def test_default_profile_has_no_timestamp_override(self):
+        """X1/H2 send correct, advancing RTP timestamps — they must NOT get
+        the wallclock override, which would needlessly re-stamp a healthy
+        stream. Guards against the P2S fix leaking into the default."""
+        assert DEFAULT_PROFILE.extra_ffmpeg_input_args == ()
+
     def test_p2s_internal_code_resolves_to_p2s_profile(self):
         """SSDP internal codes (e.g. `N7` for P2S) must resolve to the
         same profile as their display name. Otherwise printers freshly

+ 267 - 0
backend/tests/unit/services/test_filament_deficit.py

@@ -0,0 +1,267 @@
+"""Unit tests for the filament-deficit pre-dispatch check (#1496).
+
+The check is the single source of truth that both ``POST /queue/{id}/start``
+and the dispatch scheduler call before sending a print to the printer. Pin
+the contract for the cases that matter:
+
+* Internal-inventory mode: shortfall + sufficient + no assignment.
+* AMS-mapping gating: a missing mapping means "not yet decided, skip".
+* Disabled-warnings setting + missing printer (model-based item) + no
+  source 3MF all short-circuit to "no deficit".
+"""
+
+from __future__ import annotations
+
+import json
+import zipfile
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.settings import Settings
+from backend.app.models.spool import Spool
+from backend.app.models.spool_assignment import SpoolAssignment
+from backend.app.services.filament_deficit import (
+    FilamentDeficit,
+    compute_deficit_for_queue_item,
+)
+
+
+def _write_3mf(file_path: Path, filaments: list[dict]) -> None:
+    """Minimal 3MF that ``extract_filament_requirements`` can parse (flat shape)."""
+    body = "".join(
+        f'<filament id="{f["id"]}" type="{f["type"]}" color="{f["color"]}" '
+        f'used_g="{f["used_g"]}" tray_info_idx="{f.get("tray_info_idx", "")}"/>'
+        for f in filaments
+    )
+    config = f'<?xml version="1.0" encoding="utf-8"?><config>{body}</config>'
+    with zipfile.ZipFile(file_path, "w") as zf:
+        zf.writestr("Metadata/slice_info.config", config)
+
+
+async def _setup_archive_3mf(db_session, tmp_path: Path, filaments: list[dict]) -> PrintArchive:
+    """Create a 3MF on disk and a PrintArchive row pointing at it."""
+    file_name = "model.3mf"
+    file_path = tmp_path / file_name
+    _write_3mf(file_path, filaments)
+    archive = PrintArchive(
+        filename=file_name,
+        print_name="Test",
+        # The helper resolves via app_settings.base_dir / file_path, but
+        # storing the absolute path on the model also works because
+        # ``Path / abs`` collapses to the absolute side.
+        file_path=str(file_path),
+        file_size=file_path.stat().st_size,
+        status="completed",
+    )
+    db_session.add(archive)
+    await db_session.commit()
+    await db_session.refresh(archive)
+    return archive
+
+
+async def _spool(db_session, *, label_weight: int, weight_used: float, color: str = "#000000") -> Spool:
+    spool = Spool(
+        material="PLA",
+        label_weight=label_weight,
+        weight_used=weight_used,
+        rgba=color,
+    )
+    db_session.add(spool)
+    await db_session.commit()
+    await db_session.refresh(spool)
+    return spool
+
+
+async def _assign(db_session, *, printer_id: int, spool_id: int, ams_id: int = 0, tray_id: int = 0) -> None:
+    db_session.add(
+        SpoolAssignment(
+            spool_id=spool_id,
+            printer_id=printer_id,
+            ams_id=ams_id,
+            tray_id=tray_id,
+        )
+    )
+    await db_session.commit()
+
+
+async def _queue_item(
+    db_session,
+    *,
+    printer_id: int | None,
+    archive: PrintArchive | None,
+    ams_mapping: list[int] | None,
+    plate_id: int | None = None,
+) -> PrintQueueItem:
+    item = PrintQueueItem(
+        printer_id=printer_id,
+        archive_id=archive.id if archive else None,
+        ams_mapping=json.dumps(ams_mapping) if ams_mapping is not None else None,
+        plate_id=plate_id,
+        status="pending",
+        manual_start=True,
+    )
+    db_session.add(item)
+    await db_session.commit()
+    await db_session.refresh(item, ["archive", "library_file"])
+    return item
+
+
+class TestFilamentDeficit:
+    @pytest.mark.asyncio
+    async def test_returns_deficit_when_spool_too_light(self, db_session, printer_factory, tmp_path):
+        """Spool with 30g remaining for a 100g print → one deficit row."""
+        printer = await printer_factory()
+        archive = await _setup_archive_3mf(
+            db_session,
+            tmp_path,
+            [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"}],
+        )
+        spool = await _spool(db_session, label_weight=1000, weight_used=970.0)  # 30g left
+        await _assign(db_session, printer_id=printer.id, spool_id=spool.id, ams_id=0, tray_id=0)
+        item = await _queue_item(db_session, printer_id=printer.id, archive=archive, ams_mapping=[0])
+
+        with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
+            deficit = await compute_deficit_for_queue_item(db_session, item)
+
+        assert len(deficit) == 1
+        assert isinstance(deficit[0], FilamentDeficit)
+        assert deficit[0].slot_id == 1
+        assert deficit[0].required_grams == 100.0
+        assert deficit[0].remaining_grams == 30.0
+        assert deficit[0].filament_type == "PLA"
+
+    @pytest.mark.asyncio
+    async def test_returns_empty_when_spool_has_enough(self, db_session, printer_factory, tmp_path):
+        printer = await printer_factory()
+        archive = await _setup_archive_3mf(
+            db_session,
+            tmp_path,
+            [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"}],
+        )
+        spool = await _spool(db_session, label_weight=1000, weight_used=200.0)  # 800g left
+        await _assign(db_session, printer_id=printer.id, spool_id=spool.id, ams_id=0, tray_id=0)
+        item = await _queue_item(db_session, printer_id=printer.id, archive=archive, ams_mapping=[0])
+
+        with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
+            deficit = await compute_deficit_for_queue_item(db_session, item)
+
+        assert deficit == []
+
+    @pytest.mark.asyncio
+    async def test_returns_empty_when_ams_mapping_missing(self, db_session, printer_factory, tmp_path):
+        """No mapping yet = scheduler hasn't decided which slot maps where."""
+        printer = await printer_factory()
+        archive = await _setup_archive_3mf(
+            db_session,
+            tmp_path,
+            [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"}],
+        )
+        item = await _queue_item(db_session, printer_id=printer.id, archive=archive, ams_mapping=None)
+
+        with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
+            deficit = await compute_deficit_for_queue_item(db_session, item)
+
+        assert deficit == []
+
+    @pytest.mark.asyncio
+    async def test_returns_empty_when_no_printer_assigned(self, db_session, tmp_path):
+        """Model-based queue items with no resolved printer_id can't be checked."""
+        archive = await _setup_archive_3mf(
+            db_session,
+            tmp_path,
+            [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"}],
+        )
+        item = await _queue_item(db_session, printer_id=None, archive=archive, ams_mapping=[0])
+
+        with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
+            deficit = await compute_deficit_for_queue_item(db_session, item)
+
+        assert deficit == []
+
+    @pytest.mark.asyncio
+    async def test_returns_empty_when_warnings_disabled(self, db_session, printer_factory, tmp_path):
+        """Honour the disable_filament_warnings setting (#720 toggle)."""
+        printer = await printer_factory()
+        archive = await _setup_archive_3mf(
+            db_session,
+            tmp_path,
+            [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"}],
+        )
+        spool = await _spool(db_session, label_weight=1000, weight_used=970.0)
+        await _assign(db_session, printer_id=printer.id, spool_id=spool.id)
+        item = await _queue_item(db_session, printer_id=printer.id, archive=archive, ams_mapping=[0])
+        db_session.add(Settings(key="disable_filament_warnings", value="true"))
+        await db_session.commit()
+
+        with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
+            deficit = await compute_deficit_for_queue_item(db_session, item)
+
+        assert deficit == []
+
+    @pytest.mark.asyncio
+    async def test_returns_empty_when_no_assignment(self, db_session, printer_factory, tmp_path):
+        """Mapping points at a slot with no spool assigned → silent, not blocked."""
+        printer = await printer_factory()
+        archive = await _setup_archive_3mf(
+            db_session,
+            tmp_path,
+            [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"}],
+        )
+        item = await _queue_item(db_session, printer_id=printer.id, archive=archive, ams_mapping=[0])
+
+        with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
+            deficit = await compute_deficit_for_queue_item(db_session, item)
+
+        assert deficit == []
+
+    @pytest.mark.asyncio
+    async def test_returns_empty_when_3mf_missing(self, db_session, printer_factory):
+        printer = await printer_factory()
+        archive = PrintArchive(
+            filename="ghost.3mf",
+            file_path="/nonexistent/ghost.3mf",
+            file_size=0,
+            status="completed",
+        )
+        db_session.add(archive)
+        await db_session.commit()
+        await db_session.refresh(archive)
+        item = await _queue_item(db_session, printer_id=printer.id, archive=archive, ams_mapping=[0])
+
+        deficit = await compute_deficit_for_queue_item(db_session, item)
+
+        assert deficit == []
+
+    @pytest.mark.asyncio
+    async def test_multi_slot_only_shorted_slot_returned(self, db_session, printer_factory, tmp_path):
+        """One slot fine, one short — only the short slot is in the result."""
+        printer = await printer_factory()
+        archive = await _setup_archive_3mf(
+            db_session,
+            tmp_path,
+            [
+                {"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"},
+                {"id": "2", "type": "PETG", "color": "#000000", "used_g": "80.0"},
+            ],
+        )
+        plenty = await _spool(db_session, label_weight=1000, weight_used=100.0)  # 900g
+        shorted = await _spool(db_session, label_weight=1000, weight_used=950.0)  # 50g
+        await _assign(db_session, printer_id=printer.id, spool_id=plenty.id, ams_id=0, tray_id=0)
+        await _assign(db_session, printer_id=printer.id, spool_id=shorted.id, ams_id=0, tray_id=1)
+        item = await _queue_item(
+            db_session,
+            printer_id=printer.id,
+            archive=archive,
+            ams_mapping=[0, 1],  # slot 1 -> tray 0, slot 2 -> tray 1
+        )
+
+        with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
+            deficit = await compute_deficit_for_queue_item(db_session, item)
+
+        assert [d.slot_id for d in deficit] == [2]
+        assert deficit[0].remaining_grams == 50.0
+        assert deficit[0].required_grams == 80.0

+ 48 - 0
backend/tests/unit/services/test_filament_requirements.py

@@ -109,6 +109,54 @@ class TestExtractFilamentRequirements:
         assert len(out) == 1
         assert out[0]["type"] == "PLA"
 
+    def test_no_plate_id_collects_from_all_plates(self, tmp_path: Path):
+        """Modern BambuStudio wraps filaments inside <plate> elements.  When
+        plate_id=None, every plate's filaments must be returned (deduplicated)."""
+        f = tmp_path / "multi.3mf"
+        _make_3mf(
+            f,
+            plates=[
+                (1, [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "5"}]),
+                (2, [{"id": "2", "type": "PETG", "color": "#000000", "used_g": "3"}]),
+            ],
+        )
+        out = extract_filament_requirements(f, plate_id=None)
+        assert len(out) == 2
+        slot_ids = [r["slot_id"] for r in out]
+        assert 1 in slot_ids
+        assert 2 in slot_ids
+
+    def test_no_plate_id_deduplicates_shared_slots(self, tmp_path: Path):
+        """Same slot_id on multiple plates keeps only the entry with the
+        highest used_grams (the plate that actually consumes more)."""
+        f = tmp_path / "shared.3mf"
+        _make_3mf(
+            f,
+            plates=[
+                (1, [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "5"}]),
+                (2, [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "8"}]),
+            ],
+        )
+        out = extract_filament_requirements(f, plate_id=None)
+        assert len(out) == 1
+        assert out[0]["slot_id"] == 1
+        assert out[0]["used_grams"] == 8.0
+
+    def test_no_plate_id_single_plate_modern_format(self, tmp_path: Path):
+        """Single-plate 3MF using modern <plate> wrapping is parsed correctly
+        when plate_id=None — this is the common queue scenario where no specific
+        plate is targeted."""
+        f = tmp_path / "single.3mf"
+        _make_3mf(
+            f,
+            plates=[(1, [{"id": "1", "type": "PLA", "color": "#CBC6B8", "used_g": "0.12"}])],
+        )
+        out = extract_filament_requirements(f, plate_id=None)
+        assert len(out) == 1
+        assert out[0]["slot_id"] == 1
+        assert out[0]["type"] == "PLA"
+        assert out[0]["color"] == "#CBC6B8"
+
     def test_returns_empty_list_for_unparseable_file(self, tmp_path: Path):
         f = tmp_path / "bad.3mf"
         f.write_bytes(b"not a zip")

+ 93 - 0
backend/tests/unit/services/test_ftp_profiles.py

@@ -0,0 +1,93 @@
+"""Per-model FTP profile registry (#1401).
+
+Mirrors ``test_camera_profiles.py`` in shape — the FTP profile module
+follows the same pattern.
+"""
+
+import ssl
+
+from backend.app.services.ftp_profiles import (
+    DEFAULT_PROFILE,
+    FTPProfile,
+    get_ftp_profile,
+)
+
+
+def test_default_profile_does_not_cap_tls():
+    """Default profile keeps the historical Python-default TLS negotiation
+    (typically TLS 1.3 on Python 3.13). Capping would be a silent
+    regression for users who work fine today."""
+    assert DEFAULT_PROFILE.cap_tls_v1_2 is False
+
+
+def test_unknown_model_returns_default():
+    """Unknown / missing models fall back to DEFAULT_PROFILE so the FTP
+    path is never blocked on a missing entry."""
+    assert get_ftp_profile(None) is DEFAULT_PROFILE
+    assert get_ftp_profile("") is DEFAULT_PROFILE
+    assert get_ftp_profile("Unknown Future Model") is DEFAULT_PROFILE
+
+
+def test_p2s_caps_tls_v1_2():
+    """P2S firmware 01.02.00.00 trips a vsFTPd + TLS 1.3 session-reuse
+    bug on the data channel; the profile must cap to TLS 1.2 so session
+    resumption is synchronous (#1401, reporter @iitazz)."""
+    profile = get_ftp_profile("P2S")
+    assert profile.cap_tls_v1_2 is True
+
+
+def test_p2s_internal_ssdp_code_resolves_to_p2s():
+    """SSDP internal code N7 → P2S profile. Camera profiles do the same
+    thing — keeps callers free of the code↔display-name mapping."""
+    profile = get_ftp_profile("N7")
+    assert profile.cap_tls_v1_2 is True
+
+
+def test_lookup_is_case_insensitive():
+    """Printer.model may carry mixed case; the lookup normalises."""
+    assert get_ftp_profile("p2s").cap_tls_v1_2 is True
+    assert get_ftp_profile("P2s").cap_tls_v1_2 is True
+
+
+def test_non_capped_models_still_default():
+    """Spot-check: the models the user dogfoods today (X1C, H2D) stay on
+    the default profile. Adding the P2S override must not accidentally
+    flip these."""
+    assert get_ftp_profile("X1C").cap_tls_v1_2 is False
+    assert get_ftp_profile("H2D").cap_tls_v1_2 is False
+    assert get_ftp_profile("P1S").cap_tls_v1_2 is False
+    assert get_ftp_profile("A1").cap_tls_v1_2 is False
+
+
+def test_profile_is_frozen():
+    """FTPProfile is a frozen dataclass — runtime mutation should raise.
+    Same guarantee CameraProfile has."""
+    try:
+        DEFAULT_PROFILE.cap_tls_v1_2 = True  # type: ignore[misc]
+    except Exception as e:
+        assert "frozen" in str(e).lower() or "FrozenInstanceError" in type(e).__name__
+        return
+    raise AssertionError("FTPProfile should be frozen but assignment succeeded")
+
+
+def test_cap_tls_v1_2_actually_applied_to_ssl_context():
+    """Pins the integration: when ``cap_tls_v1_2=True`` is passed to the
+    FTPS subclass, the SSL context's ``maximum_version`` is set to
+    TLSv1.2. Guards against a future refactor that drops the wiring
+    between profile and context (the registry would still look
+    correct, but the cap would silently stop applying)."""
+    from backend.app.services.bambu_ftp import ImplicitFTP_TLS
+
+    capped = ImplicitFTP_TLS(cap_tls_v1_2=True)
+    assert capped.ssl_context.maximum_version == ssl.TLSVersion.TLSv1_2
+
+    uncapped = ImplicitFTP_TLS(cap_tls_v1_2=False)
+    # MAXIMUM_SUPPORTED is the "no cap applied" sentinel for SSLContext.
+    assert uncapped.ssl_context.maximum_version == ssl.TLSVersion.MAXIMUM_SUPPORTED
+
+
+def test_ftp_profile_dataclass_default_constructible():
+    """Sanity: FTPProfile() with no args yields the default profile
+    (every field has a default)."""
+    fresh = FTPProfile()
+    assert fresh == DEFAULT_PROFILE

+ 169 - 0
backend/tests/unit/services/test_log_health.py

@@ -0,0 +1,169 @@
+"""Tests for the log-health scanner (backend/app/services/log_health.py)."""
+
+import pytest
+
+from backend.app.core.config import settings
+from backend.app.services.log_health import SIGNATURES, scan_logs
+
+
+def _line(level, logger, msg, ts="2026-05-22 10:00:00,000"):
+    """Build one log line in the app's log format: TS LEVEL [logger] message."""
+    return f"{ts} {level} [{logger}] {msg}"
+
+
+def _write_log(tmp_path, monkeypatch, lines):
+    log_file = tmp_path / "bambuddy.log"
+    log_file.write_text("\n".join(lines) + "\n", encoding="utf-8")
+    monkeypatch.setattr(settings, "log_dir", tmp_path)
+    return log_file
+
+
+FTP_LOGGER = "backend.app.services.bambu_ftp"
+MQTT_LOGGER = "backend.app.services.bambu_mqtt"
+CAM_LOGGER = "backend.app.services.camera"
+
+
+def test_clean_log_has_no_findings(tmp_path, monkeypatch):
+    _write_log(
+        tmp_path,
+        monkeypatch,
+        [
+            _line("INFO", "backend.app.main", "Application startup complete"),
+            _line("INFO", FTP_LOGGER, "FTP connected, logging in as bblp"),
+        ],
+    )
+    result = scan_logs()
+    assert result.findings == []
+    assert result.log_available is True
+    assert result.scanned_entries == 2
+    assert result.summary == {"total": 0, "layer8": 0, "environment": 0, "bug": 0}
+
+
+def test_log_unavailable_when_file_missing(tmp_path, monkeypatch):
+    # No log file written.
+    monkeypatch.setattr(settings, "log_dir", tmp_path)
+    result = scan_logs()
+    assert result.log_available is False
+    assert result.findings == []
+
+
+def test_ftp_auth_rejected_is_detected(tmp_path, monkeypatch):
+    _write_log(
+        tmp_path,
+        monkeypatch,
+        [_line("WARNING", FTP_LOGGER, "FTP connection permission error to 10.0.0.9: 530 Login incorrect")],
+    )
+    result = scan_logs()
+    assert len(result.findings) == 1
+    f = result.findings[0]
+    assert f.signature_id == "ftp-auth-rejected"
+    assert f.severity == "error"
+    assert f.category == "layer8"
+    assert f.count == 1
+
+
+def test_min_count_gates_low_frequency_signals(tmp_path, monkeypatch):
+    # ftp-connection-timeout requires min_count=3 — two hits must not surface.
+    _write_log(
+        tmp_path,
+        monkeypatch,
+        [_line("WARNING", FTP_LOGGER, "FTP connection timed out to 10.0.0.9: timed out")] * 2,
+    )
+    assert scan_logs().findings == []
+
+    _write_log(
+        tmp_path,
+        monkeypatch,
+        [_line("WARNING", FTP_LOGGER, "FTP connection timed out to 10.0.0.9: timed out")] * 3,
+    )
+    findings = scan_logs().findings
+    assert len(findings) == 1
+    assert findings[0].signature_id == "ftp-connection-timeout"
+    assert findings[0].count == 3
+
+
+def test_aggregation_tracks_count_and_seen_range(tmp_path, monkeypatch):
+    _write_log(
+        tmp_path,
+        monkeypatch,
+        [
+            _line("WARNING", FTP_LOGGER, "FTP connection permission error to 10.0.0.9", ts="2026-05-22 09:00:00,000"),
+            _line("WARNING", FTP_LOGGER, "FTP connection permission error to 10.0.0.9", ts="2026-05-22 10:30:00,000"),
+        ],
+    )
+    f = scan_logs().findings[0]
+    assert f.count == 2
+    assert f.first_seen == "2026-05-22 09:00:00,000"
+    assert f.last_seen == "2026-05-22 10:30:00,000"
+
+
+def test_logger_prefix_filters_unrelated_loggers(tmp_path, monkeypatch):
+    # Same text, but logged by an unrelated logger — must not match the
+    # bambu_ftp-scoped signature.
+    _write_log(
+        tmp_path,
+        monkeypatch,
+        [_line("WARNING", "backend.app.services.something_else", "FTP connection permission error to 10.0.0.9")],
+    )
+    assert scan_logs().findings == []
+
+
+def test_min_level_filters_below_threshold(tmp_path, monkeypatch):
+    # ftp-auth-rejected requires at least WARNING — an INFO line must not match.
+    _write_log(
+        tmp_path,
+        monkeypatch,
+        [_line("INFO", FTP_LOGGER, "FTP connection permission error to 10.0.0.9")],
+    )
+    assert scan_logs().findings == []
+
+
+def test_sample_is_sanitized(tmp_path, monkeypatch):
+    _write_log(
+        tmp_path,
+        monkeypatch,
+        [_line("WARNING", FTP_LOGGER, "FTP connection permission error to 192.168.1.50: 530")],
+    )
+    f = scan_logs().findings[0]
+    assert "192.168.1.50" not in f.sample
+    assert "[IP]" in f.sample
+
+
+def test_database_locked_matches_inside_traceback(tmp_path, monkeypatch):
+    # The signature text appears on a continuation line of a multi-line entry;
+    # read_log_entries folds it into the parent message.
+    _write_log(
+        tmp_path,
+        monkeypatch,
+        [
+            _line("ERROR", "backend.app.core.database", "Unhandled DB error"),
+            "Traceback (most recent call last):",
+            "sqlite3.OperationalError: database is locked",
+        ],
+    )
+    findings = scan_logs().findings
+    assert len(findings) == 1
+    assert findings[0].signature_id == "database-locked"
+    assert findings[0].category == "environment"
+
+
+def test_findings_sorted_layer8_then_environment(tmp_path, monkeypatch):
+    _write_log(
+        tmp_path,
+        monkeypatch,
+        [
+            _line("ERROR", "backend.app.core.database", "x: database is locked"),
+            _line("WARNING", FTP_LOGGER, "FTP connection timed out to 10.0.0.9"),
+            _line("WARNING", FTP_LOGGER, "FTP connection timed out to 10.0.0.9"),
+            _line("WARNING", FTP_LOGGER, "FTP connection timed out to 10.0.0.9"),
+            _line("WARNING", FTP_LOGGER, "FTP connection permission error to 10.0.0.9"),
+        ],
+    )
+    ids = [f.signature_id for f in scan_logs().findings]
+    # layer8 error, then layer8 warning, then environment.
+    assert ids == ["ftp-auth-rejected", "ftp-connection-timeout", "database-locked"]
+
+
+def test_every_signature_id_is_unique():
+    ids = [s.id for s in SIGNATURES]
+    assert len(ids) == len(set(ids))

+ 72 - 0
backend/tests/unit/services/test_loop_watchdog.py

@@ -0,0 +1,72 @@
+"""Tests for the event-loop stall watchdog (#1486)."""
+
+import asyncio
+import faulthandler
+from unittest.mock import patch
+
+import pytest
+
+from backend.app.services import loop_watchdog
+
+
+@pytest.fixture(autouse=True)
+def _mock_faulthandler():
+    """Patch faulthandler so tests never arm a real 30s stall timer that
+    could fire mid-suite. Yields (arm_mock, cancel_mock)."""
+    with (
+        patch.object(faulthandler, "dump_traceback_later") as arm,
+        patch.object(faulthandler, "cancel_dump_traceback_later") as cancel,
+    ):
+        yield arm, cancel
+    # Safety net: make sure no test leaves the watchdog task running.
+    loop_watchdog.stop_loop_watchdog()
+
+
+async def test_start_arms_the_stall_timer(_mock_faulthandler):
+    arm, cancel = _mock_faulthandler
+    loop_watchdog.start_loop_watchdog()
+    await asyncio.sleep(0)  # let the heartbeat run its first iteration
+
+    assert cancel.called, "previous timer must be cancelled before re-arming"
+    assert arm.called
+    # Armed STALL_THRESHOLD seconds ahead, single-shot.
+    args, kwargs = arm.call_args
+    assert args[0] == loop_watchdog.STALL_THRESHOLD
+    assert kwargs.get("repeat") is False
+
+
+async def test_start_is_idempotent(_mock_faulthandler):
+    loop_watchdog.start_loop_watchdog()
+    first = loop_watchdog._watchdog_task
+    loop_watchdog.start_loop_watchdog()
+    assert loop_watchdog._watchdog_task is first, "second start must not spawn a task"
+
+
+async def test_stop_cancels_the_task_and_disarms(_mock_faulthandler):
+    _arm, cancel = _mock_faulthandler
+    loop_watchdog.start_loop_watchdog()
+    task = loop_watchdog._watchdog_task
+    assert task is not None
+
+    cancel.reset_mock()
+    loop_watchdog.stop_loop_watchdog()
+
+    assert loop_watchdog._watchdog_task is None
+    assert cancel.called, "stop must disarm the pending faulthandler timer"
+    await asyncio.sleep(0)
+    assert task.cancelled() or task.done()
+
+
+async def test_heartbeat_interval_is_below_stall_threshold():
+    """A healthy loop must always re-arm before the timer can fire."""
+    assert loop_watchdog.HEARTBEAT_INTERVAL < loop_watchdog.STALL_THRESHOLD
+
+
+async def test_rearm_failure_does_not_crash_the_watchdog(_mock_faulthandler):
+    """A faulthandler hiccup must not take down the heartbeat task."""
+    arm, _cancel = _mock_faulthandler
+    arm.side_effect = RuntimeError("boom")
+    loop_watchdog.start_loop_watchdog()
+    await asyncio.sleep(0)
+    task = loop_watchdog._watchdog_task
+    assert task is not None and not task.done(), "watchdog must survive a re-arm error"

+ 178 - 0
backend/tests/unit/services/test_printer_diagnostic.py

@@ -0,0 +1,178 @@
+"""Unit tests for the connection diagnostic.
+
+Pins the pass / fail / warn / skip contract of each check. Those statuses
+drive the localized fix text the user sees when a printer won't connect,
+so a status flip is a user-facing regression — each one is asserted here.
+"""
+
+import types
+from contextlib import ExitStack
+from unittest.mock import AsyncMock, MagicMock, patch
+
+from backend.app.services.printer_diagnostic import _same_subnet, run_connection_diagnostic
+
+MOD = "backend.app.services.printer_diagnostic"
+
+
+def _statuses(result):
+    """Map of check id -> status for concise assertions."""
+    return {c.id: c.status for c in result.checks}
+
+
+def _port_probe(overrides=None):
+    """Sync side_effect for _check_port. Defaults: every port reachable."""
+    reachable = {8883: True, 990: True, 322: True}
+    reachable.update(overrides or {})
+
+    def _probe(ip, port, timeout=3.0):
+        return reachable[port]
+
+    return _probe
+
+
+def _state(*, connected=True, developer_mode=True):
+    return types.SimpleNamespace(connected=connected, developer_mode=developer_mode)
+
+
+class _Env:
+    """Patches the diagnostic's network/printer helpers for one run."""
+
+    def __init__(
+        self,
+        *,
+        ports=None,
+        in_docker=True,
+        network_mode="host",
+        host_ip="192.168.1.5",
+        state=None,
+        test_connection_success=True,
+    ):
+        self.ports = ports or _port_probe()
+        self.in_docker = in_docker
+        self.network_mode = network_mode
+        self.host_ip = host_ip
+        self.state = state
+        self.test_connection_success = test_connection_success
+        self._stack = ExitStack()
+
+    def __enter__(self):
+        manager = MagicMock()
+        manager.get_status.return_value = self.state
+        manager.test_connection = AsyncMock(return_value={"success": self.test_connection_success})
+        self._stack.enter_context(patch(f"{MOD}._check_port", new_callable=AsyncMock, side_effect=self.ports))
+        self._stack.enter_context(patch(f"{MOD}.is_running_in_docker", return_value=self.in_docker))
+        self._stack.enter_context(patch(f"{MOD}._detect_docker_network_mode", return_value=self.network_mode))
+        self._stack.enter_context(patch(f"{MOD}._get_host_ip", return_value=self.host_ip))
+        self._stack.enter_context(patch(f"{MOD}.printer_manager", manager))
+        return self
+
+    def __exit__(self, *exc):
+        self._stack.close()
+        return False
+
+
+def _printer(ip="192.168.1.50"):
+    return types.SimpleNamespace(id=1, ip_address=ip)
+
+
+class TestSameSubnet:
+    def test_same_24(self):
+        assert _same_subnet("192.168.1.10", "192.168.1.200") is True
+
+    def test_different_24(self):
+        assert _same_subnet("192.168.1.10", "192.168.2.10") is False
+
+    def test_hostname_undeterminable(self):
+        assert _same_subnet("printer.local", "192.168.1.10") is None
+
+    def test_ipv6_undeterminable(self):
+        assert _same_subnet("fe80::1", "192.168.1.10") is None
+
+
+class TestExistingPrinter:
+    async def test_all_healthy(self):
+        with _Env(state=_state(connected=True, developer_mode=True)):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        s = _statuses(result)
+        assert result.overall == "ok"
+        assert s == {
+            "port_mqtt": "pass",
+            "port_ftps": "pass",
+            "port_rtsps": "pass",
+            "network_mode": "pass",
+            "subnet": "pass",
+            "mqtt_auth": "pass",
+            "developer_mode": "pass",
+        }
+
+    async def test_mqtt_port_unreachable_is_a_problem(self):
+        with _Env(ports=_port_probe({8883: False}), state=_state()):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        s = _statuses(result)
+        assert result.overall == "problems"
+        assert s["port_mqtt"] == "fail"
+        # Auth can't be judged when the broker port itself is closed.
+        assert s["mqtt_auth"] == "skip"
+
+    async def test_ftps_and_rtsps_only_warn(self):
+        with _Env(ports=_port_probe({990: False, 322: False}), state=_state()):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        s = _statuses(result)
+        # No critical failure -> warnings, not problems.
+        assert result.overall == "warnings"
+        assert s["port_ftps"] == "warn"
+        assert s["port_rtsps"] == "warn"
+
+    async def test_developer_mode_off_is_a_problem(self):
+        with _Env(state=_state(connected=True, developer_mode=False)):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        s = _statuses(result)
+        assert s["developer_mode"] == "fail"
+        assert result.overall == "problems"
+
+    async def test_developer_mode_skipped_when_disconnected(self):
+        # No live MQTT connection -> developer_mode can't be read.
+        with _Env(state=_state(connected=False)):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        s = _statuses(result)
+        assert s["developer_mode"] == "skip"
+        # Reachable port but no connection -> credential failure class.
+        assert s["mqtt_auth"] == "fail"
+
+    async def test_bridge_mode_warns_and_skips_subnet(self):
+        with _Env(network_mode="bridge", state=_state()):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        s = _statuses(result)
+        assert s["network_mode"] == "warn"
+        # Container IP isn't the host IP in bridge mode -> subnet check is meaningless.
+        assert s["subnet"] == "skip"
+
+    async def test_network_mode_skipped_outside_docker(self):
+        with _Env(in_docker=False, state=_state()):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        assert _statuses(result)["network_mode"] == "skip"
+
+    async def test_different_subnet_warns(self):
+        with _Env(host_ip="10.0.0.5", state=_state()):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
+        assert _statuses(result)["subnet"] == "warn"
+
+
+class TestPreAddFlow:
+    async def test_bad_credentials_fail_mqtt_auth(self):
+        with _Env(test_connection_success=False):
+            result = await run_connection_diagnostic("192.168.1.50", serial_number="01P", access_code="wrong")
+        s = _statuses(result)
+        assert s["mqtt_auth"] == "fail"
+        # No saved printer -> developer mode can't be read.
+        assert s["developer_mode"] == "skip"
+
+    async def test_good_credentials_pass_mqtt_auth(self):
+        with _Env(test_connection_success=True):
+            result = await run_connection_diagnostic("192.168.1.50", serial_number="01P", access_code="right")
+        assert _statuses(result)["mqtt_auth"] == "pass"
+
+    async def test_no_credentials_skips_mqtt_auth(self):
+        with _Env():
+            result = await run_connection_diagnostic("192.168.1.50")
+        assert _statuses(result)["mqtt_auth"] == "skip"

+ 115 - 1
backend/tests/unit/services/test_printer_manager.py

@@ -584,12 +584,126 @@ class TestPrinterManager:
             mock_instance.state.connected = False
             MockClient.return_value = mock_instance
 
-            result = await manager.test_connection("192.168.1.100", "00M09A123456789", "12345678")
+            # Shorten the probe budget so the test doesn't burn the full
+            # 8-second production timeout while polling a failing connection.
+            with (
+                patch.object(manager, "PROBE_TIMEOUT_SECONDS", 0.4),
+                patch.object(manager, "PROBE_POLL_INTERVAL_SECONDS", 0.1),
+            ):
+                result = await manager.test_connection("192.168.1.100", "00M09A123456789", "12345678")
 
             assert result["success"] is False
             assert result["state"] is None
             mock_instance.disconnect.assert_called_once()
 
+    @pytest.mark.asyncio
+    async def test_test_connection_polls_and_returns_early_on_connect(self, manager):
+        """#1445: a slow printer that finishes its handshake mid-probe must
+        not be reported as a failure. Previously a fixed 2s sleep meant P1S
+        TLS / CONNACK that took 3-5s got falsely rejected; now we poll and
+        early-return as soon as connected flips True.
+        """
+        import asyncio
+        import time
+
+        with patch("backend.app.services.printer_manager.BambuMQTTClient") as MockClient:
+            mock_instance = MagicMock()
+            mock_instance.state = MagicMock()
+            mock_instance.state.connected = False  # not connected at probe start
+            mock_instance.state.state = "IDLE"
+            mock_instance.state.raw_data = {"device_model": "P1S"}
+            MockClient.return_value = mock_instance
+
+            async def flip_connected_after(delay: float):
+                await asyncio.sleep(delay)
+                mock_instance.state.connected = True
+
+            # Simulates the P1S broker finishing its slow handshake ~0.5s in,
+            # well past the old 2s-or-fail boundary's natural variance.
+            with (
+                patch.object(manager, "PROBE_TIMEOUT_SECONDS", 3.0),
+                patch.object(manager, "PROBE_POLL_INTERVAL_SECONDS", 0.05),
+            ):
+                start = time.monotonic()
+                flip_task = asyncio.create_task(flip_connected_after(0.5))
+                try:
+                    result = await manager.test_connection("192.168.1.100", "00M09A123456789", "12345678")
+                finally:
+                    await flip_task
+                elapsed = time.monotonic() - start
+
+            assert result["success"] is True
+            assert result["state"] == "IDLE"
+            # Early-return guarantee: must come back well before the configured
+            # timeout once connected flips. ~0.5s + one poll interval is plenty.
+            assert elapsed < 1.5, f"probe should have early-returned shortly after 0.5s, took {elapsed:.2f}s"
+
+    @pytest.mark.asyncio
+    async def test_test_connection_disconnect_runs_off_loop(self, manager):
+        """#1445: the root cause of the "Docker container hangs" symptom was
+        `client.disconnect()` running on the asyncio thread — paho's
+        `loop_stop()` does a thread-join that blocks until its network
+        thread exits, which on a slow P1S TLS handshake could take many
+        seconds. This test pins the off-loop teardown so a regression that
+        reintroduces sync disconnect breaks CI immediately.
+        """
+        import asyncio
+        import threading
+        import time
+
+        with patch("backend.app.services.printer_manager.BambuMQTTClient") as MockClient:
+            asyncio_thread_id = threading.get_ident()
+            disconnect_thread_ids: list[int] = []
+            disconnect_blocked_for: list[float] = []
+
+            def slow_blocking_disconnect():
+                # Mirrors paho.Client.loop_stop()'s thread-join semantics —
+                # if this runs on the asyncio thread the event loop stalls.
+                disconnect_thread_ids.append(threading.get_ident())
+                start = time.monotonic()
+                time.sleep(0.4)
+                disconnect_blocked_for.append(time.monotonic() - start)
+
+            mock_instance = MagicMock()
+            mock_instance.state = MagicMock()
+            mock_instance.state.connected = True
+            mock_instance.state.state = "IDLE"
+            mock_instance.state.raw_data = {"device_model": "P1S"}
+            mock_instance.disconnect = slow_blocking_disconnect
+            MockClient.return_value = mock_instance
+
+            # Another coroutine must keep making progress while disconnect()
+            # runs — proves the event loop was not blocked.
+            event_loop_alive_ticks = 0
+
+            async def heartbeat():
+                nonlocal event_loop_alive_ticks
+                while True:
+                    await asyncio.sleep(0.05)
+                    event_loop_alive_ticks += 1
+
+            heartbeat_task = asyncio.create_task(heartbeat())
+            try:
+                await manager.test_connection("192.168.1.100", "00M09A123456789", "12345678")
+            finally:
+                heartbeat_task.cancel()
+                try:
+                    await heartbeat_task
+                except asyncio.CancelledError:
+                    pass
+
+            # disconnect ran on a different thread than asyncio's
+            assert disconnect_thread_ids, "disconnect was never called"
+            assert disconnect_thread_ids[0] != asyncio_thread_id, (
+                "disconnect ran on the asyncio thread — this blocks the event loop (#1445)"
+            )
+            # Heartbeat made progress while the 0.4s disconnect was blocking
+            # the worker thread (proves the loop wasn't stalled).
+            assert event_loop_alive_ticks >= 3, (
+                f"event loop appears to have stalled during disconnect "
+                f"(only {event_loop_alive_ticks} heartbeats; expected >=3)"
+            )
+
     # ========================================================================
     # Tests for current print user tracking (Issue #206)
     # ========================================================================

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

@@ -0,0 +1,328 @@
+"""Tests for the per-slice 3MF input normalisation helpers."""
+
+from __future__ import annotations
+
+import json
+import zipfile
+from io import BytesIO
+
+from backend.app.services.slicer_3mf_convert import (
+    count_plates_in_3mf,
+    extract_source_printer_model,
+    merge_plate_3mfs,
+    substitute_unused_plate_filaments,
+)
+
+
+def _make_3mf(entries: dict[str, bytes]) -> bytes:
+    buf = BytesIO()
+    with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+        for name, payload in entries.items():
+            zf.writestr(name, payload)
+    return buf.getvalue()
+
+
+class TestExtractSourcePrinterModel:
+    def test_returns_canonical_short_code_for_x1c(self):
+        # Raw field is the long display name; we need the short code so
+        # is_dual_nozzle_model() matches against the model registry.
+        cfg = json.dumps({"printer_model": "Bambu Lab X1 Carbon", "other": "field"}).encode()
+        zip_bytes = _make_3mf({"Metadata/project_settings.config": cfg})
+        assert extract_source_printer_model(zip_bytes) == "X1C"
+
+    def test_returns_canonical_short_code_for_h2d(self):
+        cfg = json.dumps({"printer_model": "Bambu Lab H2D"}).encode()
+        zip_bytes = _make_3mf({"Metadata/project_settings.config": cfg})
+        assert extract_source_printer_model(zip_bytes) == "H2D"
+
+    def test_dual_nozzle_check_works_on_extracted_code(self):
+        """The whole point of canonicalising in this helper: the result
+        must feed straight into is_dual_nozzle_model() without further
+        normalisation."""
+        from backend.app.utils.printer_models import is_dual_nozzle_model
+
+        h2d = _make_3mf({"Metadata/project_settings.config": json.dumps({"printer_model": "Bambu Lab H2D"}).encode()})
+        x1c = _make_3mf(
+            {"Metadata/project_settings.config": json.dumps({"printer_model": "Bambu Lab X1 Carbon"}).encode()}
+        )
+        assert is_dual_nozzle_model(extract_source_printer_model(h2d)) is True
+        assert is_dual_nozzle_model(extract_source_printer_model(x1c)) is False
+
+    def test_returns_none_when_field_missing(self):
+        cfg = json.dumps({"other": "field"}).encode()
+        zip_bytes = _make_3mf({"Metadata/project_settings.config": cfg})
+        assert extract_source_printer_model(zip_bytes) is None
+
+    def test_returns_none_when_field_empty(self):
+        cfg = json.dumps({"printer_model": ""}).encode()
+        zip_bytes = _make_3mf({"Metadata/project_settings.config": cfg})
+        assert extract_source_printer_model(zip_bytes) is None
+
+    def test_returns_none_when_no_embedded_config(self):
+        zip_bytes = _make_3mf({"Metadata/other.txt": b"hello"})
+        assert extract_source_printer_model(zip_bytes) is None
+
+    def test_returns_none_for_non_zip_bytes(self):
+        assert extract_source_printer_model(b"not a zip") is None
+
+    def test_returns_none_for_malformed_json(self):
+        zip_bytes = _make_3mf({"Metadata/project_settings.config": b"{not json"})
+        assert extract_source_printer_model(zip_bytes) is None
+
+    def test_returns_none_when_config_is_list_not_dict(self):
+        cfg = json.dumps(["not", "a", "dict"]).encode()
+        zip_bytes = _make_3mf({"Metadata/project_settings.config": cfg})
+        assert extract_source_printer_model(zip_bytes) is None
+
+
+class TestCountPlatesIn3mf:
+    def test_counts_plater_id_entries(self):
+        xml = (
+            b'<?xml version="1.0"?>\n<config>\n'
+            b'<plate><metadata key="plater_id" value="1"/></plate>\n'
+            b'<plate><metadata key="plater_id" value="2"/></plate>\n'
+            b'<plate><metadata key="plater_id" value="3"/></plate>\n'
+            b"</config>\n"
+        )
+        zip_bytes = _make_3mf({"Metadata/model_settings.config": xml})
+        assert count_plates_in_3mf(zip_bytes) == 3
+
+    def test_returns_zero_for_no_model_settings(self):
+        zip_bytes = _make_3mf({"3D/3dmodel.model": b"<model/>"})
+        assert count_plates_in_3mf(zip_bytes) == 0
+
+    def test_returns_zero_for_non_zip(self):
+        assert count_plates_in_3mf(b"not a zip") == 0
+
+    def test_returns_zero_when_no_plate_ids(self):
+        zip_bytes = _make_3mf({"Metadata/model_settings.config": b"<config/>"})
+        assert count_plates_in_3mf(zip_bytes) == 0
+
+
+class TestMergePlate3mfs:
+    """Per-plate cross-class loop output → merged multi-plate 3MF. The
+    merge needs to: (1) carry forward the first plate's base metadata
+    (project_settings, model_settings, 3dmodel), (2) overlay each
+    plate's gcode + thumbnails, (3) re-assemble slice_info.config to
+    list every plate."""
+
+    @staticmethod
+    def _single_plate_3mf(plate_num: int, gcode_bytes: bytes, slice_info_block: str | None = None) -> bytes:
+        slice_info = (
+            '<?xml version="1.0" encoding="UTF-8"?>\n<config>\n'
+            '<header><header_item key="X-BBL-Client-Type" value="slicer"/></header>\n'
+            + (slice_info_block or f'<plate><metadata key="index" value="{plate_num}"/></plate>')
+            + "\n</config>\n"
+        ).encode("utf-8")
+        return _make_3mf(
+            {
+                "3D/3dmodel.model": f"<model plate={plate_num}/>".encode(),
+                "Metadata/project_settings.config": b'{"printer_model": "Bambu Lab H2D"}',
+                "Metadata/model_settings.config": b"<config/>",
+                "Metadata/slice_info.config": slice_info,
+                f"Metadata/plate_{plate_num}.gcode": gcode_bytes,
+                f"Metadata/plate_{plate_num}.gcode.md5": b"d41d8cd98f00b204e9800998ecf8427e",
+                f"Metadata/plate_{plate_num}.json": b"{}",
+                f"Metadata/plate_{plate_num}.png": b"PLATE_PNG",
+                f"Metadata/plate_{plate_num}_small.png": b"SMALL",
+                f"Metadata/top_{plate_num}.png": b"TOP",
+                f"Metadata/pick_{plate_num}.png": b"PICK",
+            }
+        )
+
+    def test_empty_input_raises(self):
+        import pytest as _pytest
+
+        with _pytest.raises(ValueError):
+            merge_plate_3mfs([])
+
+    def test_single_plate_is_passthrough(self):
+        only = self._single_plate_3mf(1, b"GCODE-1")
+        assert merge_plate_3mfs([(1, only)]) == only
+
+    def test_overlays_per_plate_artifacts(self):
+        p1 = self._single_plate_3mf(1, b"GCODE-PLATE-1")
+        p2 = self._single_plate_3mf(2, b"GCODE-PLATE-2")
+        p3 = self._single_plate_3mf(3, b"GCODE-PLATE-3")
+        merged = merge_plate_3mfs([(1, p1), (2, p2), (3, p3)])
+
+        with zipfile.ZipFile(BytesIO(merged), "r") as zf:
+            assert zf.read("Metadata/plate_1.gcode") == b"GCODE-PLATE-1"
+            assert zf.read("Metadata/plate_2.gcode") == b"GCODE-PLATE-2"
+            assert zf.read("Metadata/plate_3.gcode") == b"GCODE-PLATE-3"
+            # Per-plate thumbnails and json overlaid too.
+            assert zf.read("Metadata/plate_2.png") == b"PLATE_PNG"
+            assert zf.read("Metadata/plate_3_small.png") == b"SMALL"
+            # Base 3MF's project_settings.config carried forward unchanged.
+            assert zf.read("Metadata/project_settings.config") == p1_project(p1)
+
+    def test_combined_slice_info_lists_every_plate(self):
+        p1 = self._single_plate_3mf(1, b"G1", slice_info_block='<plate><metadata key="index" value="1"/></plate>')
+        p2 = self._single_plate_3mf(2, b"G2", slice_info_block='<plate><metadata key="index" value="2"/></plate>')
+        merged = merge_plate_3mfs([(1, p1), (2, p2)])
+
+        with zipfile.ZipFile(BytesIO(merged), "r") as zf:
+            info = zf.read("Metadata/slice_info.config").decode("utf-8")
+        # Both plate blocks present.
+        assert 'value="1"' in info
+        assert 'value="2"' in info
+        # Two <plate> blocks total (we don't include the source's stale
+        # one from before slicing).
+        assert info.count("<plate>") == 2
+
+    def test_falls_back_to_source_thumbnails_when_sliced_outputs_lack_them(self):
+        """BS CLI with --arrange generates fresh per-plate gcode but
+        doesn't always write a fresh ``plate_N.png``. The merger's
+        ``source_3mf_bytes`` fallback should fill the gap from the
+        source 3MF's original per-plate render so the archive's per-
+        plate previews aren't blank."""
+
+        # Sliced outputs that lack plate_N.png entries entirely (only
+        # gcode + json + md5 — the thumbnail slot is empty).
+        def _no_thumb_3mf(plate_num: int) -> bytes:
+            return _make_3mf(
+                {
+                    "3D/3dmodel.model": b"<model/>",
+                    "Metadata/project_settings.config": b"{}",
+                    "Metadata/model_settings.config": b"<config/>",
+                    "Metadata/slice_info.config": (
+                        '<?xml version="1.0"?>\n<config>'
+                        f'<plate><metadata key="index" value="{plate_num}"/></plate>'
+                        "</config>"
+                    ).encode(),
+                    f"Metadata/plate_{plate_num}.gcode": f"G{plate_num}".encode(),
+                }
+            )
+
+        source = _make_3mf(
+            {
+                "3D/3dmodel.model": b"<model/>",
+                "Metadata/plate_1.png": b"SRC_PNG_1",
+                "Metadata/plate_1_small.png": b"SRC_SMALL_1",
+                "Metadata/plate_2.png": b"SRC_PNG_2",
+                "Metadata/plate_2_small.png": b"SRC_SMALL_2",
+            }
+        )
+
+        merged = merge_plate_3mfs(
+            [(1, _no_thumb_3mf(1)), (2, _no_thumb_3mf(2))],
+            source_3mf_bytes=source,
+        )
+        with zipfile.ZipFile(BytesIO(merged), "r") as zf:
+            assert zf.read("Metadata/plate_1.png") == b"SRC_PNG_1"
+            assert zf.read("Metadata/plate_1_small.png") == b"SRC_SMALL_1"
+            assert zf.read("Metadata/plate_2.png") == b"SRC_PNG_2"
+            assert zf.read("Metadata/plate_2_small.png") == b"SRC_SMALL_2"
+
+    def test_source_fallback_does_not_overwrite_fresh_sliced_thumbnails(self):
+        """If a sliced output DID write its own ``plate_N.png`` (same-class
+        slice / older BS that renders even with arrange), keep it — the
+        sliced render reflects the actual H2D layout; the source fallback
+        only fills gaps."""
+        p1 = self._single_plate_3mf(1, b"G1")  # has plate_1.png = PLATE_PNG
+        p2 = self._single_plate_3mf(2, b"G2")  # has plate_2.png = PLATE_PNG
+        source = _make_3mf(
+            {
+                "Metadata/plate_1.png": b"SRC_PNG_1",
+                "Metadata/plate_2.png": b"SRC_PNG_2",
+            }
+        )
+        merged = merge_plate_3mfs([(1, p1), (2, p2)], source_3mf_bytes=source)
+        with zipfile.ZipFile(BytesIO(merged), "r") as zf:
+            # Sliced output's thumbnails win.
+            assert zf.read("Metadata/plate_1.png") == b"PLATE_PNG"
+            assert zf.read("Metadata/plate_2.png") == b"PLATE_PNG"
+
+    def test_unsorted_input_is_sorted_by_plate_number(self):
+        p1 = self._single_plate_3mf(1, b"G1")
+        p2 = self._single_plate_3mf(2, b"G2")
+        # Pass them out of order; the merger should still place plate 2's
+        # artifacts at plate_2.* and plate 1's at plate_1.*.
+        merged = merge_plate_3mfs([(2, p2), (1, p1)])
+        with zipfile.ZipFile(BytesIO(merged), "r") as zf:
+            assert zf.read("Metadata/plate_1.gcode") == b"G1"
+            assert zf.read("Metadata/plate_2.gcode") == b"G2"
+
+
+def p1_project(zip_bytes: bytes) -> bytes:
+    """Helper for the merge test — pulls plate-1's project_settings.config out
+    of a fixture so the test's assertion shows the actual reference value
+    rather than hard-coding the literal."""
+    with zipfile.ZipFile(BytesIO(zip_bytes), "r") as zf:
+        return zf.read("Metadata/project_settings.config")
+
+
+class TestSubstituteUnusedPlateFilaments:
+    """Slot 1 carries the used filament; unused-slot entries are
+    overwritten with slot 1 so BambuStudio's filament-temp validator
+    doesn't trip on heterogeneous loaded filaments that the plate's
+    G-code never actually touches."""
+
+    @staticmethod
+    def _model_settings_xml(per_plate_extruders: list[tuple[int, list[int]]]) -> bytes:
+        """Build a minimal model_settings.config mapping each plate to a set
+        of extruder/slot numbers via per-object extruder metadata. Mirrors
+        the schema ``extract_plate_extruder_set_from_3mf`` parses:
+        - top-level ``<object id=N>`` with ``<metadata key="extruder" .../>``
+        - per-plate ``<plate>`` listing the object ids it contains.
+        ``per_plate_extruders`` is a list of (plate_id, [extruder_ids]).
+        Object ids are auto-numbered globally so plates can reference them.
+        """
+        objects = []
+        plates = []
+        oid = 1
+        for plate_id, exts in per_plate_extruders:
+            plate_obj_refs = []
+            for ext in exts:
+                objects.append(f'<object id="{oid}"><metadata key="extruder" value="{ext}"/></object>')
+                plate_obj_refs.append(
+                    f'<model_instance><metadata key="object_id" value="{oid}"/>'
+                    f'<metadata key="instance_id" value="0"/>'
+                    f'<metadata key="identify_id" value="{oid}"/></model_instance>'
+                )
+                oid += 1
+            plates.append(
+                f'<plate><metadata key="plater_id" value="{plate_id}"/>' + "".join(plate_obj_refs) + "</plate>"
+            )
+        xml = (
+            '<?xml version="1.0" encoding="UTF-8"?>\n'
+            "<config>\n" + "\n".join(objects) + "\n" + "\n".join(plates) + "\n" + "</config>"
+        )
+        return xml.encode("utf-8")
+
+    def test_substitutes_unused_slot_with_slot_1(self):
+        # Plate 1 uses slot 1 only; slots 2 and 3 are loaded but unused.
+        zip_bytes = _make_3mf({"Metadata/model_settings.config": self._model_settings_xml([(1, [1])])})
+        items = ["pla_basic.json", "abs_loaded_but_unused.json", "abs_again_unused.json"]
+        result = substitute_unused_plate_filaments(zip_bytes, plate_id=1, items=items)
+        assert result == ["pla_basic.json", "pla_basic.json", "pla_basic.json"]
+
+    def test_no_substitution_when_all_used(self):
+        # Multi-colour plate where every slot is used: leave the user's selections alone.
+        zip_bytes = _make_3mf({"Metadata/model_settings.config": self._model_settings_xml([(1, [1, 2, 3])])})
+        items = ["pla_white.json", "pla_red.json", "pla_blue.json"]
+        result = substitute_unused_plate_filaments(zip_bytes, plate_id=1, items=items)
+        assert result == ["pla_white.json", "pla_red.json", "pla_blue.json"]
+
+    def test_no_op_when_plate_id_is_none(self):
+        items = ["a.json", "b.json", "c.json"]
+        result = substitute_unused_plate_filaments(b"any bytes", plate_id=None, items=items)
+        assert result == items
+
+    def test_no_op_when_single_filament(self):
+        result = substitute_unused_plate_filaments(b"any bytes", plate_id=1, items=["only.json"])
+        assert result == ["only.json"]
+        result = substitute_unused_plate_filaments(b"any bytes", plate_id=1, items=[])
+        assert result == []
+
+    def test_no_op_when_source_not_zip(self):
+        items = ["a.json", "b.json"]
+        result = substitute_unused_plate_filaments(b"not a zip", plate_id=1, items=items)
+        assert result == items
+
+    def test_no_op_when_no_model_settings(self):
+        # Empty parse result is treated as "every slot used" (fail-open default).
+        zip_bytes = _make_3mf({"3D/3dmodel.model": b"<model/>"})
+        items = ["a.json", "b.json", "c.json"]
+        result = substitute_unused_plate_filaments(zip_bytes, plate_id=1, items=items)
+        assert result == items

+ 84 - 0
backend/tests/unit/services/test_slicer_api.py

@@ -253,6 +253,62 @@ class TestSliceWithProfiles:
         assert b'name="exportType"' in body
         assert b"3mf" in body
 
+    @pytest.mark.asyncio
+    async def test_arrange_true_emits_form_field(self):
+        """#1493: cross-class re-slices set arrange=True so BambuStudio
+        repositions objects for the target bed. The flag must arrive as
+        a multipart form field the sidecar's SlicingSettings parses."""
+        captured: dict = {}
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            captured["body"] = request.content
+            return httpx.Response(
+                status_code=200,
+                content=b"3MF",
+                headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
+            )
+
+        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
+        await service.slice_with_profiles(
+            model_bytes=b"x",
+            model_filename="Cube.3mf",
+            printer_profile_json="{}",
+            process_profile_json="{}",
+            filament_profile_jsons=["{}"],
+            arrange=True,
+        )
+
+        body = captured["body"]
+        assert b'name="arrange"' in body
+        # Sidecar treats non-empty strings as truthy, so "true" suffices.
+        assert b"true" in body
+
+    @pytest.mark.asyncio
+    async def test_arrange_false_omits_form_field(self):
+        """Default arrange=False keeps the wire payload identical to the
+        pre-#1493 shape — no spurious form field that downstream sidecar
+        versions might mis-parse."""
+        captured: dict = {}
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            captured["body"] = request.content
+            return httpx.Response(
+                status_code=200,
+                content=b"3MF",
+                headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
+            )
+
+        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
+        await service.slice_with_profiles(
+            model_bytes=b"x",
+            model_filename="Cube.3mf",
+            printer_profile_json="{}",
+            process_profile_json="{}",
+            filament_profile_jsons=["{}"],
+        )
+
+        assert b'name="arrange"' not in captured["body"]
+
     @pytest.mark.asyncio
     async def test_multi_filament_sends_one_part_per_profile(self):
         # Multi-color slicing requires N filament profiles, in plate-slot
@@ -683,6 +739,34 @@ class TestSliceWithBundle:
         # Bundle id round-trips on the wire.
         assert b"2bd8722dd20a837e" in body
 
+    @pytest.mark.asyncio
+    async def test_arrange_true_emits_form_field(self):
+        """#1493: bundle dispatch also forwards arrange=True so cross-class
+        slices via .bbscfg bundles get the same BS auto-arrange behaviour
+        as the preset path."""
+        captured: dict = {}
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            captured["body"] = request.content
+            return httpx.Response(
+                status_code=200,
+                content=b"3MF",
+                headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
+            )
+
+        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
+        await service.slice_with_bundle(
+            model_bytes=b"x",
+            model_filename="Cube.3mf",
+            bundle_id="abc",
+            printer_name="p",
+            process_name="pr",
+            filament_names=["f"],
+            arrange=True,
+        )
+
+        assert b'name="arrange"' in captured["body"]
+
     @pytest.mark.asyncio
     async def test_404_unknown_preset_maps_to_input_error(self):
         # Sidecar returns 404 when bundle exists but preset name doesn't.

+ 97 - 3
backend/tests/unit/services/test_spool_assignment_notifications.py

@@ -18,9 +18,18 @@ class _FakeAssignmentsResult:
 
 
 class _FakeSession:
-    def __init__(self, printer_name: str, assignments: list[SimpleNamespace]):
+    """Fake DB session that returns legacy vs. Spoolman assignment rows based
+    on which table the SELECT targets, so tests can exercise either mode."""
+
+    def __init__(
+        self,
+        printer_name: str,
+        legacy: list[SimpleNamespace] | None = None,
+        spoolman: list[SimpleNamespace] | None = None,
+    ):
         self._printer = SimpleNamespace(name=printer_name)
-        self._assignments = assignments
+        self._legacy = legacy or []
+        self._spoolman = spoolman or []
 
     async def __aenter__(self):
         return self
@@ -32,7 +41,10 @@ class _FakeSession:
         return self._printer
 
     async def execute(self, statement):
-        return _FakeAssignmentsResult(self._assignments)
+        table = statement.get_final_froms()[0].name
+        if table == "spoolman_slot_assignments":
+            return _FakeAssignmentsResult(self._spoolman)
+        return _FakeAssignmentsResult(self._legacy)
 
 
 @pytest.mark.asyncio
@@ -75,3 +87,85 @@ async def test_missing_assignment_broadcasts_websocket_event_and_push_notificati
     assert notify_kwargs["printer_id"] == 1
     assert notify_kwargs["printer_name"] == "Printer A"
     assert notify_kwargs["missing_slots"] == [{"slot": "A2", "profile": "Unknown", "color": "Unknown"}]
+
+
+def _patches(session):
+    """Common patch set: the fake session + stubbed printer state / emitters."""
+    return (
+        patch(
+            "backend.app.services.spool_assignment_notifications.async_session",
+            return_value=session,
+        ),
+        patch("backend.app.services.spool_assignment_notifications.printer_manager.get_status", return_value=None),
+        patch(
+            "backend.app.services.spool_assignment_notifications.ws_manager.send_missing_spool_assignment",
+            new_callable=AsyncMock,
+        ),
+        patch(
+            "backend.app.services.spool_assignment_notifications.notification_service.on_print_missing_spool_assignment",
+            new_callable=AsyncMock,
+        ),
+    )
+
+
+@pytest.mark.asyncio
+async def test_spoolman_only_assignment_suppresses_notification():
+    """#1473 — trays bound only via spoolman_slot_assignments must NOT be
+    flagged missing (the legacy spool_assignment table is empty in Spoolman
+    mode, so checking it alone fired a false positive on every print)."""
+    logger = logging.getLogger(__name__)
+    data = {"ams_mapping": [0, 1], "raw_data": {}}  # print uses A1 + A2
+
+    # Both used trays bound via Spoolman; legacy table empty.
+    session = _FakeSession(
+        "Printer A",
+        legacy=[],
+        spoolman=[SimpleNamespace(ams_id=0, tray_id=0), SimpleNamespace(ams_id=0, tray_id=1)],
+    )
+    p_session, p_status, p_ws, p_notify = _patches(session)
+    with p_session, p_status, p_ws as mock_ws, p_notify as mock_notify:
+        await notify_missing_spool_assignments_on_print_start(1, data, logger)
+
+    mock_ws.assert_not_awaited()
+    mock_notify.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_spoolman_partial_coverage_flags_only_uncovered_tray():
+    """A Spoolman assignment for A1 only, with a print using A1 + A2, flags
+    A2 alone."""
+    logger = logging.getLogger(__name__)
+    data = {"ams_mapping": [0, 1], "raw_data": {}}
+
+    session = _FakeSession(
+        "Printer A",
+        legacy=[],
+        spoolman=[SimpleNamespace(ams_id=0, tray_id=0)],  # A1 only
+    )
+    p_session, p_status, p_ws, p_notify = _patches(session)
+    with p_session, p_status, p_ws as mock_ws, p_notify as mock_notify:
+        await notify_missing_spool_assignments_on_print_start(1, data, logger)
+
+    mock_ws.assert_awaited_once()
+    assert mock_ws.await_args.kwargs["missing_slots"] == [{"slot": "A2", "profile": "Unknown", "color": "Unknown"}]
+    mock_notify.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_mixed_mode_union_covers_all_used_trays():
+    """A1 bound in the legacy table, A2 bound in spoolman_slot_assignments —
+    the union covers both used trays, so no notification fires."""
+    logger = logging.getLogger(__name__)
+    data = {"ams_mapping": [0, 1], "raw_data": {}}
+
+    session = _FakeSession(
+        "Printer A",
+        legacy=[SimpleNamespace(ams_id=0, tray_id=0)],  # A1
+        spoolman=[SimpleNamespace(ams_id=0, tray_id=1)],  # A2
+    )
+    p_session, p_status, p_ws, p_notify = _patches(session)
+    with p_session, p_status, p_ws as mock_ws, p_notify as mock_notify:
+        await notify_missing_spool_assignments_on_print_start(1, data, logger)
+
+    mock_ws.assert_not_awaited()
+    mock_notify.assert_not_awaited()

+ 67 - 0
backend/tests/unit/services/test_spoolman_tracking.py

@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
 import pytest
 
 from backend.app.services.spoolman_tracking import (
+    _apply_spool_colors_to_archive,
     _get_fallback_spool_tag,
     _global_tray_id_to_ams_slot,
     _hash_serial_to_hex32,
@@ -300,3 +301,69 @@ class TestStorePrintData:
 
         # Tracking row was inserted — the fix is working.
         db.add.assert_called_once()
+
+
+class TestApplySpoolColorsToArchive:
+    """`_apply_spool_colors_to_archive` stamps the archive's filament_color
+    from the matched Spoolman spools (#1494) — the Spoolman-mode mirror of
+    the built-in inventory rewrite in usage_tracker."""
+
+    def _make_db(self, archive):
+        db = AsyncMock()
+        db.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=archive)))
+        return db
+
+    @pytest.mark.asyncio
+    async def test_rewrites_color_from_spoolman_spool(self):
+        """The #1494 case: 3MF said #161616, the Spoolman spool is 000000."""
+        archive = MagicMock()
+        archive.filament_color = "#161616"
+        db = self._make_db(archive)
+
+        await _apply_spool_colors_to_archive(
+            db,
+            archive_id=10,
+            filament_usage=[{"slot_id": 1, "used_g": 15.9}],
+            slot_colors={1: "000000"},
+        )
+
+        assert archive.filament_color == "#000000"
+        db.commit.assert_awaited()
+
+    @pytest.mark.asyncio
+    async def test_empty_slot_colors_is_noop(self):
+        """No resolved spool colours — never touches the DB."""
+        db = self._make_db(MagicMock())
+        await _apply_spool_colors_to_archive(
+            db, archive_id=10, filament_usage=[{"slot_id": 1, "used_g": 15.9}], slot_colors={}
+        )
+        db.execute.assert_not_awaited()
+        db.commit.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_partial_match_leaves_archive_untouched(self):
+        """Slot 2 used but unresolved — keep the 3MF colour, don't load the archive."""
+        db = self._make_db(MagicMock())
+        await _apply_spool_colors_to_archive(
+            db,
+            archive_id=10,
+            filament_usage=[
+                {"slot_id": 1, "used_g": 10.0},
+                {"slot_id": 2, "used_g": 20.0},
+            ],
+            slot_colors={1: "000000"},
+        )
+        db.execute.assert_not_awaited()
+        db.commit.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_missing_archive_does_not_crash(self):
+        """Archive row gone (deleted between completion and reporting)."""
+        db = self._make_db(None)
+        await _apply_spool_colors_to_archive(
+            db,
+            archive_id=10,
+            filament_usage=[{"slot_id": 1, "used_g": 15.9}],
+            slot_colors={1: "000000"},
+        )
+        db.commit.assert_not_awaited()

+ 195 - 1
backend/tests/unit/services/test_usage_tracker.py

@@ -12,13 +12,15 @@ import pytest
 from backend.app.services.usage_tracker import (
     PrintSession,
     _active_sessions,
+    _archive_colors_from_spools,
+    _spool_color_to_hex,
     _track_from_3mf,
     on_print_complete,
     on_print_start,
 )
 
 
-def _make_spool(*, id=1, label_weight=1000, weight_used=0, tag_uid=None, tray_uuid=None):
+def _make_spool(*, id=1, label_weight=1000, weight_used=0, tag_uid=None, tray_uuid=None, rgba=None):
     """Create a mock Spool object."""
     spool = MagicMock()
     spool.id = id
@@ -29,6 +31,7 @@ def _make_spool(*, id=1, label_weight=1000, weight_used=0, tag_uid=None, tray_uu
     spool.last_used = None
     spool.cost_per_kg = None
     spool.material = "PLA"
+    spool.rgba = rgba
     return spool
 
 
@@ -766,3 +769,194 @@ class TestSpoolAssignmentSnapshot:
         assert results[0]["weight_used"] == 14.2
         # Spool weight should be updated: 50 + 14.2 = 64.2
         assert spool.weight_used == 64.2
+
+
+class TestSpoolColorToHex:
+    """`_spool_color_to_hex` normalises Spool.rgba (RRGGBBAA, no #) to #RRGGBB."""
+
+    def test_strips_alpha_and_adds_hash(self):
+        assert _spool_color_to_hex("000000FF") == "#000000"
+        assert _spool_color_to_hex("EC984CFF") == "#EC984C"
+
+    def test_uppercases(self):
+        assert _spool_color_to_hex("ec984cff") == "#EC984C"
+
+    def test_accepts_six_char_value(self):
+        """A value with no alpha is still valid."""
+        assert _spool_color_to_hex("161616") == "#161616"
+
+    def test_tolerates_leading_hash(self):
+        assert _spool_color_to_hex("#000000FF") == "#000000"
+
+    def test_none_and_too_short_return_none(self):
+        """Missing / malformed colour falls back to the 3MF value."""
+        assert _spool_color_to_hex(None) is None
+        assert _spool_color_to_hex("") is None
+        assert _spool_color_to_hex("FFF") is None
+
+
+class TestArchiveColorsFromSpools:
+    """`_archive_colors_from_spools` rebuilds an archive's filament_color from
+    the inventory spools that fed the print (#1494). All-or-nothing: a partial
+    match returns None so the 3MF colour is left intact."""
+
+    def test_single_slot_matched(self):
+        """The #1494 case: one used slot, matched to a #000000 spool."""
+        usage = [{"slot_id": 1, "used_g": 15.9, "color": "#161616"}]
+        results = [{"slot_id": 1, "color": "#000000"}]
+        assert _archive_colors_from_spools(usage, results) == ["#000000"]
+
+    def test_multi_slot_all_matched_keeps_slot_order(self):
+        usage = [
+            {"slot_id": 1, "used_g": 10.0, "color": "#111111"},
+            {"slot_id": 2, "used_g": 20.0, "color": "#222222"},
+        ]
+        # results deliberately out of slot order — output must be slot-ordered
+        results = [
+            {"slot_id": 2, "color": "#00FF00"},
+            {"slot_id": 1, "color": "#FF0000"},
+        ]
+        assert _archive_colors_from_spools(usage, results) == ["#FF0000", "#00FF00"]
+
+    def test_duplicate_colors_deduplicated(self):
+        """Two slots of the same spool colour collapse to one entry, as the
+        3MF-derived path also de-duplicates."""
+        usage = [
+            {"slot_id": 1, "used_g": 10.0, "color": "#111111"},
+            {"slot_id": 2, "used_g": 20.0, "color": "#222222"},
+        ]
+        results = [
+            {"slot_id": 1, "color": "#000000"},
+            {"slot_id": 2, "color": "#000000"},
+        ]
+        assert _archive_colors_from_spools(usage, results) == ["#000000"]
+
+    def test_partial_match_returns_none(self):
+        """Slot 2 was used but never matched to a spool — leave the 3MF colour
+        untouched rather than dropping slot 2 from the archive."""
+        usage = [
+            {"slot_id": 1, "used_g": 10.0, "color": "#111111"},
+            {"slot_id": 2, "used_g": 20.0, "color": "#222222"},
+        ]
+        results = [{"slot_id": 1, "color": "#000000"}]
+        assert _archive_colors_from_spools(usage, results) is None
+
+    def test_matched_spool_without_color_returns_none(self):
+        """A spool with no rgba (color None) does not count as matched."""
+        usage = [{"slot_id": 1, "used_g": 15.0, "color": "#161616"}]
+        results = [{"slot_id": 1, "color": None}]
+        assert _archive_colors_from_spools(usage, results) is None
+
+    def test_unused_slot_not_required(self):
+        """A slot with zero usage need not be matched."""
+        usage = [
+            {"slot_id": 1, "used_g": 15.0, "color": "#161616"},
+            {"slot_id": 2, "used_g": 0.0, "color": "#888888"},
+        ]
+        results = [{"slot_id": 1, "color": "#000000"}]
+        assert _archive_colors_from_spools(usage, results) == ["#000000"]
+
+    def test_no_used_slots_returns_none(self):
+        assert _archive_colors_from_spools([], []) is None
+
+    def test_ams_fallback_results_excluded(self):
+        """AMS remain%-delta fallback results carry slot_id=None and must not
+        satisfy the match for a real 3MF slot."""
+        usage = [{"slot_id": 1, "used_g": 15.0, "color": "#161616"}]
+        results = [{"slot_id": None, "color": "#000000"}]
+        assert _archive_colors_from_spools(usage, results) is None
+
+
+class TestArchiveFilamentColorRewrite:
+    """`_track_from_3mf` overwrites the archive's filament_color with the
+    matched inventory spool colour at print completion (#1494)."""
+
+    @pytest.mark.asyncio
+    async def test_archive_color_adopts_spool_color(self):
+        """A print from a #000000 inventory spool whose 3MF says #161616 ends
+        up with the archive showing the spool's #000000."""
+        spool = _make_spool(id=5, label_weight=1000, weight_used=100, rgba="000000FF")
+        assignment = _make_assignment(spool_id=5)
+        archive = MagicMock()
+        archive.file_path = "archives/test.3mf"
+        archive.filament_color = "#161616"  # what archive.py set from the 3MF
+
+        db = AsyncMock()
+        db.execute = AsyncMock(
+            side_effect=[
+                MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
+            ]
+        )
+
+        pm = _make_printer_manager(_make_printer_state([], tray_now=0))
+        filament_usage = [{"slot_id": 1, "used_g": 25.5, "type": "PETG", "color": "#161616"}]
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
+        ):
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=10,
+                status="completed",
+                print_name="test_print",
+                handled_trays=set(),
+                printer_manager=pm,
+                db=db,
+            )
+
+        assert len(results) == 1
+        assert results[0]["color"] == "#000000"
+        assert results[0]["slot_id"] == 1
+        # The archive colour was rewritten from the slicer's #161616 to the
+        # inventory spool's #000000.
+        assert archive.filament_color == "#000000"
+
+    @pytest.mark.asyncio
+    async def test_archive_color_untouched_when_spool_has_no_color(self):
+        """A spool with no rgba leaves the 3MF colour in place."""
+        spool = _make_spool(id=5, label_weight=1000, weight_used=100, rgba=None)
+        assignment = _make_assignment(spool_id=5)
+        archive = MagicMock()
+        archive.file_path = "archives/test.3mf"
+        archive.filament_color = "#161616"
+
+        db = AsyncMock()
+        db.execute = AsyncMock(
+            side_effect=[
+                MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
+            ]
+        )
+
+        pm = _make_printer_manager(_make_printer_state([], tray_now=0))
+        filament_usage = [{"slot_id": 1, "used_g": 25.5, "type": "PETG", "color": "#161616"}]
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
+        ):
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            await _track_from_3mf(
+                printer_id=1,
+                archive_id=10,
+                status="completed",
+                print_name="test_print",
+                handled_trays=set(),
+                printer_manager=pm,
+                db=db,
+            )
+
+        assert archive.filament_color == "#161616"

+ 167 - 0
backend/tests/unit/services/test_vp_diagnostic.py

@@ -0,0 +1,167 @@
+"""Unit tests for the virtual printer setup diagnostic."""
+
+import tempfile
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.services.virtual_printer.certificate import CertificateService
+from backend.app.services.virtual_printer.diagnostic import run_vp_diagnostic
+
+_DIAG = "backend.app.services.virtual_printer.diagnostic._check_port"
+_FIND_IFACE = "backend.app.services.network_utils.find_interface_for_ip"
+
+
+def _vp(**overrides):
+    """A virtual-printer DB row stand-in with sensible healthy defaults."""
+    base = {
+        "id": 1,
+        "name": "Test VP",
+        "mode": "immediate",
+        "enabled": True,
+        "bind_ip": "192.168.1.50",
+        "access_code": "12345678",
+        "target_printer_id": None,
+    }
+    base.update(overrides)
+    return SimpleNamespace(**base)
+
+
+class _FakeInstance:
+    """Minimal VirtualPrinterInstance stand-in for the diagnostic."""
+
+    def __init__(self, running=True, cert_exists=True, proxy_status=None):
+        self.is_running = running
+        self._cert_exists = cert_exists
+        self._proxy_status = proxy_status
+
+    @property
+    def cert_path(self):
+        return SimpleNamespace(exists=lambda: self._cert_exists)
+
+    def get_status(self):
+        return {"proxy": self._proxy_status} if self._proxy_status is not None else {}
+
+
+def _checks(result):
+    return {c.id: c.status for c in result.checks}
+
+
+class TestRunVpDiagnostic:
+    @pytest.mark.asyncio
+    async def test_disabled_vp_reports_problems(self):
+        """A disabled VP fails the 'enabled' check; running/port checks skip."""
+        result = await run_vp_diagnostic(_vp(enabled=False, bind_ip=None, access_code=None), None)
+        c = _checks(result)
+        assert result.overall == "problems"
+        assert c["enabled"] == "fail"
+        assert c["running"] == "skip"
+        assert c["port_ftps"] == c["port_mqtt"] == c["port_bind"] == "skip"
+        assert c["certificate"] == "skip"
+
+    @pytest.mark.asyncio
+    async def test_running_server_vp_all_pass(self):
+        """Enabled + running + every port listening + cert present → overall ok."""
+        with (
+            patch(_DIAG, AsyncMock(return_value=True)),
+            patch(_FIND_IFACE, return_value={"name": "eth0", "ip": "192.168.1.50"}),
+        ):
+            result = await run_vp_diagnostic(_vp(), _FakeInstance())
+        c = _checks(result)
+        assert result.overall == "ok"
+        assert c["enabled"] == "pass"
+        assert c["running"] == "pass"
+        assert c["bind_interface"] == "pass"
+        assert c["access_code"] == "pass"
+        assert c["target_printer"] == "skip"  # not proxy mode
+        assert c["port_ftps"] == c["port_mqtt"] == c["port_bind"] == "pass"
+        assert c["certificate"] == "pass"
+
+    @pytest.mark.asyncio
+    async def test_port_not_listening_is_a_problem(self):
+        """A service object can exist while its socket never bound — the probe
+        is what catches it, so a dead port must surface as a failure."""
+        with (
+            patch(_DIAG, AsyncMock(return_value=False)),
+            patch(_FIND_IFACE, return_value={"name": "eth0", "ip": "192.168.1.50"}),
+        ):
+            result = await run_vp_diagnostic(_vp(), _FakeInstance())
+        c = _checks(result)
+        assert result.overall == "problems"
+        assert c["port_ftps"] == c["port_mqtt"] == c["port_bind"] == "fail"
+
+    @pytest.mark.asyncio
+    async def test_stale_bind_ip_fails_interface_check(self):
+        """A bind IP that no longer matches any interface fails the check."""
+        with (
+            patch(_DIAG, AsyncMock(return_value=True)),
+            patch(_FIND_IFACE, return_value=None),
+        ):
+            result = await run_vp_diagnostic(_vp(), _FakeInstance())
+        c = _checks(result)
+        assert c["bind_interface"] == "fail"
+        assert result.overall == "problems"
+
+    @pytest.mark.asyncio
+    async def test_missing_access_code_fails_non_proxy(self):
+        with (
+            patch(_DIAG, AsyncMock(return_value=True)),
+            patch(_FIND_IFACE, return_value={"name": "eth0", "ip": "192.168.1.50"}),
+        ):
+            result = await run_vp_diagnostic(_vp(access_code=None), _FakeInstance())
+        assert _checks(result)["access_code"] == "fail"
+
+    @pytest.mark.asyncio
+    async def test_proxy_mode_skips_access_code_and_bind_port(self):
+        """Proxy mode has no access code and runs no bind/detect server."""
+        instance = _FakeInstance(proxy_status={"ftp_port": 3001, "mqtt_port": 3003})
+        with (
+            patch(_DIAG, AsyncMock(return_value=True)),
+            patch(_FIND_IFACE, return_value={"name": "eth0", "ip": "192.168.1.50"}),
+        ):
+            result = await run_vp_diagnostic(_vp(mode="proxy", target_printer_id=7), instance)
+        c = _checks(result)
+        assert c["access_code"] == "skip"
+        assert c["port_bind"] == "skip"
+        assert c["port_ftps"] == "pass"
+        assert c["port_mqtt"] == "pass"
+
+    @pytest.mark.asyncio
+    async def test_proxy_without_target_fails(self):
+        """Proxy mode with no target printer fails the target check."""
+        with (
+            patch(_DIAG, AsyncMock(return_value=True)),
+            patch(_FIND_IFACE, return_value={"name": "eth0", "ip": "192.168.1.50"}),
+        ):
+            result = await run_vp_diagnostic(
+                _vp(mode="proxy", target_printer_id=None, access_code=None), _FakeInstance()
+            )
+        c = _checks(result)
+        assert c["target_printer"] == "fail"
+        assert result.overall == "problems"
+
+
+class TestCaCertificateInfo:
+    def test_get_ca_certificate_info_generates_and_returns_pem(self):
+        """The CA is generated on demand; the returned PEM is the public cert."""
+        with tempfile.TemporaryDirectory() as d:
+            service = CertificateService(cert_dir=Path(d), shared_ca_dir=Path(d))
+            info = service.get_ca_certificate_info()
+        assert info["pem"].startswith("-----BEGIN CERTIFICATE-----")
+        assert "-----END CERTIFICATE-----" in info["pem"]
+        # SHA-256 fingerprint: 32 colon-separated uppercase hex bytes.
+        parts = info["fingerprint_sha256"].split(":")
+        assert len(parts) == 32
+        assert all(len(p) == 2 and p == p.upper() for p in parts)
+        assert info["not_valid_after"]
+
+    def test_ca_certificate_info_is_stable_across_calls(self):
+        """A second call reuses the persisted CA — same fingerprint, no key leak."""
+        with tempfile.TemporaryDirectory() as d:
+            service = CertificateService(cert_dir=Path(d), shared_ca_dir=Path(d))
+            first = service.get_ca_certificate_info()
+            second = service.get_ca_certificate_info()
+        assert first["fingerprint_sha256"] == second["fingerprint_sha256"]
+        assert "PRIVATE KEY" not in first["pem"]

+ 55 - 1
backend/tests/unit/test_camera_stderr_summary.py

@@ -7,7 +7,9 @@ single click produced 555 lines across 30 retries. The helper strips the
 banner so logs stay focused on the real error.
 """
 
-from backend.app.api.routes.camera import _summarize_ffmpeg_stderr
+import asyncio
+
+from backend.app.api.routes.camera import _read_ffmpeg_stderr, _summarize_ffmpeg_stderr
 
 _FAKE_BANNER = """ffmpeg version 7.1.3-0+deb13u1 Copyright (c) 2000-2025 the FFmpeg developers
   built with gcc 14 (Debian 14.2.0-19)
@@ -66,3 +68,55 @@ def test_drops_blank_lines():
 def test_banner_only_returns_empty():
     """If ffmpeg prints only the banner (no errors), the summary should be empty."""
     assert _summarize_ffmpeg_stderr(_FAKE_BANNER) == ""
+
+
+# --- _read_ffmpeg_stderr (#1395) -------------------------------------------
+# A stalled-but-alive ffmpeg (the P2S RTSP failure) never closes stderr, so a
+# read-to-EOF discarded everything it had already printed. _read_ffmpeg_stderr
+# now drains incrementally and must return that buffered output.
+
+
+class _FakeProcess:
+    """Minimal stand-in for asyncio.subprocess.Process — only .stderr is read."""
+
+    def __init__(self, stderr):
+        self.stderr = stderr
+
+
+def _reader_with(data: bytes, *, eof: bool) -> asyncio.StreamReader:
+    reader = asyncio.StreamReader()
+    if data:
+        reader.feed_data(data)
+    if eof:
+        reader.feed_eof()
+    return reader
+
+
+async def test_read_stderr_captures_output_from_a_running_ffmpeg():
+    """The #1395 regression: ffmpeg is alive and has NOT closed stderr (no EOF).
+    The output it already printed must still be returned, not discarded while
+    waiting for an EOF that never arrives."""
+    stderr = _FAKE_BANNER + "[rtsp @ 0x5] Could not find codec parameters\n"
+    proc = _FakeProcess(_reader_with(stderr.encode(), eof=False))
+    result = await _read_ffmpeg_stderr(proc)
+    assert result is not None
+    assert "Could not find codec parameters" in result
+    assert "ffmpeg version" not in result  # banner still stripped
+
+
+async def test_read_stderr_captures_output_from_an_exited_ffmpeg():
+    stderr = _FAKE_BANNER + "Error opening input: Connection refused\n"
+    proc = _FakeProcess(_reader_with(stderr.encode(), eof=True))
+    result = await _read_ffmpeg_stderr(proc)
+    assert result is not None
+    assert "Connection refused" in result
+
+
+async def test_read_stderr_returns_none_when_no_stderr_pipe():
+    assert await _read_ffmpeg_stderr(_FakeProcess(None)) is None
+
+
+async def test_read_stderr_returns_none_for_banner_only_output():
+    """Banner with no actionable lines summarizes to empty -> None."""
+    proc = _FakeProcess(_reader_with(_FAKE_BANNER.encode(), eof=True))
+    assert await _read_ffmpeg_stderr(proc) is None

+ 19 - 4
backend/tests/unit/test_code_quality.py

@@ -218,9 +218,27 @@ class TestModuleImports:
         """Verify all Python modules can be imported without errors.
 
         This catches syntax errors and missing dependencies.
+
+        IMPORTANT: We must NOT ``del sys.modules[name]`` to force a fresh
+        import here. ``backend.app.main`` is a stateful module — re-importing
+        it builds NEW module-level dicts (_timelapse_baselines,
+        _expected_prints, _active_prints, …) and re-runs ``root_logger.
+        addHandler(console_handler)``. Any test that already bound those
+        names via ``from backend.app.main import _timelapse_baselines`` now
+        holds a stale reference, while production code resolves the symbol
+        through the new module instance — they're two different dicts. CI
+        under -n 2 puts test_code_quality.py on the same worker as
+        test_print_start_assigns_printer_id_to_vp_archive.py and
+        test_timelapse_baseline_restart_recovery.py, and those tests see
+        their mock_archive un-mutated / their baseline dict empty even
+        though production logged the mutations went through. Local -n 30
+        spreads the tests across workers and the collision never happens.
+
+        ``importlib.import_module`` already covers the "is this importable"
+        check — it returns the cached module if cached, or runs the import
+        machinery if not. Either way, an import-time error surfaces here.
         """
         import importlib
-        import sys
 
         # Modules to test importing
         modules = [
@@ -235,9 +253,6 @@ class TestModuleImports:
         errors = []
         for module_name in modules:
             try:
-                # Remove from cache first to ensure fresh import
-                if module_name in sys.modules:
-                    del sys.modules[module_name]
                 importlib.import_module(module_name)
             except Exception as e:
                 errors.append(f"{module_name}: {type(e).__name__}: {e}")

+ 291 - 0
backend/tests/unit/test_diagnostic_snapshot.py

@@ -0,0 +1,291 @@
+"""Tests for the diagnostic snapshot helper that aggregates connection,
+virtual-printer, and log-health diagnostics for the support bundle and
+bug-report submission paths (#1506 follow-up).
+
+The helper has three hard requirements:
+
+- Always returns the three top-level keys, even when sections are empty.
+- Fail-soft per probe — a single crash doesn't break the snapshot.
+- Bounded total runtime — concurrent gather caps wall-clock to the slowest probe.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.diagnostic_snapshot import collect_diagnostic_snapshot
+
+
+def _make_db_with_printers_and_vps(printers: list, vps: list):
+    """Stub AsyncSession whose two .execute() calls return printers then VPs."""
+    printers_result = MagicMock()
+    printers_result.scalars.return_value.all.return_value = printers
+    vps_result = MagicMock()
+    vps_result.scalars.return_value.all.return_value = vps
+    db = MagicMock()
+    # Two execute calls — printer query, then VP query (order matches the
+    # helper). side_effect cycles through the queue.
+    db.execute = AsyncMock(side_effect=[printers_result, vps_result])
+    return db
+
+
+@pytest.mark.asyncio
+async def test_snapshot_always_returns_three_top_level_keys_when_empty():
+    """No printers, no VPs — still get the three keys (empty lists, empty
+    log-health). Callers downstream rely on the shape being stable."""
+    db = _make_db_with_printers_and_vps([], [])
+    with patch(
+        "backend.app.services.diagnostic_snapshot._run_log_health",
+        new=AsyncMock(return_value={"findings": []}),
+    ):
+        out = await collect_diagnostic_snapshot(db)
+    assert set(out.keys()) == {"connection_diagnostics", "vp_diagnostics", "log_health"}
+    assert out["connection_diagnostics"] == []
+    assert out["vp_diagnostics"] == []
+    assert out["log_health"] == {"findings": []}
+
+
+@pytest.mark.asyncio
+async def test_snapshot_runs_diagnostic_per_active_printer():
+    """Each active printer gets a connection check; each enabled VP gets a
+    setup check. Result list length matches the input lists."""
+    printers = [
+        SimpleNamespace(id=1, name="P1S", ip_address="192.168.1.10", serial_number="01S00A", access_code="abc123"),
+        SimpleNamespace(id=2, name="X1C", ip_address="192.168.1.11", serial_number="C11Y00", access_code="xyz456"),
+    ]
+    vps = [SimpleNamespace(id=10, name="VP-1")]
+    db = _make_db_with_printers_and_vps(printers, vps)
+
+    fake_conn = SimpleNamespace(model_dump=lambda: {"checks": []})
+    fake_vp = SimpleNamespace(model_dump=lambda: {"checks": []})
+
+    with (
+        patch(
+            "backend.app.services.printer_diagnostic.run_connection_diagnostic",
+            new=AsyncMock(return_value=fake_conn),
+        ),
+        patch(
+            "backend.app.services.virtual_printer.virtual_printer_manager.get_instance",
+            return_value=None,
+        ),
+        patch(
+            "backend.app.services.virtual_printer.diagnostic.run_vp_diagnostic",
+            new=AsyncMock(return_value=fake_vp),
+        ),
+        patch(
+            "backend.app.services.diagnostic_snapshot._run_log_health",
+            new=AsyncMock(return_value={"findings": []}),
+        ),
+    ):
+        out = await collect_diagnostic_snapshot(db)
+
+    assert len(out["connection_diagnostics"]) == 2
+    assert out["connection_diagnostics"][0]["printer_id"] == 1
+    assert out["connection_diagnostics"][1]["printer_id"] == 2
+    assert all("result" in entry for entry in out["connection_diagnostics"])
+    assert len(out["vp_diagnostics"]) == 1
+    assert out["vp_diagnostics"][0]["vp_id"] == 10
+    assert "result" in out["vp_diagnostics"][0]
+
+
+@pytest.mark.asyncio
+async def test_snapshot_fails_soft_when_single_printer_diagnostic_raises():
+    """A crash inside one printer's diagnostic emits an error marker for that
+    printer, but the snapshot's other sections still complete. This is the
+    whole point of including diagnostics in the bundle — a partial result
+    beats a 500."""
+    printers = [
+        SimpleNamespace(id=1, name="ok", ip_address="1.1.1.1", serial_number="s1", access_code="a"),
+        SimpleNamespace(id=2, name="bad", ip_address="2.2.2.2", serial_number="s2", access_code="b"),
+    ]
+    db = _make_db_with_printers_and_vps(printers, [])
+
+    fake_ok = SimpleNamespace(model_dump=lambda: {"status": "ok"})
+
+    async def diag(ip_address, **_):
+        if ip_address == "2.2.2.2":
+            raise RuntimeError("simulated crash")
+        return fake_ok
+
+    with (
+        patch("backend.app.services.printer_diagnostic.run_connection_diagnostic", new=AsyncMock(side_effect=diag)),
+        patch(
+            "backend.app.services.diagnostic_snapshot._run_log_health",
+            new=AsyncMock(return_value={"findings": []}),
+        ),
+    ):
+        out = await collect_diagnostic_snapshot(db)
+
+    # Both printers represented; the crashing one carries an `error` field.
+    assert len(out["connection_diagnostics"]) == 2
+    ok_entry = next(e for e in out["connection_diagnostics"] if e["printer_id"] == 1)
+    bad_entry = next(e for e in out["connection_diagnostics"] if e["printer_id"] == 2)
+    assert "result" in ok_entry
+    assert "error" not in ok_entry
+    assert "error" in bad_entry
+    assert "simulated crash" in bad_entry["error"]
+    # Log-health still completes despite the per-printer crash.
+    assert out["log_health"] == {"findings": []}
+
+
+@pytest.mark.asyncio
+async def test_snapshot_emits_timed_out_marker_when_probe_exceeds_cap():
+    """If a single probe stalls past the per-diagnostic timeout, the entry
+    is marked `timed_out` rather than blocking the whole snapshot. Patch
+    the timeout small so the test runs fast."""
+    printers = [SimpleNamespace(id=1, name="slow", ip_address="1.1.1.1", serial_number="s", access_code="a")]
+    db = _make_db_with_printers_and_vps(printers, [])
+
+    async def slow_diag(*a, **k):
+        import asyncio
+
+        await asyncio.sleep(5)  # well past the patched cap below
+        return SimpleNamespace(model_dump=lambda: {})
+
+    with (
+        patch(
+            "backend.app.services.printer_diagnostic.run_connection_diagnostic", new=AsyncMock(side_effect=slow_diag)
+        ),
+        patch("backend.app.services.diagnostic_snapshot._PER_DIAGNOSTIC_TIMEOUT_SECONDS", 0.05),
+        patch(
+            "backend.app.services.diagnostic_snapshot._run_log_health",
+            new=AsyncMock(return_value={"findings": []}),
+        ),
+    ):
+        out = await collect_diagnostic_snapshot(db)
+
+    assert len(out["connection_diagnostics"]) == 1
+    assert out["connection_diagnostics"][0]["error"] == "timed_out"
+
+
+@pytest.mark.asyncio
+async def test_snapshot_masks_ip_addresses_in_all_diagnostic_fields():
+    """The diagnostic schemas embed raw IPv4 in three places — the top-level
+    ``PrinterDiagnosticResult.ip_address``, the network-mode check's
+    ``params.{printer_ip, host_ip}``, and the VP diagnostic's
+    ``params.bind_ip``. None of those should leak into the submitted
+    snapshot. Sanitization runs after the per-probe gather; both DB-known
+    IPs (covered by sensitive_strings → "[IP]") and host / VP-bind IPs
+    (caught by the IPv4 regex fallback) end up redacted.
+    """
+    printers = [
+        SimpleNamespace(
+            id=1, name="Workshop", ip_address="192.168.255.131", serial_number="01S00ABC123", access_code="abcd1234"
+        )
+    ]
+    vps = [SimpleNamespace(id=10, name="VP-Workshop")]
+    db = _make_db_with_printers_and_vps(printers, vps)
+
+    fake_conn = SimpleNamespace(
+        model_dump=lambda: {
+            "ip_address": "192.168.255.131",
+            "overall": "ok",
+            "checks": [
+                {
+                    "id": "network_mode",
+                    "status": "warn",
+                    "params": {"printer_ip": "192.168.255.131", "host_ip": "192.168.255.16"},
+                }
+            ],
+        }
+    )
+    fake_vp = SimpleNamespace(
+        model_dump=lambda: {
+            "overall": "ok",
+            "checks": [{"id": "bind_interface", "status": "pass", "params": {"bind_ip": "192.168.254.2"}}],
+        }
+    )
+
+    with (
+        patch(
+            "backend.app.services.log_reader.collect_sensitive_strings",
+            new=AsyncMock(
+                return_value={
+                    "Workshop": "[PRINTER]",
+                    "192.168.255.131": "[IP]",
+                    "01S00ABC123": "[SERIAL]",
+                    "abcd1234": "[ACCESS_CODE]",
+                }
+            ),
+        ),
+        patch(
+            "backend.app.services.printer_diagnostic.run_connection_diagnostic", new=AsyncMock(return_value=fake_conn)
+        ),
+        patch("backend.app.services.virtual_printer.virtual_printer_manager.get_instance", return_value=None),
+        patch("backend.app.services.virtual_printer.diagnostic.run_vp_diagnostic", new=AsyncMock(return_value=fake_vp)),
+        patch(
+            "backend.app.services.diagnostic_snapshot._run_log_health",
+            new=AsyncMock(return_value={"findings": [{"sample": "Connecting to 10.0.0.5..."}]}),
+        ),
+    ):
+        out = await collect_diagnostic_snapshot(db)
+
+    # Conection diagnostic — top-level ip_address and check params both masked.
+    conn_entry = out["connection_diagnostics"][0]
+    assert conn_entry["printer_name"] == "[PRINTER]"
+    assert conn_entry["result"]["ip_address"] == "[IP]"
+    check_params = conn_entry["result"]["checks"][0]["params"]
+    assert check_params["printer_ip"] == "[IP]"
+    assert check_params["host_ip"] == "[IP]"  # not in DB; caught by regex fallback
+
+    # VP diagnostic — bind_ip masked (regex fallback; never in DB).
+    vp_entry = out["vp_diagnostics"][0]
+    assert vp_entry["result"]["checks"][0]["params"]["bind_ip"] == "[IP]"
+
+    # Log-health findings — IPs in log samples also masked (regex applies
+    # recursively through the dict, not just to known fields).
+    assert "10.0.0.5" not in str(out["log_health"])
+    assert "[IP]" in out["log_health"]["findings"][0]["sample"]
+
+    # Sanity: no raw IPv4 anywhere in the serialized snapshot.
+    import json
+    import re as _re
+
+    serialized = json.dumps(out)
+    raw_ipv4 = _re.search(
+        r"\b(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}\b", serialized
+    )
+    assert raw_ipv4 is None, f"raw IPv4 leaked into snapshot: {raw_ipv4.group()}"
+
+
+@pytest.mark.asyncio
+async def test_snapshot_runs_probes_concurrently_not_sequentially():
+    """Total wall-clock for N printers should be O(slowest), not O(sum) —
+    this is what makes the feature usable on a fleet. Set each probe to
+    take 0.2 s; with 4 printers, sequential is 0.8 s, concurrent is 0.2 s.
+    Allow margin for scheduling and the test still catches a regression
+    to sequential execution.
+    """
+    import time
+
+    printers = [
+        SimpleNamespace(id=i, name=f"P{i}", ip_address=f"1.1.1.{i}", serial_number=f"s{i}", access_code="a")
+        for i in range(4)
+    ]
+    db = _make_db_with_printers_and_vps(printers, [])
+
+    async def slow_diag(*a, **k):
+        import asyncio
+
+        await asyncio.sleep(0.2)
+        return SimpleNamespace(model_dump=lambda: {"ok": True})
+
+    with (
+        patch(
+            "backend.app.services.printer_diagnostic.run_connection_diagnostic", new=AsyncMock(side_effect=slow_diag)
+        ),
+        patch(
+            "backend.app.services.diagnostic_snapshot._run_log_health",
+            new=AsyncMock(return_value={"findings": []}),
+        ),
+    ):
+        start = time.monotonic()
+        out = await collect_diagnostic_snapshot(db)
+        elapsed = time.monotonic() - start
+
+    assert len(out["connection_diagnostics"]) == 4
+    # Concurrent should be ~0.2 s; sequential would be ~0.8 s. Use 0.5 s
+    # as the threshold — slack enough for slow CI, tight enough to catch
+    # a regression to sequential execution.
+    assert elapsed < 0.5, f"snapshot ran sequentially: {elapsed:.2f}s for 4 x 0.2s probes"

+ 139 - 0
backend/tests/unit/test_ffmpeg_rtsp_timeout_flag.py

@@ -0,0 +1,139 @@
+"""Regression for #1504: ffmpeg RTSP socket-I/O timeout flag.
+
+The RTSP demuxer's client-side socket I/O timeout option name varies by
+ffmpeg version (full chronology in
+`backend/app/services/camera.rtsp_socket_timeout_flag`). Hard-coding
+either ``-timeout`` or ``-stimeout`` regresses one half of the install
+base. The flag is therefore probed at runtime; this module tests that
+probe and guards against either RTSP ffmpeg argv re-hard-coding the
+wrong literal.
+"""
+
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+import backend.app.services.camera as camera_svc
+from backend.app.services.camera import rtsp_socket_timeout_flag
+
+
+@pytest.fixture(autouse=True)
+def _reset_cache():
+    """The probe caches its result in a module-level global. Reset it
+    before every test so each one sees a fresh probe."""
+    camera_svc._rtsp_socket_timeout_flag = None
+    yield
+    camera_svc._rtsp_socket_timeout_flag = None
+
+
+class TestRtspSocketTimeoutFlagProbe:
+    def test_prefers_stimeout_when_ffmpeg_advertises_it(self):
+        """Transitional ffmpeg (~late-4.x): both options are listed and
+        ``-timeout`` is the broken listen-mode option — pick ``-stimeout``."""
+        transitional_help = (
+            "  -listen_timeout    <int>  ... incoming connections ...\n"
+            "  -stimeout          <int64> ... socket TCP I/O ...\n"
+            "  -timeout           <int>  ... DEPRECATED ...\n"
+        )
+        with (
+            patch.object(camera_svc, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
+            patch("backend.app.services.camera.subprocess.run") as mock_run,
+        ):
+            mock_run.return_value.stdout = transitional_help
+            mock_run.return_value.stderr = ""
+            assert rtsp_socket_timeout_flag() == "stimeout"
+
+    def test_falls_back_to_timeout_on_modern_ffmpeg(self):
+        """Modern ffmpeg (5+/6+/7+): ``-stimeout`` no longer exists and
+        ``-timeout`` is back to meaning socket I/O — pick ``-timeout``."""
+        modern_help = (
+            "  -listen_timeout    <int>  ... incoming connections ...\n"
+            "  -timeout           <int64> ... socket I/O ...\n"
+            "  -reorder_queue_size <int> ... reordered packets ...\n"
+        )
+        with (
+            patch.object(camera_svc, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
+            patch("backend.app.services.camera.subprocess.run") as mock_run,
+        ):
+            mock_run.return_value.stdout = modern_help
+            mock_run.return_value.stderr = ""
+            assert rtsp_socket_timeout_flag() == "timeout"
+
+    def test_defaults_to_timeout_when_ffmpeg_missing(self):
+        """No ffmpeg available — return the modern default so we don't
+        wedge ffmpeg-less unit tests trying to import camera.py."""
+        with patch.object(camera_svc, "get_ffmpeg_path", return_value=None):
+            assert rtsp_socket_timeout_flag() == "timeout"
+
+    def test_defaults_to_timeout_when_probe_raises(self):
+        """If subprocess probe blows up, prefer the modern default —
+        breaking the transitional-ffmpeg case is preferable to crashing
+        every live-view start."""
+        with (
+            patch.object(camera_svc, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
+            patch("backend.app.services.camera.subprocess.run", side_effect=OSError("boom")),
+        ):
+            assert rtsp_socket_timeout_flag() == "timeout"
+
+    def test_result_is_cached_across_calls(self):
+        """Probing ffmpeg is a subprocess spawn; cache it for the
+        process lifetime (ffmpeg won't swap mid-run)."""
+        with (
+            patch.object(camera_svc, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
+            patch("backend.app.services.camera.subprocess.run") as mock_run,
+        ):
+            mock_run.return_value.stdout = "  -timeout  <int64>\n"
+            mock_run.return_value.stderr = ""
+            rtsp_socket_timeout_flag()
+            rtsp_socket_timeout_flag()
+            rtsp_socket_timeout_flag()
+            assert mock_run.call_count == 1
+
+    def test_substring_match_does_not_false_positive(self):
+        """Match the option as ``-stimeout `` (trailing space) so an
+        unrelated mention like ``-listen_timeout`` or a fragment in
+        another section doesn't trick us into picking the missing flag."""
+        only_listen_help = (
+            "  -listen_timeout    <int>  ... incoming connections ...\n"
+            "  -timeout           <int64> ... socket I/O ...\n"
+        )
+        with (
+            patch.object(camera_svc, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
+            patch("backend.app.services.camera.subprocess.run") as mock_run,
+        ):
+            mock_run.return_value.stdout = only_listen_help
+            mock_run.return_value.stderr = ""
+            assert rtsp_socket_timeout_flag() == "timeout"
+
+
+class TestRtspArgvUsesProbe:
+    """The two RTSP ffmpeg callers must not hard-code either flag literal —
+    they must consume the probe so version-dependent correctness is
+    preserved. Guards #1504 from being half-fixed again."""
+
+    # Anchor on this file so the assertion is CWD-independent (pytest can
+    # be invoked from the project root OR from backend/, depending on who
+    # runs it). __file__ lives at backend/tests/unit/, so the repo root
+    # is three parents up.
+    _REPO_ROOT = Path(__file__).resolve().parents[3]
+    _RTSP_FFMPEG_CALLERS = (
+        "backend/app/api/routes/camera.py",
+        "backend/app/services/external_camera.py",
+    )
+
+    @pytest.mark.parametrize("rel", _RTSP_FFMPEG_CALLERS)
+    def test_no_hard_coded_timeout_literal(self, rel):
+        """Neither RTSP ffmpeg argv may pass a hard-coded ``-timeout``
+        or ``-stimeout`` literal — both must come from the probe."""
+        src = (self._REPO_ROOT / rel).read_text()
+        assert '"-timeout"' not in src, (
+            f"{rel} hard-codes `-timeout` — this is the listen-mode option on "
+            f"transitional ffmpeg (EADDRINUSE, #1504). Use rtsp_socket_timeout_flag()."
+        )
+        assert '"-stimeout"' not in src, (
+            f"{rel} hard-codes `-stimeout` — this option was removed in ffmpeg 7. Use rtsp_socket_timeout_flag()."
+        )
+        assert "rtsp_socket_timeout_flag()" in src, (
+            f"{rel} should derive its RTSP socket timeout flag from rtsp_socket_timeout_flag() — see #1504."
+        )

+ 95 - 0
backend/tests/unit/test_library_print_name.py

@@ -0,0 +1,95 @@
+"""Tests for library files displaying the filename, not the embedded 3MF Title (#1489).
+
+The 3MF ``<metadata name="Title">`` is the in-app project title — generic
+("Exported 3D Model") for a Bambu Studio "Save As", a marketing title for a
+MakerWorld download — never the filename the user saved as. The FileManager
+keyed its display name / search / sort off ``file_metadata.print_name``, so
+storing the Title made every card show the wrong name. ``_without_print_name``
+strips it on import; ``_migrate_drop_library_print_name`` clears it from rows
+imported before the fix.
+"""
+
+from sqlalchemy import select
+
+from backend.app.api.routes.library import _without_print_name
+from backend.app.core.database import _migrate_drop_library_print_name
+from backend.app.models.library import LibraryFile
+
+# --- _without_print_name ---------------------------------------------------
+
+
+def test_strips_print_name_keeps_siblings():
+    cleaned = _without_print_name({"print_name": "Exported 3D Model", "print_time_seconds": 100})
+    assert cleaned == {"print_time_seconds": 100}
+
+
+def test_none_passes_through():
+    assert _without_print_name(None) is None
+
+
+def test_dict_without_print_name_returned_unchanged():
+    meta = {"print_time_seconds": 50}
+    # No copy needed when there's nothing to strip — same object back.
+    assert _without_print_name(meta) is meta
+
+
+def test_does_not_mutate_input():
+    original = {"print_name": "Whatever", "filament_used_grams": 12}
+    cleaned = _without_print_name(original)
+    assert original == {"print_name": "Whatever", "filament_used_grams": 12}  # untouched
+    assert cleaned == {"filament_used_grams": 12}
+
+
+def test_print_name_only_collapses_to_empty_dict():
+    assert _without_print_name({"print_name": "Exported 3D Model"}) == {}
+
+
+# --- _migrate_drop_library_print_name --------------------------------------
+
+
+async def test_migration_strips_print_name_from_existing_rows(db_session, monkeypatch):
+    """Rows imported before the fix get print_name cleared; siblings and rows
+    that never had it are untouched. Idempotent on a second run.
+
+    The test DB is SQLite; is_sqlite() reads settings.database_url (not the
+    test engine), so pin it to exercise the SQLite branch deterministically.
+    The PostgreSQL branch is verified against a real PG instance separately."""
+    monkeypatch.setattr("backend.app.core.database.is_sqlite", lambda: True)
+    db_session.add_all(
+        [
+            LibraryFile(
+                filename="halloween.3mf",
+                file_path="/a",
+                file_type="3mf",
+                file_size=1,
+                file_metadata={"print_name": "Haunted House", "print_time_seconds": 100},
+            ),
+            LibraryFile(
+                filename="no_meta.3mf",
+                file_path="/b",
+                file_type="3mf",
+                file_size=1,
+                file_metadata={"print_time_seconds": 50},
+            ),
+            LibraryFile(
+                filename="null_meta.3mf",
+                file_path="/c",
+                file_type="3mf",
+                file_size=1,
+                file_metadata=None,
+            ),
+        ]
+    )
+    await db_session.commit()
+
+    conn = await db_session.connection()
+    await _migrate_drop_library_print_name(conn)
+    await _migrate_drop_library_print_name(conn)  # idempotent
+
+    db_session.expire_all()
+    rows = (await db_session.execute(select(LibraryFile).order_by(LibraryFile.filename))).scalars().all()
+    by_name = {r.filename: r for r in rows}
+
+    assert by_name["halloween.3mf"].file_metadata == {"print_time_seconds": 100}
+    assert by_name["no_meta.3mf"].file_metadata == {"print_time_seconds": 50}
+    assert by_name["null_meta.3mf"].file_metadata is None

+ 16 - 0
backend/tests/unit/test_obico_detection.py

@@ -74,6 +74,22 @@ class TestGetStatus:
         assert s["history"] == []
         assert "low" in s["thresholds"] and "high" in s["thresholds"]
 
+    def test_thresholds_reflect_configured_sensitivity(self):
+        """#1469 — get_status() reports the thresholds for the passed
+        sensitivity, not a hardcoded 'medium'. Each level must be distinct so
+        the Status panel changes when the user changes the setting."""
+        svc = ObicoDetectionService()
+        low = svc.get_status("low")["thresholds"]
+        medium = svc.get_status("medium")["thresholds"]
+        high = svc.get_status("high")["thresholds"]
+
+        # Higher sensitivity → lower thresholds (easier to trigger).
+        assert low["low"] > medium["low"] > high["low"]
+        assert low["high"] > medium["high"] > high["high"]
+        # Default and unknown values fall back to medium.
+        assert svc.get_status()["thresholds"] == medium
+        assert svc.get_status("bogus")["thresholds"] == medium
+
 
 class TestTestConnection:
     @pytest.mark.asyncio

+ 107 - 0
backend/tests/unit/test_print_start_assigns_printer_id_to_vp_archive.py

@@ -22,6 +22,7 @@ from backend.app.main import (
     _expected_print_registered_at,
     _expected_prints,
     _print_ams_mappings,
+    _timelapse_baselines,
     register_expected_print,
 )
 
@@ -33,12 +34,14 @@ def _clear_dicts():
     _expected_print_creators.clear()
     _print_ams_mappings.clear()
     _active_prints.clear()
+    _timelapse_baselines.clear()
     yield
     _expected_prints.clear()
     _expected_print_registered_at.clear()
     _expected_print_creators.clear()
     _print_ams_mappings.clear()
     _active_prints.clear()
+    _timelapse_baselines.clear()
 
 
 @pytest.mark.asyncio
@@ -205,3 +208,107 @@ async def test_expected_archive_path_preserves_existing_printer_id():
 
         assert mock_archive.printer_id == 7
         assert mock_archive.status == "printing"
+
+
+@pytest.mark.asyncio
+async def test_expected_archive_path_captures_timelapse_baseline():
+    """VP-queue prints hit the expected-archive branch, which used to skip the
+    timelapse baseline-capture step that the new-archive branch did. Without a
+    baseline, _scan_for_timelapse_with_retries falls into its "take baseline
+    now" fallback that snapshots the SD card AFTER the new MP4 has landed —
+    the new file ends up in the baseline set and no diff ever matches, so
+    auto-attach never picks the right file.
+
+    Regression: at print start the global _timelapse_baselines dict must
+    contain the snapshot of existing video filenames for this printer_id,
+    so the completion-time scan can set-diff against it.
+    """
+    mock_printer = MagicMock()
+    mock_printer.id = 1
+    mock_printer.auto_archive = True
+    mock_printer.external_camera_enabled = False
+    mock_printer.external_camera_url = None
+    mock_printer.name = "TestP1S"
+
+    mock_archive = MagicMock()
+    mock_archive.id = 42
+    mock_archive.filename = "bambu_lab_a1_tool_plate_3.gcode.3mf"
+    mock_archive.subtask_id = None
+    mock_archive.print_time_seconds = None
+    mock_archive.created_by_id = None
+    mock_archive.printer_id = None
+    mock_archive.print_name = "A1 Tool Plate 3"
+    mock_archive.status = "archived"
+    mock_archive.file_path = "/tmp/fake.3mf"  # nosec B108 — mock path; nothing ever writes to it
+    mock_archive.energy_start_kwh = None
+
+    register_expected_print(1, "bambu_lab_a1_tool_plate_3.gcode.3mf", archive_id=42, ams_mapping=None)
+
+    # Two pre-existing files on the printer's SD card before this print starts.
+    # The fake completion scan would diff against this set.
+    existing_videos = [
+        {"name": "earlier_print_a.mp4", "is_directory": False, "path": "/timelapse/earlier_print_a.mp4"},
+        {"name": "earlier_print_b.mp4", "is_directory": False, "path": "/timelapse/earlier_print_b.mp4"},
+    ]
+
+    def execute_router(stmt, *args, **kwargs):
+        sql = str(stmt).lower()
+        if "from printers" in sql or "from printer " in sql:
+            return MagicMock(
+                scalar_one_or_none=MagicMock(return_value=mock_printer),
+                scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_printer]))),
+            )
+        if "from print_archives" in sql or "from print_archive" in sql:
+            return MagicMock(
+                scalar_one_or_none=MagicMock(return_value=mock_archive),
+                scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_archive]))),
+            )
+        return MagicMock(
+            scalar_one_or_none=MagicMock(return_value=None),
+            scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
+        )
+
+    mock_session = AsyncMock()
+    mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+    mock_session.__aexit__ = AsyncMock()
+    mock_session.execute = AsyncMock(side_effect=execute_router)
+    mock_session.commit = AsyncMock()
+
+    with (
+        patch("backend.app.main.async_session") as mock_session_maker,
+        patch("backend.app.main.notification_service") as mock_notif,
+        patch("backend.app.main.smart_plug_manager") as mock_plug,
+        patch("backend.app.main.ws_manager") as mock_ws,
+        patch("backend.app.main.printer_manager") as mock_pm,
+        patch("backend.app.main.mqtt_relay") as mock_relay,
+        patch("backend.app.main._record_energy_start", new_callable=AsyncMock),
+        patch("backend.app.main._load_objects_from_archive"),
+        patch("backend.app.main._store_spoolman_print_data", new_callable=AsyncMock),
+        patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock),
+        patch(
+            "backend.app.main._list_timelapse_videos",
+            new=AsyncMock(return_value=(existing_videos, "/timelapse")),
+        ),
+    ):
+        mock_session_maker.return_value = mock_session
+        mock_notif.on_print_start = AsyncMock()
+        mock_plug.on_print_start = AsyncMock()
+        mock_ws.send_print_start = AsyncMock()
+        mock_ws.send_archive_updated = AsyncMock()
+        mock_relay.on_print_start = AsyncMock()
+        mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
+
+        from backend.app.main import on_print_start
+
+        await on_print_start(
+            1,
+            {
+                "filename": "bambu_lab_a1_tool_plate_3.gcode.3mf",
+                "subtask_name": "bambu_lab_a1_tool_plate_3",
+            },
+        )
+
+        assert _timelapse_baselines.get(1) == {"earlier_print_a.mp4", "earlier_print_b.mp4"}, (
+            "expected-archive branch must capture the printer's existing-videos "
+            "baseline so completion-time scan can set-diff to find the new file"
+        )

+ 26 - 0
backend/tests/unit/test_printer_models.py

@@ -8,6 +8,7 @@ from backend.app.utils.printer_models import (
     STEEL_ROD_MODELS,
     get_rod_type,
     has_ethernet,
+    is_dual_nozzle_model,
     normalize_printer_model,
     normalize_printer_model_id,
 )
@@ -104,3 +105,28 @@ class TestX2DModel:
         assert "N6" not in CARBON_ROD_MODELS
         assert "X2D" in STEEL_ROD_MODELS
         assert "N6" in STEEL_ROD_MODELS
+
+
+class TestDualNozzleModel:
+    """is_dual_nozzle_model — the single source of truth for nozzle class,
+    consumed by start_print, the K-profile routes, and the re-slice guard."""
+
+    def test_h2d_and_pro_are_dual(self):
+        # Takes a normalized model code (like has_ethernet) — "H2D Pro" with a
+        # space is accepted; full "Bambu Lab …" names are normalized by callers.
+        assert is_dual_nozzle_model("H2D") is True
+        assert is_dual_nozzle_model("H2D Pro") is True
+        assert is_dual_nozzle_model("H2DPRO") is True
+
+    def test_internal_codes_are_dual(self):
+        assert is_dual_nozzle_model("O1D") is True  # H2D
+        assert is_dual_nozzle_model("O1E") is True  # H2D Pro
+
+    def test_single_nozzle_models_are_not_dual(self):
+        # H2S is in the H2 family but single-nozzle (#1386) — must be False.
+        for model in ("X1C", "X1E", "P1S", "P1P", "A1", "A1 Mini", "P2S", "H2S"):
+            assert is_dual_nozzle_model(model) is False, model
+
+    def test_none_and_empty_are_not_dual(self):
+        assert is_dual_nozzle_model(None) is False
+        assert is_dual_nozzle_model("") is False

+ 42 - 0
backend/tests/unit/test_printer_schema.py

@@ -0,0 +1,42 @@
+"""Serial-number normalization on the printer schema (#1465).
+
+Bambu serial numbers are uppercase alphanumeric and the MQTT report topic
+``device/<serial>/report`` is case-sensitive. A serial entered in the wrong
+case connects and subscribes without error but never receives a message, so
+the schema normalizes it on input.
+"""
+
+import pytest
+from pydantic import ValidationError
+
+from backend.app.schemas.printer import PrinterCreate
+
+
+def _make(serial: str) -> PrinterCreate:
+    return PrinterCreate(
+        name="Test Printer",
+        serial_number=serial,
+        ip_address="192.168.1.50",
+        access_code="12345678",
+    )
+
+
+def test_serial_number_uppercased():
+    assert _make("01p00a3b1234567").serial_number == "01P00A3B1234567"
+
+
+def test_serial_number_whitespace_stripped():
+    assert _make("  01P00A3B1234567  ").serial_number == "01P00A3B1234567"
+
+
+def test_serial_number_stripped_and_uppercased():
+    assert _make(" 31b8c0ca1234567 ").serial_number == "31B8C0CA1234567"
+
+
+def test_already_normalized_serial_unchanged():
+    assert _make("31B8C0CA1234567").serial_number == "31B8C0CA1234567"
+
+
+def test_blank_serial_number_rejected():
+    with pytest.raises(ValidationError):
+        _make("   ")

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

@@ -505,6 +505,270 @@ class TestPreferLowestFilament:
         assert result == [254]  # Should pick external spool (10%) over AMS (80%)
 
 
+class TestPreferLowestInventoryOverride:
+    """Tests for the #1508 inventory-aware sort: when the user has bound a
+    Bambuddy inventory spool to an AMS slot, that spool's remaining weight
+    becomes the sort signal instead of the MQTT ``remain`` percentage.
+
+    The fix is two-tier: inventory-tracked spools always sort before
+    MQTT-only ones, then ascending by remaining within each tier, then
+    ascending by AMS slot position. See
+    ``print_scheduler._prefer_lowest_sort_key`` for the rationale.
+    """
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    def test_inventory_override_beats_mqtt_remain(self, scheduler):
+        """Slot 4's inventory shows 50 g remaining; slot 1's clone has 950 g.
+        MQTT ``remain`` is -1 for both (non-RFID spools), so without the
+        override the sort collapses to AMS-slot order and slot 1 wins.
+        With the override slot 4 (the original, nearly empty) wins. This is
+        the literal reporter scenario in #1508.
+        """
+        required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
+        loaded = [
+            {
+                "type": "PLA",
+                "color": "#FF0000",
+                "ams_id": 0,
+                "tray_id": 0,
+                "global_tray_id": 0,
+                "remain": -1,
+            },
+            {
+                "type": "PLA",
+                "color": "#FF0000",
+                "ams_id": 0,
+                "tray_id": 3,
+                "global_tray_id": 3,
+                "remain": -1,
+            },
+        ]
+        # Slot 1 (gtid 0) is the fresh clone at 950 g; slot 4 (gtid 3) is the
+        # nearly-empty original at 50 g.
+        overrides = {0: 950.0, 3: 50.0}
+        result = scheduler._match_filaments_to_slots(
+            required, loaded, prefer_lowest=True, inventory_remain_overrides=overrides
+        )
+        assert result == [3]
+
+    def test_inventory_override_with_zero_grams_still_wins(self, scheduler):
+        """An inventory-tracked spool at 0 g must still sort first within
+        its tier — the user wants to finish what's left (or be told there's
+        a deficit) rather than skip to the fresh one. The clamp-to-101
+        legacy logic only fires on negative values, so 0 g stays 0.0.
+        """
+        required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
+        loaded = [
+            {
+                "type": "PLA",
+                "color": "#FF0000",
+                "ams_id": 0,
+                "tray_id": 0,
+                "global_tray_id": 0,
+                "remain": -1,
+            },
+            {
+                "type": "PLA",
+                "color": "#FF0000",
+                "ams_id": 0,
+                "tray_id": 1,
+                "global_tray_id": 1,
+                "remain": -1,
+            },
+        ]
+        overrides = {0: 500.0, 1: 0.0}
+        result = scheduler._match_filaments_to_slots(
+            required, loaded, prefer_lowest=True, inventory_remain_overrides=overrides
+        )
+        assert result == [1]
+
+    def test_inventory_tier_beats_mqtt_tier_regardless_of_value(self, scheduler):
+        """Mixed mode: one slot is inventory-tracked at 800 g (high), the
+        other has only a MQTT remain of 10 (very low). The inventory tier
+        wins because the user has explicitly told us to manage that slot —
+        unit-mixing across tiers is intentional and resolved by the tier
+        flag, not value comparison.
+        """
+        required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
+        loaded = [
+            {
+                "type": "PLA",
+                "color": "#FF0000",
+                "ams_id": 0,
+                "tray_id": 0,
+                "global_tray_id": 0,
+                "remain": 10,
+            },
+            {
+                "type": "PLA",
+                "color": "#FF0000",
+                "ams_id": 0,
+                "tray_id": 1,
+                "global_tray_id": 1,
+                "remain": -1,
+            },
+        ]
+        overrides = {1: 800.0}  # only slot 2 has an inventory binding
+        result = scheduler._match_filaments_to_slots(
+            required, loaded, prefer_lowest=True, inventory_remain_overrides=overrides
+        )
+        assert result == [1]
+
+    def test_two_tracked_spools_tied_at_same_grams_lower_slot_wins(self, scheduler):
+        """Two inventory-tracked spools with genuinely equal remaining
+        weight — slot tie-breaker decides. Lower AMS slot wins to match
+        the user's mental model of "use the lower slot first when equal."
+        """
+        required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
+        loaded = [
+            {
+                "type": "PLA",
+                "color": "#FF0000",
+                "ams_id": 0,
+                "tray_id": 1,
+                "global_tray_id": 1,
+                "remain": -1,
+            },
+            {
+                "type": "PLA",
+                "color": "#FF0000",
+                "ams_id": 0,
+                "tray_id": 0,
+                "global_tray_id": 0,
+                "remain": -1,
+            },
+        ]
+        overrides = {0: 500.0, 1: 500.0}
+        result = scheduler._match_filaments_to_slots(
+            required, loaded, prefer_lowest=True, inventory_remain_overrides=overrides
+        )
+        assert result == [0]
+
+    def test_no_override_falls_back_to_mqtt_remain(self, scheduler):
+        """When no slots are inventory-bound the override map is empty
+        and behaviour is identical to the pre-#1508 MQTT-only sort.
+        Regression guard for the un-tracked-spool case.
+        """
+        required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
+        loaded = [
+            {
+                "type": "PLA",
+                "color": "#FF0000",
+                "ams_id": 0,
+                "tray_id": 0,
+                "global_tray_id": 0,
+                "remain": 80,
+            },
+            {
+                "type": "PLA",
+                "color": "#FF0000",
+                "ams_id": 0,
+                "tray_id": 1,
+                "global_tray_id": 1,
+                "remain": 30,
+            },
+        ]
+        result = scheduler._match_filaments_to_slots(
+            required, loaded, prefer_lowest=True, inventory_remain_overrides={}
+        )
+        assert result == [1]
+
+    def test_no_override_unknown_remain_sorts_after_known(self, scheduler):
+        """In the no-binding case, ``remain = -1`` (non-RFID, unknown) must
+        still sort *after* a slot the printer knows something about — the
+        legacy sentinel-101 behaviour is preserved.
+        """
+        required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
+        loaded = [
+            {
+                "type": "PLA",
+                "color": "#FF0000",
+                "ams_id": 0,
+                "tray_id": 0,
+                "global_tray_id": 0,
+                "remain": -1,
+            },
+            {
+                "type": "PLA",
+                "color": "#FF0000",
+                "ams_id": 0,
+                "tray_id": 1,
+                "global_tray_id": 1,
+                "remain": 50,
+            },
+        ]
+        result = scheduler._match_filaments_to_slots(
+            required, loaded, prefer_lowest=True, inventory_remain_overrides=None
+        )
+        assert result == [1]
+
+    def test_external_with_negative_ams_id_does_not_outrank_ams_on_tie(self, scheduler):
+        """``_build_loaded_filaments`` emits external/VT trays with
+        ``ams_id = -1``. A naive ``ams_id * 4 + tray_id`` slot-priority
+        formula would compute -4 for an external and 0 for AMS slot 0 —
+        flipping the legacy stable-sort baseline (which kept AMS first
+        because it's emitted before externals). When ``remain`` ties
+        between the two, AMS slot 0 must still win.
+        """
+        required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
+        loaded = [
+            {
+                "type": "PLA",
+                "color": "#FF0000",
+                "ams_id": 0,
+                "tray_id": 0,
+                "global_tray_id": 0,
+                "remain": -1,
+                "is_external": False,
+            },
+            {
+                "type": "PLA",
+                "color": "#FF0000",
+                "ams_id": -1,
+                "tray_id": 0,
+                "global_tray_id": 254,
+                "remain": -1,
+                "is_external": True,
+            },
+        ]
+        result = scheduler._match_filaments_to_slots(
+            required, loaded, prefer_lowest=True, inventory_remain_overrides=None
+        )
+        assert result == [0]
+
+    def test_ams_ht_does_not_outrank_regular_ams_on_tie(self, scheduler):
+        """AMS-HT units use ``ams_id`` >= 128 with a single tray. On a tied
+        ``remain`` value, regular AMS slot 0 (slot_priority 0) must beat
+        AMS-HT (slot_priority 1000+).
+        """
+        required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
+        loaded = [
+            {
+                "type": "PLA",
+                "color": "#FF0000",
+                "ams_id": 0,
+                "tray_id": 0,
+                "global_tray_id": 0,
+                "remain": 50,
+            },
+            {
+                "type": "PLA",
+                "color": "#FF0000",
+                "ams_id": 128,
+                "tray_id": 0,
+                "global_tray_id": 128,
+                "remain": 50,
+            },
+        ]
+        result = scheduler._match_filaments_to_slots(
+            required, loaded, prefer_lowest=True, inventory_remain_overrides=None
+        )
+        assert result == [0]
+
+
 class TestBuildLoadedFilamentsTrayInfoIdx:
     """Test tray_info_idx extraction in _build_loaded_filaments."""
 

+ 119 - 0
backend/tests/unit/test_scheduler_filament_deficit.py

@@ -0,0 +1,119 @@
+"""Scheduler pre-dispatch filament-deficit guard tests (#1496).
+
+``PrintScheduler._block_on_filament_deficit`` is the gate that keeps an
+auto_dispatch=True VP intake (or any other scheduler-driven dispatch) from
+sending a print onto a spool that can't satisfy it. On a deficit it
+promotes the item to manual_start; when a previously-flagged item's spool
+is now adequate it clears the flag so the next tick dispatches.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.services.filament_deficit import FilamentDeficit
+from backend.app.services.print_scheduler import PrintScheduler
+
+
+@pytest.fixture
+def scheduler():
+    """A fresh scheduler instance — internal state is not exercised."""
+    return PrintScheduler()
+
+
+@pytest.fixture
+def queue_item(db_session, printer_factory):
+    """Helper to drop a queue item the helper can mutate."""
+
+    async def _make(**overrides):
+        printer = await printer_factory()
+        defaults = {
+            "printer_id": printer.id,
+            "status": "pending",
+            "manual_start": False,
+            "filament_short": False,
+        }
+        defaults.update(overrides)
+        item = PrintQueueItem(**defaults)
+        db_session.add(item)
+        await db_session.commit()
+        await db_session.refresh(item)
+        return item
+
+    return _make
+
+
+@pytest.mark.asyncio
+async def test_blocks_on_deficit_promotes_to_manual_start(scheduler, db_session, queue_item):
+    item = await queue_item()
+    with patch(
+        "backend.app.services.print_scheduler.compute_deficit_for_queue_item",
+        AsyncMock(
+            return_value=[
+                FilamentDeficit(
+                    slot_id=1,
+                    ams_id=0,
+                    tray_id=0,
+                    filament_type="PLA",
+                    required_grams=270.0,
+                    remaining_grams=200.0,
+                ),
+            ]
+        ),
+    ):
+        blocked = await scheduler._block_on_filament_deficit(db_session, item)
+
+    assert blocked is True
+    await db_session.refresh(item)
+    assert item.manual_start is True
+    assert item.filament_short is True
+
+
+@pytest.mark.asyncio
+async def test_clears_stale_flag_when_deficit_resolves(scheduler, db_session, queue_item):
+    """Previously-flagged item whose spool was swapped is unblocked."""
+    item = await queue_item(filament_short=True, manual_start=False)
+    with patch(
+        "backend.app.services.print_scheduler.compute_deficit_for_queue_item",
+        AsyncMock(return_value=[]),
+    ):
+        blocked = await scheduler._block_on_filament_deficit(db_session, item)
+
+    assert blocked is False
+    await db_session.refresh(item)
+    assert item.filament_short is False
+    assert item.manual_start is False
+
+
+@pytest.mark.asyncio
+async def test_no_deficit_no_op(scheduler, db_session, queue_item):
+    """Happy path — no deficit, no flag changes, dispatch proceeds."""
+    item = await queue_item()
+    with patch(
+        "backend.app.services.print_scheduler.compute_deficit_for_queue_item",
+        AsyncMock(return_value=[]),
+    ):
+        blocked = await scheduler._block_on_filament_deficit(db_session, item)
+
+    assert blocked is False
+    await db_session.refresh(item)
+    assert item.filament_short is False
+    assert item.manual_start is False
+
+
+@pytest.mark.asyncio
+async def test_helper_exception_does_not_wedge_dispatch(scheduler, db_session, queue_item):
+    """A flaky deficit check (e.g. Spoolman timeout) must not block dispatch."""
+    item = await queue_item()
+    with patch(
+        "backend.app.services.print_scheduler.compute_deficit_for_queue_item",
+        AsyncMock(side_effect=RuntimeError("network down")),
+    ):
+        blocked = await scheduler._block_on_filament_deficit(db_session, item)
+
+    assert blocked is False
+    await db_session.refresh(item)
+    assert item.filament_short is False

+ 243 - 0
backend/tests/unit/test_scheduler_force_color_ams_fallback.py

@@ -0,0 +1,243 @@
+"""Tests for force-color-override AMS mapping fallback in the print scheduler.
+
+Covers the code path in ``_compute_ams_mapping_for_printer`` that kicks in
+when the 3MF's filament requirements cannot be read (e.g. ``plate_id=None``
+with a modern BambuStudio 3MF whose slice_info was missing or unreadable)
+but ``force_color_match`` overrides are present.
+
+Related issue: #1436
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.print_scheduler import PrintScheduler
+
+
+class TestBuildOverrideDirectMapping:
+    """Unit tests for ``_build_override_direct_mapping``."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    def _status(self, ams: list[dict], vt_tray: list[dict] | None = None) -> MagicMock:
+        raw: dict = {"ams": ams}
+        if vt_tray is not None:
+            raw["vt_tray"] = vt_tray
+        return MagicMock(raw_data=raw)
+
+    def test_single_force_override_matches_ams_slot(self, scheduler):
+        """Override with type+color matches the correct AMS tray."""
+        status = self._status(
+            ams=[
+                {
+                    "id": 0,
+                    "tray": [
+                        {"id": 0, "tray_type": "PLA", "tray_color": "CBC6B8FF"},
+                    ],
+                }
+            ]
+        )
+        overrides = [{"slot_id": 1, "type": "PLA", "color": "#CBC6B8", "force_color_match": True}]
+        result = scheduler._build_override_direct_mapping(overrides, status)
+        assert result == [0]  # global_tray_id 0 (AMS 0, tray 0)
+
+    def test_no_loaded_filaments_returns_none(self, scheduler):
+        """Empty AMS → cannot compute mapping, return None."""
+        status = self._status(ams=[{"id": 0, "tray": [{"id": 0}]}])  # empty tray
+        overrides = [{"slot_id": 1, "type": "PLA", "color": "#CBC6B8", "force_color_match": True}]
+        result = scheduler._build_override_direct_mapping(overrides, status)
+        assert result is None
+
+    def test_no_color_match_returns_minus_one(self, scheduler):
+        """Override color not present → slot mapped to -1 (no match)."""
+        status = self._status(
+            ams=[
+                {
+                    "id": 0,
+                    "tray": [
+                        {"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF"},
+                    ],
+                }
+            ]
+        )
+        overrides = [{"slot_id": 1, "type": "PLA", "color": "#CBC6B8", "force_color_match": True}]
+        result = scheduler._build_override_direct_mapping(overrides, status)
+        # Type matches but color is far off (red vs beige) → type-only fallback → [0]
+        # If colour threshold is exceeded, falls back to type-only, which IS a match.
+        # The important thing: result is not None and has the right length.
+        assert result is not None
+        assert len(result) == 1
+
+    def test_multiple_overrides_map_multiple_slots(self, scheduler):
+        """Two overrides with different slot_ids produce a two-element mapping."""
+        status = self._status(
+            ams=[
+                {
+                    "id": 0,
+                    "tray": [
+                        {"id": 0, "tray_type": "PLA", "tray_color": "CBC6B8FF"},
+                        {"id": 1, "tray_type": "PETG", "tray_color": "000000FF"},
+                    ],
+                }
+            ]
+        )
+        overrides = [
+            {"slot_id": 1, "type": "PLA", "color": "#CBC6B8", "force_color_match": True},
+            {"slot_id": 2, "type": "PETG", "color": "#000000", "force_color_match": True},
+        ]
+        result = scheduler._build_override_direct_mapping(overrides, status)
+        assert result == [0, 1]  # slot 1 → tray 0, slot 2 → tray 1
+
+    def test_external_spool_matched(self, scheduler):
+        """Override matching an external spool returns global_tray_id 254."""
+        status = self._status(
+            ams=[],
+            vt_tray=[{"tray_type": "TPU", "tray_color": "CBC6B8FF"}],
+        )
+        overrides = [{"slot_id": 1, "type": "TPU", "color": "#CBC6B8", "force_color_match": True}]
+        result = scheduler._build_override_direct_mapping(overrides, status)
+        assert result == [254]
+
+    def test_tray_info_idx_is_not_used_for_direct_mapping(self, scheduler):
+        """Direct-override mapping clears tray_info_idx so matching falls back
+        to colour rather than pinning to a specific spool ID from the 3MF."""
+        status = self._status(
+            ams=[
+                {
+                    "id": 0,
+                    "tray": [
+                        {
+                            "id": 0,
+                            "tray_type": "PLA",
+                            "tray_color": "CBC6B8FF",
+                            "tray_info_idx": "GFA00",
+                        },
+                    ],
+                }
+            ]
+        )
+        overrides = [{"slot_id": 1, "type": "PLA", "color": "#CBC6B8", "force_color_match": True}]
+        result = scheduler._build_override_direct_mapping(overrides, status)
+        # Should match by colour (#CBC6B8 ≈ CBC6B8FF after strip), not by tray_info_idx.
+        assert result == [0]
+
+
+class TestComputeAmsMappingFallback:
+    """Integration tests for the force-color fallback inside
+    ``_compute_ams_mapping_for_printer`` when filament reqs are unavailable."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    def _make_item(self, filament_overrides_json: str | None = None) -> MagicMock:
+        item = MagicMock()
+        item.archive_id = 141
+        item.library_file_id = None
+        item.plate_id = None
+        item.filament_overrides = filament_overrides_json
+        item.printer_id = 5
+        return item
+
+    def _make_status(self) -> MagicMock:
+        return MagicMock(
+            raw_data={
+                "ams": [
+                    {
+                        "id": 0,
+                        "tray": [
+                            {"id": 0, "tray_type": "PLA", "tray_color": "CBC6B8FF"},
+                        ],
+                    }
+                ]
+            }
+        )
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_fallback_used_when_filament_reqs_empty(self, mock_pm, scheduler):
+        """When _get_filament_requirements returns None but force-color overrides
+        are set, the fallback builds a mapping directly from the overrides."""
+        mock_pm.get_status.return_value = self._make_status()
+
+        item = self._make_item(
+            filament_overrides_json='[{"slot_id": 1, "type": "PLA", "color": "#CBC6B8", "force_color_match": true}]'
+        )
+
+        db = AsyncMock()
+
+        with patch.object(scheduler, "_get_filament_requirements", return_value=None):
+            result = await scheduler._compute_ams_mapping_for_printer(db, 5, item)
+
+        assert result == [0]  # global_tray_id 0 (AMS 0, tray 0)
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_fallback_not_used_when_no_force_color(self, mock_pm, scheduler):
+        """When overrides have no force_color_match, the fallback is not triggered."""
+        mock_pm.get_status.return_value = self._make_status()
+
+        item = self._make_item(filament_overrides_json='[{"slot_id": 1, "type": "PLA", "color": "#CBC6B8"}]')
+        db = AsyncMock()
+
+        with patch.object(scheduler, "_get_filament_requirements", return_value=None):
+            result = await scheduler._compute_ams_mapping_for_printer(db, 5, item)
+
+        assert result is None
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_fallback_not_used_when_no_overrides(self, mock_pm, scheduler):
+        """When filament_overrides is None, the fallback is not triggered."""
+        mock_pm.get_status.return_value = self._make_status()
+
+        item = self._make_item(filament_overrides_json=None)
+        db = AsyncMock()
+
+        with patch.object(scheduler, "_get_filament_requirements", return_value=None):
+            result = await scheduler._compute_ams_mapping_for_printer(db, 5, item)
+
+        assert result is None
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_normal_path_used_when_filament_reqs_available(self, mock_pm, scheduler):
+        """When filament requirements are available, the normal path is used
+        (overrides applied to reqs, then matched)."""
+        mock_pm.get_status.return_value = self._make_status()
+
+        item = self._make_item(
+            filament_overrides_json='[{"slot_id": 1, "type": "PLA", "color": "#CBC6B8", "force_color_match": true}]'
+        )
+        db = AsyncMock()
+
+        # 3MF says slot 1 is PLA with a different color; override will change it.
+        filament_reqs = [{"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA00"}]
+
+        with (
+            patch.object(scheduler, "_get_filament_requirements", return_value=filament_reqs),
+            patch.object(scheduler, "_get_bool_setting", new=AsyncMock(return_value=False)),
+        ):
+            result = await scheduler._compute_ams_mapping_for_printer(db, 5, item)
+
+        # After override, slot 1 becomes PLA #CBC6B8 → matches tray 0.
+        assert result == [0]
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_fallback_returns_none_when_printer_status_unavailable(self, mock_pm, scheduler):
+        """When the printer has no status, the fallback also returns None gracefully."""
+        mock_pm.get_status.return_value = None
+
+        item = self._make_item(
+            filament_overrides_json='[{"slot_id": 1, "type": "PLA", "color": "#CBC6B8", "force_color_match": true}]'
+        )
+        db = AsyncMock()
+
+        with patch.object(scheduler, "_get_filament_requirements", return_value=None):
+            result = await scheduler._compute_ams_mapping_for_printer(db, 5, item)
+
+        assert result is None

+ 194 - 0
backend/tests/unit/test_scheduler_inventory_remain.py

@@ -0,0 +1,194 @@
+"""Tests for the inventory-remain override builder in print_scheduler (#1508).
+
+The MQTT ``remain`` field on an AMS tray is the printer firmware's
+RFID-tracked value, which is ``-1`` for non-Bambu spools (and even when
+set diverges from Bambuddy's inventory). When the user has bound an
+inventory spool to an AMS slot, that inventory record's
+``label_weight - weight_used`` (or Spoolman's ``remaining_weight``) is
+the authoritative remaining-weight signal. These tests verify
+``_build_inventory_remain_overrides`` surfaces those values keyed by
+``global_tray_id`` so the "Prefer Lowest Remaining Filament" sort can
+consume them.
+"""
+
+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()
+
+
+def _make_async_session_returning(rows: list):
+    """Build a stub AsyncSession whose .execute() returns an object whose
+    .all() (and .scalars().all()) yield ``rows``."""
+    result = MagicMock()
+    result.all.return_value = rows
+    scalars = MagicMock()
+    scalars.all.return_value = rows
+    result.scalars.return_value = scalars
+    db = MagicMock()
+    db.execute = AsyncMock(return_value=result)
+    return db
+
+
+class TestInternalInventoryOverrides:
+    @pytest.mark.asyncio
+    async def test_returns_remaining_grams_for_bound_slots(self, scheduler):
+        """Two slots bound; both come back keyed by global_tray_id with the
+        correct ``label_weight - weight_used`` in grams. This is the
+        reporter scenario in #1508: slot 1 has a 950 g clone, slot 4 has
+        a 50 g original — the sort can now actually pick the 50 g spool.
+
+        The override builder uses ``select(SpoolAssignment).options(
+        selectinload(SpoolAssignment.spool))`` (matching the rest of the
+        codebase), so the rows it iterates expose ``.ams_id``, ``.tray_id``
+        and ``.spool`` directly — the test stubs the same shape.
+        """
+        spool_a = SimpleNamespace(label_weight=1000, weight_used=50)  # 950 g remaining
+        spool_b = SimpleNamespace(label_weight=1000, weight_used=950)  # 50 g remaining
+        rows = [
+            SimpleNamespace(ams_id=0, tray_id=0, spool=spool_a),
+            SimpleNamespace(ams_id=0, tray_id=3, spool=spool_b),
+        ]
+        loaded = [
+            {"ams_id": 0, "tray_id": 0, "global_tray_id": 0, "is_external": False},
+            {"ams_id": 0, "tray_id": 3, "global_tray_id": 3, "is_external": False},
+        ]
+        db = _make_async_session_returning(rows)
+        with patch.object(PrintScheduler, "_is_spoolman_mode", new=AsyncMock(return_value=False)):
+            out = await scheduler._build_inventory_remain_overrides(db, printer_id=1, loaded=loaded)
+        assert out == {0: 950.0, 3: 50.0}
+
+    @pytest.mark.asyncio
+    async def test_skips_external_slots(self, scheduler):
+        """VT / external slots are tracked separately from AMS inventory
+        bindings — the override builder must not assign them an inventory
+        remaining value even if (somehow) an assignment row exists.
+        """
+        loaded = [
+            {"ams_id": -1, "tray_id": 0, "global_tray_id": 254, "is_external": True},
+        ]
+        db = _make_async_session_returning([])
+        with patch.object(PrintScheduler, "_is_spoolman_mode", new=AsyncMock(return_value=False)):
+            out = await scheduler._build_inventory_remain_overrides(db, printer_id=1, loaded=loaded)
+        # DB shouldn't even be queried — nothing AMS-side to look up.
+        db.execute.assert_not_called()
+        assert out == {}
+
+    @pytest.mark.asyncio
+    async def test_empty_loaded_returns_empty(self, scheduler):
+        """No loaded filaments → no overrides. The scheduler short-circuits
+        before this is called in practice, but the function must be
+        defensive — it's used in any prefer_lowest dispatch path."""
+        db = _make_async_session_returning([])
+        with patch.object(PrintScheduler, "_is_spoolman_mode", new=AsyncMock(return_value=False)):
+            out = await scheduler._build_inventory_remain_overrides(db, printer_id=1, loaded=[])
+        assert out == {}
+        db.execute.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_negative_remaining_clamped_to_zero(self, scheduler):
+        """An over-consumed spool (weight_used > label_weight) shouldn't
+        produce a negative grams value — clamped to 0 so the sort treats
+        it as fully empty rather than "more empty than zero."
+        """
+        spool = SimpleNamespace(label_weight=1000, weight_used=1100)
+        rows = [
+            SimpleNamespace(ams_id=0, tray_id=0, spool=spool),
+        ]
+        loaded = [{"ams_id": 0, "tray_id": 0, "global_tray_id": 0, "is_external": False}]
+        db = _make_async_session_returning(rows)
+        with patch.object(PrintScheduler, "_is_spoolman_mode", new=AsyncMock(return_value=False)):
+            out = await scheduler._build_inventory_remain_overrides(db, printer_id=1, loaded=loaded)
+        assert out == {0: 0.0}
+
+    @pytest.mark.asyncio
+    async def test_slot_without_binding_absent_from_overrides(self, scheduler):
+        """A slot that has loaded filament but no inventory binding must
+        not appear in the override map — the sort then falls back to MQTT
+        ``remain`` for that one slot, preserving pre-#1508 behaviour.
+        """
+        rows = [
+            SimpleNamespace(
+                ams_id=0,
+                tray_id=0,
+                spool=SimpleNamespace(label_weight=1000, weight_used=100),
+            ),
+        ]
+        loaded = [
+            {"ams_id": 0, "tray_id": 0, "global_tray_id": 0, "is_external": False},
+            {"ams_id": 0, "tray_id": 1, "global_tray_id": 1, "is_external": False},
+        ]
+        db = _make_async_session_returning(rows)
+        with patch.object(PrintScheduler, "_is_spoolman_mode", new=AsyncMock(return_value=False)):
+            out = await scheduler._build_inventory_remain_overrides(db, printer_id=1, loaded=loaded)
+        assert out == {0: 900.0}
+        assert 1 not in out
+
+
+class TestSpoolmanModeOverrides:
+    @pytest.mark.asyncio
+    async def test_spoolman_remaining_grams_used_when_available(self, scheduler):
+        """Spoolman mode: each bound slot's spoolman_spool_id is fetched
+        through ``_spoolman_remaining_grams``; the result is the same
+        global-tray-id-keyed grams map. Parity rule with internal mode
+        (feedback_inventory_modes_parity).
+        """
+        rows = [
+            SimpleNamespace(printer_id=1, ams_id=0, tray_id=0, spoolman_spool_id=42),
+            SimpleNamespace(printer_id=1, ams_id=0, tray_id=2, spoolman_spool_id=99),
+        ]
+        loaded = [
+            {"ams_id": 0, "tray_id": 0, "global_tray_id": 0, "is_external": False},
+            {"ams_id": 0, "tray_id": 2, "global_tray_id": 2, "is_external": False},
+        ]
+        db = _make_async_session_returning(rows)
+
+        async def _fake_grams(spool_id: int):
+            return {42: 720.0, 99: 80.0}[spool_id]
+
+        with (
+            patch.object(PrintScheduler, "_is_spoolman_mode", new=AsyncMock(return_value=True)),
+            patch(
+                "backend.app.services.filament_deficit._spoolman_remaining_grams",
+                new=AsyncMock(side_effect=_fake_grams),
+            ),
+        ):
+            out = await scheduler._build_inventory_remain_overrides(db, printer_id=1, loaded=loaded)
+        assert out == {0: 720.0, 2: 80.0}
+
+    @pytest.mark.asyncio
+    async def test_spoolman_unreachable_skips_silently(self, scheduler):
+        """If Spoolman is unreachable for one spool, ``_spoolman_remaining_grams``
+        returns None and that slot is omitted from the override map —
+        sorting then falls back to MQTT remain for that slot only.
+        """
+        rows = [
+            SimpleNamespace(printer_id=1, ams_id=0, tray_id=0, spoolman_spool_id=42),
+            SimpleNamespace(printer_id=1, ams_id=0, tray_id=1, spoolman_spool_id=99),
+        ]
+        loaded = [
+            {"ams_id": 0, "tray_id": 0, "global_tray_id": 0, "is_external": False},
+            {"ams_id": 0, "tray_id": 1, "global_tray_id": 1, "is_external": False},
+        ]
+        db = _make_async_session_returning(rows)
+
+        async def _fake_grams(spool_id: int):
+            return 500.0 if spool_id == 42 else None
+
+        with (
+            patch.object(PrintScheduler, "_is_spoolman_mode", new=AsyncMock(return_value=True)),
+            patch(
+                "backend.app.services.filament_deficit._spoolman_remaining_grams",
+                new=AsyncMock(side_effect=_fake_grams),
+            ),
+        ):
+            out = await scheduler._build_inventory_remain_overrides(db, printer_id=1, loaded=loaded)
+        assert out == {0: 500.0}
+        assert 1 not in out

+ 55 - 0
backend/tests/unit/test_slicer_presets.py

@@ -642,3 +642,58 @@ class TestBundleRoutes:
         ):
             await sp.delete_slicer_bundle("missing", db=MagicMock(), _=None)
         assert exc.value.status_code == 404
+
+
+class TestParseCompatiblePrinters:
+    """``compatible_printers`` exposed for local process / filament presets so
+    the SliceModal can filter the dropdowns by the selected printer (#1325)."""
+
+    def test_parses_json_array(self):
+        raw = '["Bambu Lab X1 Carbon 0.4 nozzle", "Bambu Lab X1 0.4 nozzle"]'
+        assert sp._parse_compatible_printers(raw) == [
+            "Bambu Lab X1 Carbon 0.4 nozzle",
+            "Bambu Lab X1 0.4 nozzle",
+        ]
+
+    def test_none_and_empty_return_none(self):
+        assert sp._parse_compatible_printers(None) is None
+        assert sp._parse_compatible_printers("") is None
+        assert sp._parse_compatible_printers("[]") is None
+
+    def test_malformed_json_returns_none(self):
+        assert sp._parse_compatible_printers("not json") is None
+        # A JSON value that isn't an array is treated as absent, not an error.
+        assert sp._parse_compatible_printers('"a string"') is None
+
+    def test_drops_non_string_and_blank_entries(self):
+        assert sp._parse_compatible_printers('["X1C", 5, "", "  ", "A1"]') == [
+            "X1C",
+            "A1",
+        ]
+
+
+class TestListPrinterModels:
+    """``GET /slicer/printer-models`` exposes ``PRINTER_MODEL_MAP`` so the
+    frontend doesn't duplicate the Bambu model registry (#1325 follow-up)."""
+
+    def test_returns_canonical_printer_model_map(self):
+        from backend.app.utils.printer_models import PRINTER_MODEL_MAP
+
+        result = sp.list_printer_models()
+        # Same shape - mapping from "Bambu Lab <model>" to short code.
+        assert result == PRINTER_MODEL_MAP
+        # Spot-check a few entries: the SliceModal name-fallback (#1325)
+        # specifically depends on these resolving.
+        assert result["Bambu Lab X1 Carbon"] == "X1C"
+        assert result["Bambu Lab P2S"] == "P2S"
+        assert result["Bambu Lab A1 mini"] == "A1 Mini"
+        assert result["Bambu Lab H2D Pro"] == "H2D Pro"
+
+    def test_returns_a_copy_not_the_module_dict(self):
+        # A response handler must never hand out the live module-level dict —
+        # accidental mutation by middleware / serialisers would silently
+        # corrupt the registry for every subsequent request.
+        from backend.app.utils.printer_models import PRINTER_MODEL_MAP
+
+        result = sp.list_printer_models()
+        assert result is not PRINTER_MODEL_MAP

Some files were not shown because too many files changed in this diff