82 Коміти 6d673d0626 ... c0129ef93f

Автор SHA1 Опис Дата
  maziggy c0129ef93f chore(docker): silence Trivy DS-0026 on Dockerfile.test via HEALTHCHECK NONE 3 днів тому
  maziggy b3b37e8f08 ci(docker): full backend suite in Docker, 4-way matrix shard, GHA cache backend 3 днів тому
  maziggy ed905d8406 ci(docker): stop re-running unit tests inside the test image 3 днів тому
  maziggy 39a075918a ci(docker): drop -v, -n auto instead of -n 30, pip cache mount 3 днів тому
  maziggy 4fac9ff12c fix(test): stop sys.modules-deleting backend.app.main in test_code_quality 3 днів тому
  maziggy af03a6f384 fix(test): snapshot _timelapse_baselines inside the patch context to dodge CI race 3 днів тому
  MartinNYHC f385936c58 Merge pull request #1514 from maziggy/0.2.4.3 3 днів тому
  maziggy 02e119ea45 fix(test): use /nonexistent/ instead of /tmp/ to satisfy Bandit B108 3 днів тому
  maziggy d64c0dcd08 Merge remote-tracking branch 'origin/main' into 0.2.4.3 3 днів тому
  maziggy 07ea69c3f0 test(docker): include gcode_viewer/ in the test image so the packaging assertion actually runs 3 днів тому
  maziggy f513c2c1db Bumped version 3 днів тому
  maziggy a64df5a922 Updated CHANGELOG 3 днів тому
  maziggy b9d51ffd80 chore(deps): floor-pin starlette>=1.0.1 against PYSEC-2026-161 3 днів тому
  maziggy 3b9633a178 ● feat(support): include sanitized connection / VP / log-health diagnostics in support bundle and bug report (#1506 follow-up) 3 днів тому
  maziggy cc468ddd42 Updated BACKERS.md 3 днів тому
  maziggy 17602774f3 Updated BACKERS.md 3 днів тому
  maziggy 03896d1af0 fix(scheduler): use inventory weight for "Prefer Lowest Filament" sort (#1508) 3 днів тому
  maziggy eae96da56e fix(camera): probe ffmpeg for the right RTSP socket-timeout flag (#1504) 3 днів тому
  maziggy 6591fc011f fix(slicer): filter process / filament presets by nozzle diameter too (#1325 follow-up #2) 4 днів тому
  maziggy ed08ed3787 fix(timelapse): capture baseline on restart-recovery so post-reboot timelapses attach (follow up issue #1485) 4 днів тому
  maziggy c0c07bc509 fix(archives): cross-model re-slice no longer carries source printer_id 4 днів тому
  maziggy 5f473801f8 fix(file-manager): list-view actions clipped + preview modal slice button now respects Slicer API setting 4 днів тому
  maziggy 3058c5789b fix(slice): @BBL name fallback for users without slicer bundles (#1325 follow-up) 4 днів тому
  maziggy 4096d8d6bd fix(csp): nonce-based script-src so Cloudflare-injected scripts pass (#1460 follow-up) 4 днів тому
  maziggy 4686d108ef feat(slice): cross-printer re-slicing across nozzle classes + multi-plate slice-all 4 днів тому
  maziggy ebba1385d3 feat(spoolbuddy-settings): show CPU load on the device card 4 днів тому
  maziggy 7ea4410b21 fix(queue): insufficient-filament warning now fires on every dispatch path (#1496) 4 днів тому
  maziggy 32fcd85827 fix(library): "All Files" view now shows files inside subfolders (#1499) 4 днів тому
  maziggy 6b87cdefd7 Updated BACKERS 4 днів тому
  maziggy e222a0ef0e feat(system): log-health scanner + Add/Edit-Printer setup pre-flight 5 днів тому
  maziggy ab8e07618f fix(inventory): archive filament colour follows the assigned spool, not the 3MF (#1494) 5 днів тому
  maziggy 6d7a92c024 fix(camera): P2S RTSP stream dropped every frame after the first (#1395) 5 днів тому
  maziggy 6bc6a1d683 feat(virtual-printer): setup diagnostic + one-click slicer-certificate export 5 днів тому
  maziggy ed31b8f4a4 fix(ui): collapse bug-report connection diagnostic for multi-printer setups 5 днів тому
  maziggy 4925b4c830 fix(slice): re-slice correctness — model label, honest errors, filament usage, nozzle guard 5 днів тому
  maziggy 16da533c9a fix(static): serve /fonts/*.woff2 — self-hosted Inter font (#1460 follow-up) 5 днів тому
  maziggy 71e58e6cf1 fix(library): show the filename, not the embedded 3MF Title (#1489) 5 днів тому
  maziggy 056f06a396 fix(camera): capture ffmpeg stderr when an RTSP stream stalls (#1395) 5 днів тому
  maziggy 51b0d28b05 fix(camera): show diagnostic in window-mode camera page (#1395) 5 днів тому
  maziggy 774eba73c8 feat(diagnostics): event-loop stall watchdog to catch silent backend freezes 5 днів тому
  maziggy 745ed847e6 fix(archive): stop duplicating the job on a backend restart mid-print (#1485) 5 днів тому
  maziggy 3286ccd7d2 fix(inventory): send honest Bambuddy User-Agent on FilamentColors.xyz sync 5 днів тому
  maziggy 50d1984820 fix(printer): stop the File Manager polling the printer over FTPS every 30s (#1480) 5 днів тому
  maziggy e0247fc6a6 fix(slicer): filter process/filament presets by uploaded bundles, not preset names (#1325) 5 днів тому
  maziggy 17e39921bb fix(pwa): add in-app install button and self-host the Inter font (#1460) 5 днів тому
  maziggy 379b1c14bc fix(printer): Flow Calibration was silently skipped — wrong project_file fields (#1478) 6 днів тому
  maziggy 7aad3fb395 fix(inventory): show group totals on collapsed grouped rows (issue #1368) 6 днів тому
  maziggy e738645b0d feat(slicer): filter slice profiles by printer + default from the 3MF (issue #1325) 6 днів тому
  maziggy 7eba29624b feat(slicer): filter process & filament profiles by selected printer (issue #1325) 6 днів тому
  maziggy a51d59eabf feat(i18n): add Spanish (es) locale 6 днів тому
  maziggy 76e327f4a1 feat: connection diagnostic for "printer won't connect" triage 6 днів тому
  maziggy e1a236e408 fix(spoolman): decide spool assignability from the slot-assignment ledger, not extra.tag (#1122) 6 днів тому
  maziggy b06f8f6951 fix(spool-assignments): union both assignment tables in the missing-spool check + symmetric mode-switch clear (#1473) 6 днів тому
  maziggy 305529f483 fix(notifications): missing-spool-assignment check now unions both assignment tables (#1473) 6 днів тому
  maziggy 016e01781a fix(profiles): keep the Local Profiles search bar mounted when nothing matches (#1470) 6 днів тому
  maziggy 5b3962e3e6 fix(obico): Failure Detection status panel shows thresholds for the selected sensitivity (#1469) 6 днів тому
  maziggy 01787eb1e6 fix(printers): normalize serial numbers + diagnose connect-but-no-reports (#1465) 6 днів тому
  maziggy 76582298b5 fix(drying): stop false "drying complete" from killing the printer via smart-plug auto-off (#1462) 6 днів тому
  maziggy 8cff425c8d fix(printers): AMS drying popover scrolls instead of clipping the Start button (#1458) 6 днів тому
  maziggy 2b8887c4d5 chore(ci): also ignore disputed PyJWT CVE-2025-45768 in ci.yml 1 тиждень тому
  maziggy 4910a0fb86 Updated .gitignore 1 тиждень тому
  maziggy 39a2a79524 This reverts commit 929aae7202f20b592ce96a7bac18d229bc432aca. 1 тиждень тому
  maziggy 929aae7202 Added scripts/pip-audit.sh 1 тиждень тому
  maziggy 9d440beb80 chore(security): bump idna >=3.15 (CVE-2026-45409) + ignore disputed PyJWT advisory 1 тиждень тому
  maziggy ed27b27adb feat(slice): cross-printer re-slicing — drop the gate, the banner, and the dead plumbing 1 тиждень тому
  maziggy 787ce9e146 Updated BACKERS.md 1 тиждень тому
  maziggy fd620df3d6 feat(currency): add Belize Dollars (BZD) to currency dropdown (#1454) 1 тиждень тому
  Seb 14919a80a5 Merge pull request #1440 from Person2099/fix/filament-override-ams-mapping-dispatch 1 тиждень тому
  maziggy d3f0e9ac73 fix(spoolman): per-print weight tracker falls back to local slot-assignment table for tag-less spools (#1459) 1 тиждень тому
  maziggy 12b0c138f7 fix(spoolman): clear stale fallback-tag links on assign + link, prefer slot-assignment over tag-link in UI (#1457) 1 тиждень тому
  maziggy fbaf219094 Fix: AMS drying popover positioning + diagnostic logging (#1447) 1 тиждень тому
  maziggy 0f68039416 Fix: AMS drying popover no longer renders off the bottom of the viewport (#1447 part 1) 1 тиждень тому
  maziggy fcee1a6f7e Fix: Print Activity heatmap buckets by local date, not UTC date (#1446) 1 тиждень тому
  maziggy 0406487eb3 Fix: Add Printer no longer hangs the container on P1S (#1445) 1 тиждень тому
  maziggy badf0bed04 Fix: Failure Analysis widget honours edited failure_reason / status (#1444) 1 тиждень тому
  maziggy a4e8ae3ab4 Fix: SpoolBuddy Write-Tag page honours Spoolman mode + complete ID surface (#1439) 1 тиждень тому
  maziggy 6f050708da Fix: cap TLS to v1.2 for P2S FTPS to dodge vsFTPd session-reuse bug (#1401) 1 тиждень тому
  maziggy 235a189e31 Fix: 3D preview no longer freezes the page on complex multi-part 3MFs (#1412) 1 тиждень тому
  maziggy bfd3fc755d Fix: capture timelapse baseline on expected-archive on_print_start branch (#1403 follow-up) 1 тиждень тому
  maziggy 74f759468c Bumped version 1 тиждень тому
  maziggy d6d3fa2f99 chore(security): nosec false-positive Bandit findings in tests 1 тиждень тому
  MartinNYHC 12a352e5b8 Merge branch 'main' into dev 1 тиждень тому
100 змінених файлів з 9598 додано та 529 видалено
  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
             --ignore-vuln CVE-2025-45768
 
 
   backend-tests:
   backend-tests:
-    name: Backend Tests
+    name: Backend Tests (shard ${{ matrix.shard }}/4)
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
     if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
     if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
     needs: backend-lint
     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:
     steps:
       - uses: actions/checkout@v4
       - uses: actions/checkout@v4
 
 
@@ -110,11 +117,22 @@ jobs:
           pip install -r requirements.txt
           pip install -r requirements.txt
           pip install -r requirements-dev.txt
           pip install -r requirements-dev.txt
 
 
-      - name: Run tests
+      - name: Run tests (shard ${{ matrix.shard }}/4)
         timeout-minutes: 10
         timeout-minutes: 10
         run: |
         run: |
           cd backend
           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
   # Frontend Checks
@@ -261,6 +279,56 @@ jobs:
   # Docker Tests (matches test_docker.sh)
   # 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:
   docker-test:
     name: Docker Build
     name: Docker Build
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
@@ -280,20 +348,6 @@ jobs:
       - name: Verify static files exist
       - name: Verify static files exist
         run: docker run --rm bambuddy:test test -d /app/static
         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
       # Test 4: Integration Tests
       - name: Build integration container
       - name: Build integration container
         run: docker compose -f docker-compose.test.yml build integration
         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)
 - [@grizz0blaw](https://github.com/grizz0blaw)
 - [@NoahTingey](https://github.com/NoahTingey)
 - [@NoahTingey](https://github.com/NoahTingey)
 - [@sentinel-center](https://github.com/sentinel-center)
 - [@sentinel-center](https://github.com/sentinel-center)
+- [@brianehlert](https://github.com/brianehlert)
+- [@siiruup](https://github.com/siiruup)
 ---
 ---
 
 
 ## One-time and historical supporters
 ## One-time and historical supporters

Різницю між файлами не показано, бо вона завелика
+ 3 - 1
CHANGELOG.md


+ 27 - 4
Dockerfile.test

@@ -1,4 +1,5 @@
 # Test image for running backend and frontend tests
 # Test image for running backend and frontend tests
+# syntax=docker/dockerfile:1.7
 FROM python:3.13-slim AS backend-test
 FROM python:3.13-slim AS backend-test
 
 
 WORKDIR /app
 WORKDIR /app
@@ -8,15 +9,26 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
     curl \
     curl \
     && rm -rf /var/lib/apt/lists/*
     && 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.txt ./
 COPY requirements-dev.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 code
 COPY backend/ ./backend/
 COPY backend/ ./backend/
 COPY pyproject.toml ./
 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
 # Create necessary directories
 RUN mkdir -p /app/data /app/logs /app/archive
 RUN mkdir -p /app/data /app/logs /app/archive
 
 
@@ -25,8 +37,19 @@ ENV PYTHONUNBUFFERED=1
 ENV DATA_DIR=/app/data
 ENV DATA_DIR=/app/data
 ENV TESTING=1
 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
 # 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.
 - 🍰 **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.
 - 📱 **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.
 - 🎒 **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.
 - 🔁 **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.
 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.services.archive import ArchiveService
 from backend.app.utils.http import build_content_disposition
 from backend.app.utils.http import build_content_disposition
 from backend.app.utils.threemf_tools import (
 from backend.app.utils.threemf_tools import (
+    extract_embedded_presets_from_3mf,
     extract_nozzle_mapping_from_3mf,
     extract_nozzle_mapping_from_3mf,
     extract_project_filaments_from_3mf,
     extract_project_filaments_from_3mf,
-    extract_source_printer_model_from_3mf,
 )
 )
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -1343,9 +1343,35 @@ async def update_archive(
         if archive.created_by_id != user.id:
         if archive.created_by_id != user.id:
             raise HTTPException(403, "You can only update your own archives")
             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)
         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()
     await db.commit()
 
 
     # Re-fetch with relationships loaded after 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
     # 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 file from a sliced-archive flow that didn't request 3MF output).
     gcode_files: list[str] = []
     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:
     try:
         with zipfile.ZipFile(file_path, "r") as zf:
         with zipfile.ZipFile(file_path, "r") as zf:
             namelist = zf.namelist()
             namelist = zf.namelist()
+            embedded_presets = extract_embedded_presets_from_3mf(zf)
 
 
             # Find all plate gcode files to determine available plates
             # 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")]
             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
     # to preview gcode — the viewer, skip-objects — can gate on this instead of
     # 404-ing on every plate request.
     # 404-ing on every plate request.
     has_gcode = bool(gcode_files)
     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 {
     return {
         "archive_id": archive_id,
         "archive_id": archive_id,
         "filename": archive.filename,
         "filename": archive.filename,
         "plates": plates,
         "plates": plates,
         "is_multi_plate": len(plates) > 1,
         "is_multi_plate": len(plates) > 1,
         "has_gcode": has_gcode,
         "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
     user originally sent to slice) → ``file_path`` (the sliced 3MF/gcode that
     actually printed).
     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.core.database import async_session
     from backend.app.services.slice_dispatch import (
     from backend.app.services.slice_dispatch import (
         http_exception_to_job_error,
         http_exception_to_job_error,
@@ -3630,6 +3654,11 @@ async def slice_archive(
     archive_id_local = archive.id
     archive_id_local = archive.id
     user_id = current_user.id if current_user else None
     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 def _run(job_id: int):
         async with async_session() as task_db:
         async with async_session() as task_db:
             # Re-fetch the source archive on the background-task session.
             # 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,
     get_ffmpeg_path,
     is_chamber_image_model,
     is_chamber_image_model,
     read_next_chamber_frame,
     read_next_chamber_frame,
+    rtsp_socket_timeout_flag,
     test_camera_connection,
     test_camera_connection,
 )
 )
 from backend.app.services.camera_fanout import (
 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:
 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:
     if not process or not process.stderr:
         return None
         return None
+    chunks: list[bytes] = []
+    total = 0
+    cap = 65536
     try:
     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 None
+    return _summarize_ffmpeg_stderr(b"".join(chunks).decode(errors="replace")) or None
 
 
 
 
 async def generate_rtsp_mjpeg_stream(
 async def generate_rtsp_mjpeg_stream(
@@ -333,8 +349,11 @@ async def generate_rtsp_mjpeg_stream(
         "tcp",
         "tcp",
         "-rtsp_flags",
         "-rtsp_flags",
         "prefer_tcp",
         "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",
         "-buffer_size",
         "1024000",  # 1MB buffer
         "1024000",  # 1MB buffer
         "-max_delay",
         "-max_delay",
@@ -365,9 +384,19 @@ async def generate_rtsp_mjpeg_stream(
         _disconnect_events[stream_id] = disconnect_event
         _disconnect_events[stream_id] = disconnect_event
 
 
     logger.info(
     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
     # On Windows, spawn ffmpeg in its own process group so that
     # terminate() doesn't broadcast CTRL_C_EVENT to uvicorn (#605).
     # 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
         total_available = 0
 
 
         try:
         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
                 page = 1
                 while True:
                 while True:
                     response = await client.get(
                     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
     # device.extruder.info beats serial-prefix heuristics — H2S shares prefix
     # "094" with H2D but is single-nozzle (#1386). Model name is the fallback
     # "094" with H2D but is single-nozzle (#1386). Model name is the fallback
     # for the brief window after connect before push data arrives.
     # 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:
     if is_edit and is_dual_nozzle:
         # Dual-nozzle in-place edit: use cali_idx with slot_id=0 and empty setting_id
         # 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.archive import ThreeMFParser
 from backend.app.services.stl_thumbnail import generate_stl_thumbnail
 from backend.app.services.stl_thumbnail import generate_stl_thumbnail
 from backend.app.utils.threemf_tools import (
 from backend.app.utils.threemf_tools import (
+    extract_embedded_presets_from_3mf,
     extract_nozzle_mapping_from_3mf,
     extract_nozzle_mapping_from_3mf,
     extract_project_filaments_from_3mf,
     extract_project_filaments_from_3mf,
-    extract_source_printer_model_from_3mf,
 )
 )
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -364,6 +364,40 @@ def _clean_3mf_metadata(obj):
     return 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(
 async def save_3mf_bytes_to_library(
     db: AsyncSession,
     db: AsyncSession,
     *,
     *,
@@ -435,7 +469,7 @@ async def save_3mf_bytes_to_library(
         file_size=len(file_bytes),
         file_size=len(file_bytes),
         file_hash=file_hash,
         file_hash=file_hash,
         thumbnail_path=to_relative_path(thumbnail_path) if thumbnail_path else None,
         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_type=source_type,
         source_url=source_url,
         source_url=source_url,
         created_by_id=owner_id,
         created_by_id=owner_id,
@@ -1378,7 +1412,7 @@ async def scan_external_folder(
                 file_size=stat.st_size,
                 file_size=stat.st_size,
                 file_hash=None,  # Skip hashing external files for performance
                 file_hash=None,  # Skip hashing external files for performance
                 thumbnail_path=thumbnail_path,
                 thumbnail_path=thumbnail_path,
-                file_metadata=file_metadata,
+                file_metadata=_without_print_name(file_metadata),
             )
             )
             db.add(db_file)
             db.add(db_file)
             added += 1
             added += 1
@@ -1655,7 +1689,7 @@ async def upload_file(
             file_size=len(content),
             file_size=len(content),
             file_hash=file_hash,
             file_hash=file_hash,
             thumbnail_path=to_relative_path(thumbnail_path) if thumbnail_path else None,
             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,
             created_by_id=current_user.id if current_user else None,
         )
         )
         db.add(library_file)
         db.add(library_file)
@@ -1908,7 +1942,7 @@ async def extract_zip_file(
                         file_size=len(file_content),
                         file_size=len(file_content),
                         file_hash=file_hash,
                         file_hash=file_hash,
                         thumbnail_path=to_relative_path(thumbnail_path) if thumbnail_path else None,
                         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,
                         created_by_id=current_user.id if current_user else None,
                     )
                     )
                     db.add(library_file)
                     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}
         return {"file_id": file_id, "filename": lib_file.filename, "plates": [], "is_multi_plate": False}
 
 
     plates = []
     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:
     try:
         with zipfile.ZipFile(file_path, "r") as zf:
         with zipfile.ZipFile(file_path, "r") as zf:
             namelist = zf.namelist()
             namelist = zf.namelist()
+            embedded_presets = extract_embedded_presets_from_3mf(zf)
 
 
             # Find all plate gcode files to determine available plates
             # 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")]
             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:
     except Exception as e:
         logger.warning("Failed to parse plates from library file %s: %s", file_id, 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 {
     return {
         "file_id": file_id,
         "file_id": file_id,
         "filename": lib_file.filename,
         "filename": lib_file.filename,
         "plates": plates,
         "plates": plates,
         "is_multi_plate": len(plates) > 1,
         "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)
     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(
 async def _run_slicer_with_fallback(
     db: AsyncSession,
     db: AsyncSession,
     *,
     *,
@@ -3009,6 +3067,39 @@ async def _run_slicer_with_fallback(
 
 
     used_embedded_settings = False
     used_embedded_settings = False
     service = SlicerApiService(api_url)
     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
     # When this slice is dispatcher-tracked, generate a request_id so
     # the sidecar publishes progress under it, and wire a callback that
     # the sidecar publishes progress under it, and wire a callback that
     # forwards each frame onto SliceDispatchService.set_progress for the
     # 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)
             _dispatch.set_progress(job_id, snapshot)
 
 
         progress_callback = _on_progress
         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:
         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
                 # Bundle dispatch: sidecar materialises the JSON triplet
                 # from the stored .bbscfg by name. ``request.bundle`` is
                 # from the stored .bbscfg by name. ``request.bundle`` is
                 # guaranteed non-None here by the use_bundle branch above.
                 # 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,
                     bundle_id=request.bundle.bundle_id,
                     printer_name=request.bundle.printer_name,
                     printer_name=request.bundle.printer_name,
                     process_name=request.bundle.process_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,
                     plate=request.plate,
                     export_3mf=request.export_3mf,
                     export_3mf=request.export_3mf,
+                    arrange=cross_class_arrange,
                     bed_type=request.bed_type,
                     bed_type=request.bed_type,
                     request_id=progress_request_id,
                     request_id=progress_request_id,
                     on_progress=progress_callback,
                     on_progress=progress_callback,
@@ -3055,14 +3282,24 @@ async def _run_slicer_with_fallback(
                     filament_profile_jsons=filament_jsons,
                     filament_profile_jsons=filament_jsons,
                     plate=request.plate,
                     plate=request.plate,
                     export_3mf=request.export_3mf,
                     export_3mf=request.export_3mf,
+                    arrange=cross_class_arrange,
                     request_id=progress_request_id,
                     request_id=progress_request_id,
                     on_progress=progress_callback,
                     on_progress=progress_callback,
                 )
                 )
         except SlicerApiServerError as exc:
         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:
             if not is_3mf:
                 raise
                 raise
             logger.warning(
             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,
                 model_filename,
                 exc,
                 exc,
             )
             )
@@ -3098,6 +3335,73 @@ async def _run_slicer_with_fallback(
     return result, used_embedded_settings
     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(
 async def slice_and_persist(
     db: AsyncSession,
     db: AsyncSession,
     *,
     *,
@@ -3156,19 +3460,21 @@ async def slice_and_persist(
     except Exception as exc:
     except Exception as exc:
         logger.warning("Failed to parse sliced 3MF metadata for %s: %s", out_filename, 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(
     metadata.update(
         {
         {
             "print_time_seconds": result.print_time_seconds,
             "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:
     if used_embedded_settings:
@@ -3202,8 +3508,8 @@ async def slice_and_persist(
         library_file_id=new_file.id,
         library_file_id=new_file.id,
         name=new_file.filename,
         name=new_file.filename,
         print_time_seconds=result.print_time_seconds,
         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,
         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 = archive_dir / out_filename
     out_path.write_bytes(result.content)
     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
     thumbnail_path: str | None = None
     parsed_metadata: dict = {}
     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:
     try:
-        parser = ThreeMFParser(str(out_path))
+        parser = ThreeMFParser(str(out_path), plate_number=plate_num)
         parsed = parser.parse()
         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("_")}
         parsed_metadata = {k: v for k, v in parsed.items() if not k.startswith("_")}
     except Exception as exc:
     except Exception as exc:
         logger.warning("Failed to parse sliced 3MF metadata for %s: %s", out_filename, 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 = dict(source_archive.extra_data) if source_archive.extra_data else {}
     metadata.update(parsed_metadata)
     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(
     metadata.update(
         {
         {
             "sliced_from_archive_id": source_archive.id,
             "sliced_from_archive_id": source_archive.id,
             "print_time_seconds": result.print_time_seconds,
             "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:
     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_type = parsed_metadata.get("filament_type") or source_archive.filament_type
     new_filament_color = parsed_metadata.get("filament_color") or source_archive.filament_color
     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(
     new_archive = PrintArchive(
-        printer_id=source_archive.printer_id,
+        printer_id=new_printer_id,
         project_id=source_archive.project_id,
         project_id=source_archive.project_id,
         filename=out_filename,
         filename=out_filename,
         file_path=str(out_path.relative_to(app_settings.base_dir)),
         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.
         # up alongside its sibling in the archives list.
         print_name=(source_archive.print_name or base_name) + " (re-sliced)",
         print_name=(source_archive.print_name or base_name) + " (re-sliced)",
         print_time_seconds=result.print_time_seconds,
         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_type=new_filament_type,
         filament_color=new_filament_color,
         filament_color=new_filament_color,
         layer_height=source_archive.layer_height,
         layer_height=source_archive.layer_height,
         nozzle_diameter=source_archive.nozzle_diameter,
         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,
         makerworld_url=source_archive.makerworld_url,
         designer=source_archive.designer,
         designer=source_archive.designer,
         # Sliced-but-not-printed: keep status default ("completed") so it
         # 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,
         archive_id=new_archive.id,
         name=new_archive.print_name or out_filename,
         name=new_archive.print_name or out_filename,
         print_time_seconds=result.print_time_seconds,
         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,
         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"
     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
     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 def _run(job_id: int):
         async with async_session() as task_db:
         async with async_session() as task_db:
             try:
             try:
@@ -3641,9 +4011,8 @@ async def update_file(
         if "/" in data.filename or "\\" in data.filename:
         if "/" in data.filename or "\\" in data.filename:
             raise HTTPException(status_code=400, detail="Filename cannot contain path separators")
             raise HTTPException(status_code=400, detail="Filename cannot contain path separators")
         file.filename = data.filename
         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 is not None:
         if data.folder_id == 0:
         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."""
     """Scheduler status, per-printer classification, and recent detection history."""
     settings = await obico_detection_service._load_settings()
     settings = await obico_detection_service._load_settings()
-    status = obico_detection_service.get_status()
+    status = obico_detection_service.get_status(settings["sensitivity"])
     return {
     return {
         **status,
         **status,
         "enabled": settings["enabled"],
         "enabled": settings["enabled"],

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

@@ -32,6 +32,7 @@ from backend.app.schemas.print_queue import (
     PrintQueueItemUpdate,
     PrintQueueItemUpdate,
     PrintQueueReorder,
     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.services.notification_service import notification_service
 from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id
 from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id
 from backend.app.utils.threemf_tools import extract_filament_usage_from_3mf
 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,
         "require_previous_success": item.require_previous_success,
         "auto_off_after": item.auto_off_after,
         "auto_off_after": item.auto_off_after,
         "manual_start": item.manual_start,
         "manual_start": item.manual_start,
+        "filament_short": bool(item.filament_short),
         "ams_mapping": ams_mapping_parsed,
         "ams_mapping": ams_mapping_parsed,
         "plate_id": item.plate_id,
         "plate_id": item.plate_id,
         "bed_levelling": item.bed_levelling,
         "bed_levelling": item.bed_levelling,
@@ -1028,19 +1030,25 @@ async def stop_queue_item(
 @router.post("/{item_id}/start")
 @router.post("/{item_id}/start")
 async def start_queue_item(
 async def start_queue_item(
     item_id: int,
     item_id: int,
+    skip_filament_check: bool = Query(default=False),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_OWN),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_OWN),
 ):
 ):
     """Manually start a staged (manual_start) queue item.
     """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(
     result = await db.execute(
         select(PrintQueueItem)
         select(PrintQueueItem)
         .options(
         .options(
             selectinload(PrintQueueItem.archive),
             selectinload(PrintQueueItem.archive),
             selectinload(PrintQueueItem.printer),
             selectinload(PrintQueueItem.printer),
+            selectinload(PrintQueueItem.library_file),
             selectinload(PrintQueueItem.batch),
             selectinload(PrintQueueItem.batch),
         )
         )
         .where(PrintQueueItem.id == item_id)
         .where(PrintQueueItem.id == item_id)
@@ -1052,10 +1060,29 @@ async def start_queue_item(
     if item.status != "pending":
     if item.status != "pending":
         raise HTTPException(400, f"Can only start pending items, current status: '{item.status}'")
         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.manual_start = False
+    item.filament_short = False
     await db.commit()
     await db.commit()
     await db.refresh(item, ["archive", "printer", "library_file", "created_by", "batch"])
     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)
     return _enrich_response(item)

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

@@ -19,11 +19,13 @@ from backend.app.schemas.printer import (
     AmsLabelBody,
     AmsLabelBody,
     AMSTray,
     AMSTray,
     AMSUnit,
     AMSUnit,
+    DiagnosticRequest,
     FilaSwitchResponse,
     FilaSwitchResponse,
     HMSErrorResponse,
     HMSErrorResponse,
     NozzleInfoResponse,
     NozzleInfoResponse,
     NozzleRackSlot,
     NozzleRackSlot,
     PrinterCreate,
     PrinterCreate,
+    PrinterDiagnosticResult,
     PrinterResponse,
     PrinterResponse,
     PrinterStatus,
     PrinterStatus,
     PrinterUpdate,
     PrinterUpdate,
@@ -38,6 +40,7 @@ from backend.app.services.bambu_ftp import (
     get_storage_info_async,
     get_storage_info_async,
     list_files_async,
     list_files_async,
 )
 )
+from backend.app.services.printer_diagnostic import run_connection_diagnostic
 from backend.app.services.printer_manager import (
 from backend.app.services.printer_manager import (
     get_derived_status_name,
     get_derived_status_name,
     printer_manager,
     printer_manager,
@@ -770,6 +773,37 @@ async def test_printer_connection(
     return result
     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}).
 # 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
 # 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.
 # 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))
             result = await db.execute(delete(SpoolAssignment))
             logger.info("Cleared %d spool assignments on switch to Spoolman mode", result.rowcount)
             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:
     if "spoolman_url" in settings:
         await set_setting(db, "spoolman_url", settings["spoolman_url"])
         await set_setting(db, "spoolman_url", settings["spoolman_url"])
     if "spoolman_sync_mode" in settings:
     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,
     SlicerApiUnavailableError,
     SlicerInputError,
     SlicerInputError,
 )
 )
+from backend.app.utils.printer_models import PRINTER_MODEL_MAP
 
 
 logger = logging.getLogger(__name__)
 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)
         slot = type_to_slot.get(p.preset_type)
         if slot is None:
         if slot is None:
             continue
             continue
-        extra: dict[str, str | None] = {}
+        preset = UnifiedPreset(id=str(p.id), name=p.name, source="local")
         if slot == "filament":
         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
     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]:
 def _parse_filament_metadata(setting_json: str | None) -> tuple[str | None, str | None]:
     """Extract first-slot ``filament_type`` and ``filament_colour`` from a
     """Extract first-slot ``filament_type`` and ``filament_colour`` from a
     stored preset JSON. OrcaSlicer stores both as arrays (per-extruder) — we
     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
     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)
 @router.get("/presets", response_model=UnifiedPresetsResponse)
 async def list_unified_presets(
 async def list_unified_presets(
     db: AsyncSession = Depends(get_db),
     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 sqlalchemy.orm import selectinload
 
 
 from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
 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.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
@@ -654,7 +655,7 @@ async def get_unlinked_spools(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
     _: 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)
     sm = await get_spoolman_settings(db)
     enabled, url = sm["enabled"], sm["url"]
     enabled, url = sm["enabled"], sm["url"]
     if not enabled:
     if not enabled:
@@ -671,27 +672,34 @@ async def get_unlinked_spools(
         raise HTTPException(status_code=503, detail="Spoolman is not reachable")
         raise HTTPException(status_code=503, detail="Spoolman is not reachable")
 
 
     spools = await client.get_spools()
     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:
     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
     return unlinked
 
 
@@ -842,6 +850,21 @@ async def link_spool(
 
 
     logger.info("Linked Spoolman spool %s to tag %s", spool_id, spool_tag)
     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)
     # Auto-configure AMS slot via MQTT (best-effort; tag link and slot assignment already persisted)
     if printer_context:
     if printer_context:
         p_id, a_id, t_id = 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,
     get_spoolman_client,
     init_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 (
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
     GENERIC_FILAMENT_IDS,
     MATERIAL_TEMPS,
     MATERIAL_TEMPS,
@@ -73,6 +74,89 @@ def _tag_cleared(val: str | None) -> bool:
     return val is None
     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:
 async def _get_client(db: AsyncSession) -> SpoolmanClient:
     """Return a validated Spoolman client (URL checked, health-checked) or raise an HTTP error."""
     """Return a validated Spoolman client (URL checked, health-checked) or raise an HTTP error."""
     result = await db.execute(select(Settings))
     result = await db.execute(select(Settings))
@@ -1170,6 +1254,19 @@ async def assign_spoolman_slot(
         logger.error("Failed to persist slot assignment: %s", exc)
         logger.error("Failed to persist slot assignment: %s", exc)
         raise HTTPException(status_code=500, detail="Failed to save slot assignment") from 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)
     mapped = _map_spoolman_spool(spool)
 
 
     # Fetch K-profiles before the MQTT try block so we can use async DB access.
     # 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.smart_plug import SmartPlug
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.services.discovery import is_running_in_docker
 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.network_utils import get_network_interfaces
 from backend.app.services.printer_manager import printer_manager
 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):
 class LogsResponse(BaseModel):
     """Response containing log entries."""
     """Response containing log entries."""
 
 
@@ -173,107 +170,6 @@ class LogsResponse(BaseModel):
     filtered_count: int
     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)
 @router.get("/logs", response_model=LogsResponse)
 async def get_logs(
 async def get_logs(
     limit: int = Query(200, ge=1, le=1000, description="Maximum number of entries to return"),
     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),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
 ):
 ):
     """Get recent application log entries with optional filtering."""
     """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(
     return LogsResponse(
         entries=entries,
         entries=entries,
@@ -1203,43 +1099,23 @@ async def _collect_support_info() -> dict:
     except Exception:
     except Exception:
         logger.debug("Failed to collect WebSocket info", exc_info=True)
         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:
 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")
             content = f.read().decode("utf-8", errors="replace")
 
 
     # Sanitize sensitive data
     # Sanitize sensitive data
-    content = _sanitize_log_content(content, sensitive_strings)
+    content = sanitize_log_content(content, sensitive_strings)
     return content.encode("utf-8")
     return content.encode("utf-8")
 
 
 
 
 async def _get_recent_sanitized_logs(max_lines: int = 200) -> str:
 async def _get_recent_sanitized_logs(max_lines: int = 200) -> str:
     """Get recent log lines, sanitized for inclusion in bug reports."""
     """Get recent log lines, sanitized for inclusion in bug reports."""
     # Collect sensitive strings from DB for redaction
     # Collect sensitive strings from DB for redaction
-    sensitive_strings: dict[str, str] = {}
     async with async_session() as db:
     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"
     log_file = settings.log_dir / "bambuddy.log"
     if not log_file.exists():
     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")
         content = log_file.read_text(encoding="utf-8", errors="replace")
         lines = content.splitlines()
         lines = content.splitlines()
         recent = "\n".join(lines[-max_lines:])
         recent = "\n".join(lines[-max_lines:])
-        return _sanitize_log_content(recent, sensitive_strings)
+        return sanitize_log_content(recent, sensitive_strings)
     except Exception:
     except Exception:
         logger.debug("Failed to read logs for bug report", exc_info=True)
         logger.debug("Failed to read logs for bug report", exc_info=True)
         return ""
         return ""
@@ -1322,31 +1178,7 @@ async def generate_support_bundle(
             )
             )
 
 
         # Collect known sensitive values for log redaction
         # 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
     # Collect support info
     support_info = await _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.project import Project
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.user import User
 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
 from backend.app.services.printer_manager import printer_manager
 
 
 router = APIRouter(prefix="/system", tags=["system"])
 router = APIRouter(prefix="/system", tags=["system"])
@@ -574,3 +576,17 @@ async def get_storage_usage(
     """Get storage usage breakdown for Bambuddy data directories."""
     """Get storage usage breakdown for Bambuddy data directories."""
     max_age_seconds = max(0, min(max_age_seconds, 3600))
     max_age_seconds = max(0, min(max_age_seconds, 3600))
     return await _get_storage_usage_cached(refresh=refresh, max_age_seconds=max_age_seconds)
     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.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.models.user import User
 from backend.app.models.user import User
+from backend.app.schemas.virtual_printer import VPDiagnosticResult
 
 
 # Imported at module scope so tests can patch
 # Imported at module scope so tests can patch
 # backend.app.api.routes.virtual_printers.tailscale_service.
 # 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}")
 @router.get("/{vp_id}")
 async def get_virtual_printer(
 async def get_virtual_printer(
     vp_id: int,
     vp_id: int,

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

@@ -6,7 +6,7 @@ from pathlib import Path
 from pydantic_settings import BaseSettings
 from pydantic_settings import BaseSettings
 
 
 # Application version - single source of truth
 # Application version - single source of truth
-APP_VERSION = "0.2.4.2"
+APP_VERSION = "0.2.4.3"
 GITHUB_REPO = "maziggy/bambuddy"
 GITHUB_REPO = "maziggy/bambuddy"
 BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")
 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 = '[]'"))
             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:
 async def _migrate_update_auto_link_constraint(conn) -> None:
     """Update the auto_link CHECK constraint to allow Fall C (custom email claim).
     """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
     # 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")
     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).
     # 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
     # 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
     # 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",
             "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():
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""
     """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 mimetypes as _mimetypes
 import os
 import os
 import posixpath
 import posixpath
+import secrets
 import time
 import time
 from contextlib import asynccontextmanager
 from contextlib import asynccontextmanager
 from datetime import datetime, timedelta, timezone
 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
                 # Update archive status to printing
                 archive.status = "printing"
                 archive.status = "printing"
                 archive.started_at = datetime.now(timezone.utc)
                 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
                 # #1403 follow-up: VP-queue archives are created with
                 # printer_id=None at queue-add time (we don't know which
                 # printer_id=None at queue-add time (we don't know which
                 # printer will run the job yet). When the print actually
                 # 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:
                 except Exception as e:
                     logger.warning("[SPOOLMAN] Failed to store tracking data: %s", 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
             return  # Skip creating a new archive
 
 
         # Check if there's already a "printing" archive for this printer/file
         # 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)
                 _load_objects_from_archive(existing_archive, printer_id, logger)
                 return
                 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)
             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(
                 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.status = "cancelled"
                 existing_archive.failure_reason = "Stale - print likely cancelled or failed without status update"
                 existing_archive.failure_reason = "Stale - print likely cancelled or failed without status update"
                 await db.commit()
                 await db.commit()
                 # Fall through to create new archive (don't return)
                 # Fall through to create new archive (don't return)
-                _existing_archive = None  # Clear so we don't use stale archive
             else:
             else:
                 logger.info(
                 logger.info(
                     f"Skipping duplicate - already have printing archive {existing_archive.id} for {check_name}"
                     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)
                     logger.warning("[SPOOLMAN] Failed to store tracking data: %s", e)
 
 
                 # Capture timelapse file baseline for snapshot-diff on completion
                 # 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:
         finally:
             # Keep temp_path around until print completes so the cover endpoint
             # Keep temp_path around until print completes so the cover endpoint
             # can reuse it (#972). Cache eviction in on_print_complete deletes
             # 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
     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):
 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.
     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)
     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):
 async def on_print_complete(printer_id: int, data: dict):
     """Handle print completion - update the archive status."""
     """Handle print completion - update the archive status."""
     import time
     import time
@@ -4667,6 +4767,7 @@ async def lifespan(app: FastAPI):
     printer_manager.set_status_change_callback(on_printer_status_change)
     printer_manager.set_status_change_callback(on_printer_status_change)
     printer_manager.set_print_start_callback(on_print_start)
     printer_manager.set_print_start_callback(on_print_start)
     printer_manager.set_print_complete_callback(on_print_complete)
     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)
     printer_manager.set_ams_change_callback(on_ams_change)
 
 
     # Rehydrate persisted awaiting-plate-clear gate (#961) so prompts survive restarts
     # 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)
     # L-2: Start periodic auth cleanup (stale TOTP + expired revoked JTIs)
     start_auth_cleanup()
     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
     # Initialize virtual printer manager and sync from DB
     from backend.app.services.virtual_printer import virtual_printer_manager
     from backend.app.services.virtual_printer import virtual_printer_manager
 
 
@@ -4907,6 +5014,9 @@ async def lifespan(app: FastAPI):
     stop_runtime_tracking()
     stop_runtime_tracking()
     stop_spoolbuddy_watchdog()
     stop_spoolbuddy_watchdog()
     stop_camera_cleanup()
     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
     # Tear down all camera fan-out broadcasters (#1089) so subscribers exit
     # cleanly rather than waiting on a queue that nothing will ever fill.
     # cleanly rather than waiting on a queue that nothing will ever fill.
     try:
     try:
@@ -5085,6 +5195,16 @@ def _frame_ancestors(default_value: str) -> str:
 @app.middleware("http")
 @app.middleware("http")
 async def security_headers_middleware(request, call_next):
 async def security_headers_middleware(request, call_next):
     """Add standard HTTP security headers to every response."""
     """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 = await call_next(request)
     response.headers["X-Content-Type-Options"] = "nosniff"
     response.headers["X-Content-Type-Options"] = "nosniff"
     # X-Frame-Options is the legacy cross-origin embedding control. Modern
     # 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"] = (
         response.headers["Content-Security-Policy"] = (
             "default-src 'self'; "
             "default-src 'self'; "
             "script-src 'self' 'unsafe-eval'; "
             "script-src 'self' 'unsafe-eval'; "
-            "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
+            "style-src 'self' 'unsafe-inline'; "
             "img-src 'self' data: blob:; "
             "img-src 'self' data: blob:; "
             "media-src 'self' blob:; "
             "media-src 'self' blob:; "
             "connect-src 'self' ws: wss:; "
             "connect-src 'self' ws: wss:; "
-            "font-src 'self' data: https://fonts.gstatic.com; "
+            "font-src 'self' data:; "
             "object-src 'none'; "
             "object-src 'none'; "
             "base-uri 'self'; "
             "base-uri 'self'; "
             "frame-src 'self' http: https:; " + _frame_ancestors("'self'")
             "frame-src 'self' http: https:; " + _frame_ancestors("'self'")
@@ -5137,12 +5257,12 @@ async def security_headers_middleware(request, call_next):
     else:
     else:
         response.headers["Content-Security-Policy"] = (
         response.headers["Content-Security-Policy"] = (
             "default-src 'self'; "
             "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:; "
             "img-src 'self' data: blob:; "
             "media-src 'self' blob:; "
             "media-src 'self' blob:; "
             "connect-src 'self' ws: wss:; "
             "connect-src 'self' ws: wss:; "
-            "font-src 'self' data: https://fonts.gstatic.com; "
+            "font-src 'self' data:; "
             "object-src 'none'; "
             "object-src 'none'; "
             "base-uri 'self'; "
             "base-uri 'self'; "
             "frame-src 'self' http: https:; " + _frame_ancestors("'none'")
             "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"),
             StaticFiles(directory=app_settings.static_dir / "icons"),
             name="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("/")
 @app.get("/")
@@ -5429,7 +5559,11 @@ async def health_check():
     return {"status": "healthy"}
     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():
 async def serve_manifest():
     """Serve PWA manifest."""
     """Serve PWA manifest."""
     manifest_file = app_settings.static_dir / "manifest.json"
     manifest_file = app_settings.static_dir / "manifest.json"
@@ -5438,7 +5572,7 @@ async def serve_manifest():
     return {"error": "Manifest not found"}
     return {"error": "Manifest not found"}
 
 
 
 
-@app.get("/sw.js")
+@app.api_route("/sw.js", methods=["GET", "HEAD"])
 async def serve_service_worker():
 async def serve_service_worker():
     """Serve service worker."""
     """Serve service worker."""
     sw_file = app_settings.static_dir / "sw.js"
     sw_file = app_settings.static_dir / "sw.js"
@@ -5451,7 +5585,7 @@ async def serve_service_worker():
     return {"error": "Service worker not found"}
     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():
 async def serve_sw_register():
     """Serve the service-worker registration bootstrap script.
     """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: pending, printing, completed, failed, skipped, cancelled
     status: Mapped[str] = mapped_column(String(20), default="pending")
     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
     # Tracking
     started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     completed_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
     require_previous_success: bool
     auto_off_after: bool
     auto_off_after: bool
     manual_start: 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
     ams_mapping: list[int] | None = None
     plate_id: int | None = None  # Plate ID for multi-plate 3MF files
     plate_id: int | None = None  # Plate ID for multi-plate 3MF files
     # Print options
     # Print options

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

@@ -1,11 +1,29 @@
 from datetime import datetime
 from datetime import datetime
 
 
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, field_validator
 
 
 
 
 class PrinterBase(BaseModel):
 class PrinterBase(BaseModel):
     name: str = Field(..., min_length=1, max_length=100)
     name: str = Field(..., min_length=1, max_length=100)
     serial_number: str = Field(..., min_length=1, max_length=50)
     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(
     ip_address: str = Field(
         ...,
         ...,
         max_length=253,
         max_length=253,
@@ -306,3 +324,39 @@ class PrinterStatus(BaseModel):
     # Set for every active print regardless of plate count; the frontend decides
     # 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.
     # whether to render it based on current_archive_id's is_multi_plate flag.
     current_plate_id: int | None = None
     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(
     plate: int | None = Field(
         default=None,
         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(
     export_3mf: bool = Field(
         default=False,
         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
     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
     and color. Populated when the underlying preset JSON exposes them; left
     as ``None`` on bundled profiles where colour is a runtime spool attribute.
     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
     id: str
@@ -38,6 +47,7 @@ class UnifiedPreset(BaseModel):
     source: Literal["cloud", "local", "standard"]
     source: Literal["cloud", "local", "standard"]
     filament_type: str | None = None
     filament_type: str | None = None
     filament_colour: str | None = None
     filament_colour: str | None = None
+    compatible_printers: list[str] | None = None
 
 
 
 
 class UnifiedPresetsBySlot(BaseModel):
 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:
             if match:
                 self.metadata["total_layers"] = int(match.group(1))
                 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)
             # 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"
             # Format: "; printer_model = Bambu Lab X1 Carbon" or "; printer_model = X1C"
             if "sliced_for_model" not in self.metadata:
             if "sliced_for_model" not in self.metadata:
@@ -509,6 +523,18 @@ class ThreeMFParser:
                 "Metadata/plate_1.png",
                 "Metadata/plate_1.png",
                 "Metadata/thumbnail.png",
                 "Metadata/thumbnail.png",
                 "Metadata/model_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
     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
     timeout waiting for transfer completion. Set skip_session_reuse=True for A1
     printers to skip SSL on the data channel (control channel remains encrypted).
     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)
         super().__init__(*args, **kwargs)
         self._sock = None
         self._sock = None
         self.skip_session_reuse = skip_session_reuse
         self.skip_session_reuse = skip_session_reuse
         self.ssl_context = ssl.create_default_context()
         self.ssl_context = ssl.create_default_context()
         self.ssl_context.check_hostname = False
         self.ssl_context.check_hostname = False
         self.ssl_context.verify_mode = ssl.CERT_NONE
         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):
     def connect(self, host="", port=990, timeout=-999, source_address=None):
         """Connect to host, wrapping socket in TLS immediately (implicit FTPS)."""
         """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)."""
         """Connect to the printer FTP server (implicit FTPS on port 990)."""
         try:
         try:
             use_prot_c = self._should_use_prot_c()
             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(
             logger.debug(
                 f"FTP connecting to {self.ip_address}:{self.FTP_PORT} "
                 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)
             self._ftp.connect(self.ip_address, self.FTP_PORT, timeout=self.timeout)
             logger.debug("FTP connected, logging in as bblp")
             logger.debug("FTP connected, logging in as bblp")
             self._ftp.login("bblp", self.access_code)
             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_layer_change: Callable[[int], None] | None = None,
         on_bed_temp_update: Callable[[float], None] | None = None,
         on_bed_temp_update: Callable[[float], None] | None = None,
         on_drying_complete: Callable[[int], 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.ip_address = ip_address
         self.serial_number = serial_number
         self.serial_number = serial_number
@@ -348,6 +349,14 @@ class BambuMQTTClient:
         # the drying cycle just finished (auto- or manually-triggered).
         # the drying cycle just finished (auto- or manually-triggered).
         # Receives the AMS id of the unit that finished drying.
         # Receives the AMS id of the unit that finished drying.
         self.on_drying_complete = on_drying_complete
         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.
         # Per-AMS previous dry_time, used to detect the falling edge above.
         # Seeded lazily as we observe each AMS unit.
         # Seeded lazily as we observe each AMS unit.
         self._previous_dry_times: dict[int, int] = {}
         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._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_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)
         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._is_dual_nozzle: bool = False  # Set when device.extruder.info has >= 2 entries
         self._message_log: deque[MQTTLogEntry] = deque(maxlen=100)
         self._message_log: deque[MQTTLogEntry] = deque(maxlen=100)
         self._logging_enabled: bool = False
         self._logging_enabled: bool = False
         self._last_message_time: float = 0.0  # Track when we last received a message
         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
         # Raw-message fan-out for VP MQTT bridge (non-proxy modes republish the
         # printer's pushes verbatim to slicers connected to a virtual printer).
         # printer's pushes verbatim to slicers connected to a virtual printer).
         # Handlers receive (topic, payload_bytes) before JSON parsing.
         # Handlers receive (topic, payload_bytes) before JSON parsing.
@@ -467,6 +489,22 @@ class BambuMQTTClient:
             logger.warning(
             logger.warning(
                 f"[{self.serial_number}] Connection stale - no message for {now - self._last_message_time:.1f}s, forcing reconnect"
                 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._last_stale_reconnect = now
             self.state.connected = False
             self.state.connected = False
             if self.on_state_change:
             if self.on_state_change:
@@ -587,6 +625,7 @@ class BambuMQTTClient:
             self._dev_mode_probe_time = 0.0
             self._dev_mode_probe_time = 0.0
             self._dev_mode_probe_failures = 0
             self._dev_mode_probe_failures = 0
             self._connect_time = time.monotonic()
             self._connect_time = time.monotonic()
+            self._report_messages_since_connect = 0
             self._last_ams_cmd_time = 0.0
             self._last_ams_cmd_time = 0.0
             self._ams_cmd_unanswered = 0
             self._ams_cmd_unanswered = 0
             client.subscribe(self.topic_subscribe)
             client.subscribe(self.topic_subscribe)
@@ -729,6 +768,11 @@ class BambuMQTTClient:
                 self._handle_request_message(payload)
                 self._handle_request_message(payload)
                 return
                 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
             # Log message if logging is enabled
             if self._logging_enabled:
             if self._logging_enabled:
                 self._message_log.append(
                 self._message_log.append(
@@ -918,6 +962,12 @@ class BambuMQTTClient:
                 logger.debug("[%s] Received command response: %s", self.serial_number, cmd)
                 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"):
                 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)
                     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
                 # Check for developer mode probe response
                 if (
                 if (
                     cmd == "ams_filament_setting"
                     cmd == "ams_filament_setting"
@@ -1708,8 +1758,15 @@ class BambuMQTTClient:
                             merged_trays.append(merged_tray)
                             merged_trays.append(merged_tray)
                         else:
                         else:
                             merged_trays.append(new_tray)
                             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:
                 elif existing_unit:
                     # Partial update without tray data: merge new fields into existing
                     # Partial update without tray data: merge new fields into existing
                     # unit to preserve tray, sn, sw_ver, and other accumulated data.
                     # unit to preserve tray, sn, sw_ver, and other accumulated data.
@@ -1866,10 +1923,18 @@ class BambuMQTTClient:
                     continue
                     continue
                 if ams_id < 0:
                 if ams_id < 0:
                     continue
                     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:
                 try:
-                    current = int(ams_unit.get("dry_time") or 0)
+                    current = int(raw_dry_time)
                 except (TypeError, ValueError):
                 except (TypeError, ValueError):
-                    current = 0
+                    continue
                 previous = self._previous_dry_times.get(ams_id, 0)
                 previous = self._previous_dry_times.get(ams_id, 0)
                 self._previous_dry_times[ams_id] = current
                 self._previous_dry_times[ams_id] = current
                 if previous > 0 and current == 0:
                 if previous > 0 and current == 0:
@@ -2853,6 +2918,7 @@ class BambuMQTTClient:
         )
         )
 
 
         # Track RUNNING state for more robust completion detection
         # Track RUNNING state for more robust completion detection
+        running_first_observed = False
         if self.state.state == "RUNNING" and current_file:
         if self.state.state == "RUNNING" and current_file:
             if not self._was_running:
             if not self._was_running:
                 logger.debug("[%s] Now tracking RUNNING state for %s", self.serial_number, current_file)
                 logger.debug("[%s] Now tracking RUNNING state for %s", self.serial_number, current_file)
@@ -2860,6 +2926,14 @@ class BambuMQTTClient:
                 if self.state.timelapse:
                 if self.state.timelapse:
                     self._timelapse_during_print = True
                     self._timelapse_during_print = True
                     logger.debug("[%s] Timelapse detected when entering RUNNING state", self.serial_number)
                     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._was_running = True
             self._completion_triggered = False
             self._completion_triggered = False
 
 
@@ -2905,6 +2979,25 @@ class BambuMQTTClient:
                     "ams_mapping": self._captured_ams_mapping,
                     "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)
         # Detect print completion (FINISH = success, FAILED = error, IDLE = aborted)
         # Use _was_running flag in addition to _previous_gcode_state for more robust detection
         # 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
             use_ams: Use AMS for automatic filament changes
         """
         """
         if self._client and self.state.connected:
         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,
             # Dual-nozzle routing for external spool (254 = deputy/left,
             # 255 = main/right) and the use_ams=False fallback. H2S is in the
             # 255 = main/right) and the use_ams=False fallback. H2S is in the
             # H2 firmware family but is single-nozzle, despite sharing serial
             # 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
             # model name for the brief window after connect before push data
             # arrives. _is_dual_nozzle only ever flips False→True, so it's safe
             # arrives. _is_dual_nozzle only ever flips False→True, so it's safe
             # as the primary signal.
             # 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)
             # Build ams_mapping2 from ams_mapping (detailed format with ams_id/slot_id)
             ams_mapping2 = []
             ams_mapping2 = []
@@ -3312,6 +3401,9 @@ class BambuMQTTClient:
             # Modulo keeps uniqueness within a ~24-day wrap window; `or 1` guards
             # Modulo keeps uniqueness within a ~24-day wrap window; `or 1` guards
             # the (astronomically unlikely) zero case since task_id=0 is rejected.
             # the (astronomically unlikely) zero case since task_id=0 is rejected.
             submission_id = str(int(time.time() * 1000) % 2_147_483_647 or 1)
             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 = {
             command = {
                 "print": {
                 "print": {
@@ -3322,15 +3414,20 @@ class BambuMQTTClient:
                     "file": filename,
                     "file": filename,
                     "md5": "",
                     "md5": "",
                     "bed_type": "auto",
                     "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,
                     "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,
                     "use_ams": use_ams,
                     "cfg": "0",
                     "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,
                     "extrude_cali_manual_mode": 0,
                     "nozzle_offset_cali": 2,
                     "nozzle_offset_cali": 2,
                     "subtask_name": filename.replace(".3mf", "").replace(".gcode", ""),
                     "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-specific parameter adjustments
             # P2S printer doesn't support vibration calibration like X1/P1 series
             # P2S printer doesn't support vibration calibration like X1/P1 series
             if self.model and self.model.upper().strip() in ("P2S", "N7"):
             if self.model and self.model.upper().strip() in ("P2S", "N7"):
@@ -3703,14 +3794,17 @@ class BambuMQTTClient:
                 "close_power_conflict": False,
                 "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(
         logger.info(
-            "[%s] Sent drying command: ams_id=%d, temp=%d, duration=%d, mode=%d",
+            "[%s] Sent ams_filament_drying: %s",
             self.serial_number,
             self.serial_number,
-            ams_id,
-            temp,
-            duration,
-            mode,
+            wire_json,
         )
         )
         return True
         return True
 
 
@@ -4060,9 +4154,9 @@ class BambuMQTTClient:
         # Prefer runtime detection from device.extruder.info; fall back to
         # Prefer runtime detection from device.extruder.info; fall back to
         # model name. H2S is single-nozzle but shares serial prefix "094" with
         # model name. H2S is single-nozzle but shares serial prefix "094" with
         # H2D, so a prefix-only check misclassified it (#1386).
         # 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:
         if is_dual_nozzle:
             # H2D format: uses extruder_id, nozzle_id, nozzle_diameter
             # 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 shutil
 import ssl
 import ssl
 import struct
 import struct
+import subprocess
 import uuid
 import uuid
 from datetime import datetime
 from datetime import datetime
 from pathlib import Path
 from pathlib import Path
@@ -24,6 +25,9 @@ JPEG_END = b"\xff\xd9"
 # Cache the ffmpeg path after first lookup
 # Cache the ffmpeg path after first lookup
 _ffmpeg_path: str | None = None
 _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).
 # 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.
 # The cleanup task in routes/camera.py checks this set to avoid killing active captures.
 _active_capture_pids: set[int] = set()
 _active_capture_pids: set[int] = set()
@@ -66,6 +70,69 @@ def get_ffmpeg_path() -> str | None:
     return ffmpeg_path
     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:
 def supports_rtsp(model: str | None) -> bool:
     """Check if printer model supports RTSP camera streaming.
     """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
 # AFTER alias normalisation, so internal SSDP codes ("N7") resolve via
 # ``_MODEL_ALIASES`` below.
 # ``_MODEL_ALIASES`` below.
 _PROFILES: dict[str, CameraProfile] = {
 _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(
     "P2S": CameraProfile(
         probesize=1_000_000,
         probesize=1_000_000,
         analyzeduration=500_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")
         logger.error("ffmpeg not found - required for RTSP streaming")
         return
         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://
     # If the URL uses rtsps://, set up a TLS proxy so ffmpeg uses plain rtsp://
     proxy_server = None
     proxy_server = None
     effective_url = url
     effective_url = url
@@ -715,7 +717,9 @@ async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
         "tcp",
         "tcp",
         "-rtsp_flags",
         "-rtsp_flags",
         "prefer_tcp",
         "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",
         "30000000",
         "-buffer_size",
         "-buffer_size",
         "1024000",
         "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)
                         _collect_filaments(plate_elem, filaments)
                         break
                         break
             else:
             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"])
             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 ----
     # ---- 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 {
         return {
             "is_running": self._task is not None and not self._task.done(),
             "is_running": self._task is not None and not self._task.done(),
             "last_error": self._last_error,
             "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 import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
 
 
 from backend.app.core.config import settings
 from backend.app.core.config import settings
 from backend.app.core.database import async_session, run_with_retry
 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.printer import Printer
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
 from backend.app.models.smart_plug import SmartPlug
 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 (
 from backend.app.services.bambu_ftp import (
     cache_3mf_download,
     cache_3mf_download,
     delete_file_async,
     delete_file_async,
@@ -25,6 +28,7 @@ from backend.app.services.bambu_ftp import (
     upload_file_async,
     upload_file_async,
     with_ftp_retry,
     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.notification_service import notification_service
 from backend.app.services.printer_manager import printer_manager, supports_drying
 from backend.app.services.printer_manager import printer_manager, supports_drying
 from backend.app.services.smart_plug_manager import smart_plug_manager
 from backend.app.services.smart_plug_manager import smart_plug_manager
@@ -300,6 +304,13 @@ class PrintScheduler:
                             )
                             )
                             await db.commit()
                             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
                     # Start the print
                     await self._start_print(db, item)
                     await self._start_print(db, item)
                     busy_printers.add(item.printer_id)
                     busy_printers.add(item.printer_id)
@@ -423,6 +434,10 @@ class PrintScheduler:
                                 )
                                 )
                                 await db.commit()
                                 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)
                         await self._start_print(db, item)
                         busy_printers.add(printer_id)
                         busy_printers.add(printer_id)
 
 
@@ -792,6 +807,22 @@ class PrintScheduler:
         # Get filament requirements from source file
         # Get filament requirements from source file
         filament_reqs = await self._get_filament_requirements(db, item)
         filament_reqs = await self._get_filament_requirements(db, item)
         if not filament_reqs:
         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)
             logger.debug("No filament requirements found for queue item %s", item.id)
             return None
             return None
 
 
@@ -827,8 +858,46 @@ class PrintScheduler:
         # Check if user prefers lowest remaining filament when multiple spools match
         # Check if user prefers lowest remaining filament when multiple spools match
         prefer_lowest = await self._get_bool_setting(db, "prefer_lowest_filament")
         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
         # 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:
     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
         """Resolve the queue item's source 3MF and parse the per-slot
@@ -960,8 +1029,156 @@ class PrintScheduler:
         except ValueError:
         except ValueError:
             return False
             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(
     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:
     ) -> list[int] | None:
         """Match required filaments to loaded filaments and build AMS mapping.
         """Match required filaments to loaded filaments and build AMS mapping.
 
 
@@ -1008,9 +1225,11 @@ class PrintScheduler:
             if req_nozzle_id is not None:
             if req_nozzle_id is not None:
                 available = [f for f in available if f.get("extruder_id") == req_nozzle_id]
                 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:
             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
             # Check if tray_info_idx is unique among available trays
             if req_tray_info_idx:
             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]}"
                         f"using color matching among trays: {[f['global_tray_id'] for f in idx_matches]}"
                     )
                     )
                     if prefer_lowest:
                     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
                     # Use color matching within this subset
                     for f in idx_matches:
                     for f in idx_matches:
                         f_color = f.get("color", "")
                         f_color = f.get("color", "")
@@ -1605,6 +1824,55 @@ class PrintScheduler:
         result = await db.execute(select(Printer).where(Printer.id == printer_id))
         result = await db.execute(select(Printer).where(Printer.id == printer_id))
         return result.scalar_one_or_none()
         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):
     async def _start_print(self, db: AsyncSession, item: PrintQueueItem):
         """Upload file and start print for a queue item.
         """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._printer_info: dict[int, PrinterInfo] = {}  # Cache printer name/serial for callbacks
         self._on_print_start: Callable[[int, dict], None] | None = None
         self._on_print_start: Callable[[int, dict], None] | None = None
         self._on_print_complete: 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_status_change: Callable[[int, PrinterState], None] | None = None
         self._on_ams_change: Callable[[int, list], None] | None = None
         self._on_ams_change: Callable[[int, list], None] | None = None
         self._on_layer_change: Callable[[int, int], 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."""
         """Set callback for print completion events."""
         self._on_print_complete = callback
         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]):
     def set_status_change_callback(self, callback: Callable[[int, PrinterState], None]):
         """Set callback for status change events."""
         """Set callback for status change events."""
         self._on_status_change = callback
         self._on_status_change = callback
@@ -372,6 +382,10 @@ class PrinterManager:
             if self._on_print_complete:
             if self._on_print_complete:
                 self._schedule_async(self._on_print_complete(printer_id, data))
                 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):
         def on_ams_change(ams_data: list):
             if self._on_ams_change:
             if self._on_ams_change:
                 self._schedule_async(self._on_ams_change(printer_id, ams_data))
                 self._schedule_async(self._on_ams_change(printer_id, ams_data))
@@ -400,6 +414,7 @@ class PrinterManager:
             on_layer_change=on_layer_change,
             on_layer_change=on_layer_change,
             on_bed_temp_update=on_bed_temp_update,
             on_bed_temp_update=on_bed_temp_update,
             on_drying_complete=on_drying_complete,
             on_drying_complete=on_drying_complete,
+            on_print_running_observed=on_print_running_observed,
         )
         )
 
 
         client.connect()
         client.connect()
@@ -616,13 +631,31 @@ class PrinterManager:
             return self._clients[printer_id].request_status_update()
             return self._clients[printer_id].request_status_update()
         return False
         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(
     async def test_connection(
         self,
         self,
         ip_address: str,
         ip_address: str,
         serial_number: str,
         serial_number: str,
         access_code: str,
         access_code: str,
     ) -> dict:
     ) -> 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(
         client = BambuMQTTClient(
             ip_address=ip_address,
             ip_address=ip_address,
             serial_number=serial_number,
             serial_number=serial_number,
@@ -631,7 +664,9 @@ class PrinterManager:
 
 
         try:
         try:
             client.connect()
             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 = {
             result = {
                 "success": client.state.connected,
                 "success": client.state.connected,
@@ -639,7 +674,9 @@ class PrinterManager:
                 "model": client.state.raw_data.get("device_model"),
                 "model": client.state.raw_data.get("device_model"),
             }
             }
         finally:
         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
         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],
         filament_profile_jsons: list[str],
         plate: int | None = None,
         plate: int | None = None,
         export_3mf: bool = False,
         export_3mf: bool = False,
+        arrange: bool = False,
         request_id: str | None = None,
         request_id: str | None = None,
         on_progress: Callable[[dict], None] | None = None,
         on_progress: Callable[[dict], None] | None = None,
     ) -> SliceResult:
     ) -> SliceResult:
@@ -349,6 +350,14 @@ class SlicerApiService:
         slicing service joins them as semicolon-separated
         slicing service joins them as semicolon-separated
         ``--load-filaments`` for the OrcaSlicer / BambuStudio CLI.
         ``--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
         ``request_id``: when supplied, the sidecar wires --pipe to a
         per-request FIFO and publishes structured JSON progress events to
         per-request FIFO and publishes structured JSON progress events to
         its in-memory ProgressStore under this id. Bambuddy's slice
         its in-memory ProgressStore under this id. Bambuddy's slice
@@ -380,6 +389,11 @@ class SlicerApiService:
             data["plate"] = str(plate)
             data["plate"] = str(plate)
         if export_3mf:
         if export_3mf:
             data["exportType"] = "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:
         if request_id is not None:
             data["requestId"] = request_id
             data["requestId"] = request_id
 
 
@@ -435,6 +449,7 @@ class SlicerApiService:
         filament_names: list[str],
         filament_names: list[str],
         plate: int | None = None,
         plate: int | None = None,
         export_3mf: bool = False,
         export_3mf: bool = False,
+        arrange: bool = False,
         bed_type: str | None = None,
         bed_type: str | None = None,
         request_id: str | None = None,
         request_id: str | None = None,
         on_progress: Callable[[dict], None] | None = None,
         on_progress: Callable[[dict], None] | None = None,
@@ -477,6 +492,11 @@ class SlicerApiService:
             data["plate"] = str(plate)
             data["plate"] = str(plate)
         if export_3mf:
         if export_3mf:
             data["exportType"] = "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:
         if bed_type is not None:
             # #1337: bed-plate override flows through to the sidecar as a
             # #1337: bed-plate override flows through to the sidecar as a
             # standalone field. The sidecar wraps this as --curr_bed_type on
             # 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.core.websocket import ws_manager
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.spool_assignment import SpoolAssignment
 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.bambu_mqtt import PrinterState
 from backend.app.services.notification_service import notification_service
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import printer_manager
 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 = await db.get(Printer, printer_id)
             printer_name = printer.name if printer else f"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 = {
             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)
             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:
     if not printer_serial:
         return ""
         return ""
     ams_id, tray_id = _global_tray_id_to_ams_slot(global_tray_id)
     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)}"
     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
     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(
 async def _report_spool_usage_for_slots(
     client,
     client,
     filament_usage_items: list[tuple[int, float]],
     filament_usage_items: list[tuple[int, float]],
@@ -351,9 +384,23 @@ async def _report_spool_usage_for_slots(
     slot_to_tray: list | None,
     slot_to_tray: list | None,
     method_label: str,
     method_label: str,
     printer_serial: str = "",
     printer_serial: str = "",
+    printer_id: int | None = None,
+    slot_colors_out: dict[int, str] | None = None,
 ) -> int:
 ) -> int:
     """Report usage to Spoolman for a list of (slot_id, grams) pairs.
     """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.
     Returns number of spools successfully updated.
     """
     """
     spools_updated = 0
     spools_updated = 0
@@ -377,22 +424,62 @@ async def _report_spool_usage_for_slots(
             is_external,
             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)
         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
             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:
         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
             spools_updated += 1
         except (SpoolmanNotFoundError, SpoolmanClientError, SpoolmanUnavailableError) as exc:
         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
     return spools_updated
 
 
@@ -515,7 +602,13 @@ async def _report_partial_usage(
                 usage_items.append((slot_id, grams_used))
                 usage_items.append((slot_id, grams_used))
 
 
             spools_updated = await _report_spool_usage_for_slots(
             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:
             if spools_updated > 0:
                 logger.info("[SPOOLMAN] Reported partial usage to %s spool(s) using G-code data", spools_updated)
                 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))
             usage_items.append((slot_id, partial_used_g))
 
 
     spools_updated = await _report_spool_usage_for_slots(
     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:
     if spools_updated > 0:
         logger.info("[SPOOLMAN] Reported partial usage to %s spool(s) using linear interpolation", spools_updated)
         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)
         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]
         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(
         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:
         if spools_updated == 0:
             logger.info("[SPOOLMAN] Archive %s: no spools updated", archive_id)
             logger.info("[SPOOLMAN] Archive %s: no spools updated", archive_id)
         else:
         else:
             logger.info("[SPOOLMAN] Archive %s: updated %s spool(s)", archive_id, spools_updated)
             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)
         logger.warning("STL thumbnail generation unavailable (missing dependencies): %s", e)
         return None
         return None
     except Exception as e:
     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
         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
     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(
 def _match_slots_by_color(
     filament_usage: list[dict],
     filament_usage: list[dict],
     ams_raw: dict | list | None,
     ams_raw: dict | list | None,
@@ -589,6 +643,10 @@ async def on_print_complete(
                         "tray_id": assign_tray_id,
                         "tray_id": assign_tray_id,
                         "material": spool.material,
                         "material": spool.material,
                         "cost": cost,
                         "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
     from backend.app.utils.threemf_tools import extract_filament_usage_from_3mf
 
 
     file_path: Path | None = threemf_path
     file_path: Path | None = threemf_path
+    archive: PrintArchive | None = None
 
 
     if file_path is None and archive_id:
     if file_path is None and archive_id:
         result = await db.execute(select(PrintArchive).where(PrintArchive.id == 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,
                         "tray_id": seg_tray_id,
                         "material": spool.material,
                         "material": spool.material,
                         "cost": cost,
                         "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,
                 "tray_id": tray_id,
                 "material": spool.material,
                 "material": spool.material,
                 "cost": cost,
                 "cost": cost,
+                "slot_id": slot_id,
+                "color": _spool_color_to_hex(spool.rgba),
             }
             }
         )
         )
 
 
@@ -1285,4 +1348,22 @@ async def _track_from_3mf(
             status,
             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
     return results

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

@@ -330,6 +330,28 @@ class CertificateService:
         logger.info("  Printer: CN=%s", self.serial)
         logger.info("  Printer: CN=%s", self.serial)
         return self.cert_path, self.key_path
         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:
     def delete_printer_certificate(self) -> None:
         """Delete only the printer certificate (preserves CA)."""
         """Delete only the printer certificate (preserves CA)."""
         for path in [self.cert_path, self.key_path]:
         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."""
         """Inject the global printer_manager so non-proxy VPs can mirror their target's MQTT stream."""
         self._printer_manager = printer_manager
         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
     @property
     def is_enabled(self) -> bool:
     def is_enabled(self) -> bool:
         """Check if any virtual printer is running."""
         """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:
 def has_ethernet(model: str | None) -> bool:
     """Return True if the printer model has an ethernet port."""
     """Return True if the printer model has an ethernet port."""
     if not model:
     if not model:
@@ -145,6 +170,14 @@ def has_ethernet(model: str | None) -> bool:
     return normalized in ETHERNET_MODELS
     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:
 def get_rod_type(model: str | None) -> str | None:
     """Return the rod/rail type for a printer model.
     """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
     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:
 def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | None:
     """Extract per-slot nozzle/extruder mapping from a 3MF file.
     """Extract per-slot nozzle/extruder mapping from a 3MF file.
 
 
@@ -609,38 +648,6 @@ def inject_gcode_into_3mf(
         return None
         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]:
 def extract_project_filaments_from_3mf(zf: zipfile.ZipFile) -> list[dict]:
     """Project-wide AMS slot config from ``Metadata/project_settings.config``.
     """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.status_code == 200
         assert response.json()["external_url"] is None
         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
     # Delete endpoints
     # ========================================================================
     # ========================================================================

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

@@ -22,6 +22,7 @@ import httpx
 import pytest
 import pytest
 from httpx import AsyncClient
 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.core.config import settings as app_settings
 from backend.app.models.library import LibraryFile
 from backend.app.models.library import LibraryFile
 from backend.app.models.local_preset import LocalPreset
 from backend.app.models.local_preset import LocalPreset
@@ -775,3 +776,948 @@ class TestSliceJobs:
         slice_dispatch._jobs.clear()
         slice_dispatch._jobs.clear()
         r = await async_client.get("/api/v1/slice-jobs/999999")
         r = await async_client.get("/api/v1/slice-jobs/999999")
         assert r.status_code == 404
         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")
         response = await async_client.post(f"/api/v1/queue/{item.id}/start")
         assert response.status_code == 400
         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:
 class TestQueueCancelEndpoint:
     """Tests for the /queue/{item_id}/cancel endpoint."""
     """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")
         resp = await async_client.get("/api/v1/auth/status")
         assert resp.headers.get("X-Content-Type-Options") == "nosniff"
         assert resp.headers.get("X-Content-Type-Options") == "nosniff"
         assert resp.headers.get("Referrer-Policy") == "strict-origin-when-cross-origin"
         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(
     async def test_get_unlinked_spools_success(
         self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
         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 = {
         mock_spool = {
             "id": 1,
             "id": 1,
             "remaining_weight": 800,
             "remaining_weight": 800,
             "used_weight": 200,
             "used_weight": 200,
-            "extra": {},  # No tag = unlinked
+            "extra": {"tag": '"04A1B2C3D4E5F6"'},  # OpenSpoolman-style NFC tag value
             "filament": {
             "filament": {
                 "id": 1,
                 "id": 1,
                 "name": "PLA Basic",
                 "name": "PLA Basic",
@@ -232,35 +237,40 @@ class TestSpoolmanAPI:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @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,
             "id": 1,
             "remaining_weight": 800,
             "remaining_weight": 800,
             "used_weight": 200,
             "used_weight": 200,
-            "extra": {"tag": '"ABC123"'},  # Has tag = linked
+            "extra": {"tag": '"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"'},
             "filament": {"id": 1, "name": "PLA Red", "material": "PLA", "color_hex": "FF0000"},
             "filament": {"id": 1, "name": "PLA Red", "material": "PLA", "color_hex": "FF0000"},
         }
         }
-
-        # Mock spool without tag (unlinked)
-        mock_spool_unlinked = {
+        mock_spool_unassigned = {
             "id": 2,
             "id": 2,
             "remaining_weight": 900,
             "remaining_weight": 900,
             "used_weight": 100,
             "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"},
             "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")
         response = await async_client.get("/api/v1/spoolman/spools/unlinked")
         assert response.status_code == 200
         assert response.status_code == 200
         data = response.json()
         data = response.json()
         assert len(data) == 1
         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
     # Linked Spools Tests
@@ -1159,6 +1169,8 @@ class TestLinkSpoolMqttConfigure:
         mock_client.merge_spool_extra = AsyncMock(
         mock_client.merge_spool_extra = AsyncMock(
             return_value={"id": 5, "extra": {"tag": '"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"'}}
             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(
         mock_client.get_spool = AsyncMock(
             return_value={
             return_value={
                 "id": 5,
                 "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.base_url = "http://localhost:7912"
     client.health_check = AsyncMock(return_value=True)
     client.health_check = AsyncMock(return_value=True)
     client.get_spool = AsyncMock(return_value=SAMPLE_SPOOL)
     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(
     with patch(
         "backend.app.api.routes.spoolman_inventory._get_client",
         "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.base_url = "http://localhost:7912"
     client.health_check = AsyncMock(return_value=True)
     client.health_check = AsyncMock(return_value=True)
     client.get_spool = AsyncMock(return_value=SAMPLE_SPOOL)
     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(
     with patch(
         "backend.app.api.routes.spoolman_inventory._get_client",
         "backend.app.api.routes.spoolman_inventory._get_client",
@@ -573,3 +577,33 @@ class TestCascadeDeletePrinter:
             select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == test_printer.id)
             select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == test_printer.id)
         )
         )
         assert post.scalars().all() == []
         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.base_url = "http://localhost:7912"
     client.health_check = AsyncMock(return_value=True)
     client.health_check = AsyncMock(return_value=True)
     client.get_spool = AsyncMock(return_value=SAMPLE_SPOOL)
     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(
     with patch(
         "backend.app.api.routes.spoolman_inventory._get_client",
         "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
     @pytest.mark.integration
     async def test_get_logs_empty_file(self, async_client: AsyncClient):
     async def test_get_logs_empty_file(self, async_client: AsyncClient):
         """Verify get logs returns empty list when log file doesn't exist."""
         """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")
             mock_settings.log_dir = Path("/nonexistent/path")
 
 
             response = await async_client.get("/api/v1/support/logs")
             response = await async_client.get("/api/v1/support/logs")
@@ -46,7 +46,7 @@ class TestSupportLogsAPI:
             log_file = Path(tmpdir) / "bambuddy.log"
             log_file = Path(tmpdir) / "bambuddy.log"
             log_file.write_text(log_content)
             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)
                 mock_settings.log_dir = Path(tmpdir)
 
 
                 response = await async_client.get("/api/v1/support/logs")
                 response = await async_client.get("/api/v1/support/logs")
@@ -76,7 +76,7 @@ class TestSupportLogsAPI:
             log_file = Path(tmpdir) / "bambuddy.log"
             log_file = Path(tmpdir) / "bambuddy.log"
             log_file.write_text(log_content)
             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)
                 mock_settings.log_dir = Path(tmpdir)
 
 
                 response = await async_client.get("/api/v1/support/logs?level=ERROR")
                 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 = Path(tmpdir) / "bambuddy.log"
             log_file.write_text(log_content)
             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)
                 mock_settings.log_dir = Path(tmpdir)
 
 
                 response = await async_client.get("/api/v1/support/logs?search=printer")
                 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 = Path(tmpdir) / "bambuddy.log"
             log_file.write_text(log_content)
             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)
                 mock_settings.log_dir = Path(tmpdir)
 
 
                 response = await async_client.get("/api/v1/support/logs?limit=2")
                 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 = Path(tmpdir) / "bambuddy.log"
             log_file.write_text(log_content)
             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)
                 mock_settings.log_dir = Path(tmpdir)
 
 
                 response = await async_client.get("/api/v1/support/logs")
                 response = await async_client.get("/api/v1/support/logs")
@@ -214,7 +214,7 @@ class TestLogParsingHelpers:
 
 
     def test_parse_log_line_valid(self):
     def test_parse_log_line_valid(self):
         """Verify _parse_log_line handles valid log lines."""
         """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"
         line = "2024-01-15 10:30:45,123 INFO [backend.app.main] Server started"
         entry = _parse_log_line(line)
         entry = _parse_log_line(line)
@@ -227,7 +227,7 @@ class TestLogParsingHelpers:
 
 
     def test_parse_log_line_invalid(self):
     def test_parse_log_line_invalid(self):
         """Verify _parse_log_line returns None for invalid lines."""
         """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"
         line = "This is not a valid log line"
         entry = _parse_log_line(line)
         entry = _parse_log_line(line)
@@ -236,7 +236,7 @@ class TestLogParsingHelpers:
 
 
     def test_parse_log_line_with_brackets_in_message(self):
     def test_parse_log_line_with_brackets_in_message(self):
         """Verify _parse_log_line handles messages with brackets."""
         """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]"
         line = "2024-01-15 10:30:45,123 INFO [backend.app.main] Processing [item 1] and [item 2]"
         entry = _parse_log_line(line)
         entry = _parse_log_line(line)
@@ -246,7 +246,7 @@ class TestLogParsingHelpers:
 
 
     def test_parse_log_line_all_levels(self):
     def test_parse_log_line_all_levels(self):
         """Verify _parse_log_line handles all log levels."""
         """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"]
         levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
         for level in levels:
         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
         result = format_uptime(30)  # 30 seconds
         assert result == "< 1m"
         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 enable_resp.json()["tailscale_disabled"] is False
         assert disable_resp.status_code == 200
         assert disable_resp.status_code == 200
         assert disable_resp.json()["tailscale_disabled"] is True
         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:
         for path in expected_thumbnail_paths:
             assert "png" in path.lower()
             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:
 class TestPrintableObjectsExtraction:
     """Tests for extracting printable objects count from 3MF files."""
     """Tests for extracting printable objects count from 3MF files."""
@@ -643,3 +685,47 @@ class TestReprintCostCalculation:
         # After 3 prints (1 original + 2 reprints)
         # After 3 prints (1 original + 2 reprints)
         total_after_3_prints = round(single_print_cost * 3, 2)
         total_after_3_prints = round(single_print_cost * 3, 2)
         assert total_after_3_prints == 6.0
         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},
             {"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.model = "X2D"
         mqtt_client.start_print(
         mqtt_client.start_print(
@@ -3749,24 +3749,24 @@ class TestStartPrintAmsMapping:
         )
         )
 
 
         cmd = self._get_published_command(mqtt_client)
         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.model = "P2S"
         mqtt_client.start_print("test.3mf", timelapse=True, flow_cali=False)
         mqtt_client.start_print("test.3mf", timelapse=True, flow_cali=False)
 
 
         cmd = self._get_published_command(mqtt_client)
         cmd = self._get_published_command(mqtt_client)
         assert cmd["timelapse"] is True
         assert cmd["timelapse"] is True
         assert cmd["flow_cali"] is False
         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):
     def test_h2s_single_external_spool_uses_main_id(self, mqtt_client):
         """H2S is single-nozzle (#1386): external spool (254) → ams_id=255.
         """H2S is single-nozzle (#1386): external spool (254) → ams_id=255.
@@ -3797,11 +3797,14 @@ class TestStartPrintAmsMapping:
         cmd = self._get_published_command(mqtt_client)
         cmd = self._get_published_command(mqtt_client)
         assert cmd["use_ams"] is False
         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.model = "H2S"
         mqtt_client.start_print(
         mqtt_client.start_print(
@@ -3814,11 +3817,14 @@ class TestStartPrintAmsMapping:
         )
         )
 
 
         cmd = self._get_published_command(mqtt_client)
         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:
 class TestStartPrintUniqueIdentityFields:
@@ -3900,6 +3906,24 @@ class TestStartPrintUniqueIdentityFields:
         assert int(cmd["task_id"]) > 0
         assert int(cmd["task_id"]) > 0
         assert len(cmd["task_id"]) <= 64
         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):
     def test_submission_id_fits_signed_int32(self, mqtt_client):
         """Regression for #1042: P1S firmware clamps oversized task identity
         """Regression for #1042: P1S firmware clamps oversized task identity
         fields to signed int32 max (2**31-1 = 2147483647). If we send raw
         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.state.connected is True
         assert mqtt_client._stale_reconnecting is False
         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):
     def test_on_disconnect_skipped_during_stale_reconnect(self, mqtt_client):
         """_on_disconnect should not broadcast state when _stale_reconnecting is set."""
         """_on_disconnect should not broadcast state when _stale_reconnecting is set."""
         state_changes = []
         state_changes = []
@@ -5132,3 +5197,214 @@ class TestDryingCompleteCallback:
         # And finishes.
         # And finishes.
         mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
         mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
         assert mqtt_client._drying_events == [0, 0]
         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.probesize >= 1_000_000
         assert profile.analyzeduration >= 500_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):
     def test_p2s_internal_code_resolves_to_p2s_profile(self):
         """SSDP internal codes (e.g. `N7` for P2S) must resolve to the
         """SSDP internal codes (e.g. `N7` for P2S) must resolve to the
         same profile as their display name. Otherwise printers freshly
         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 len(out) == 1
         assert out[0]["type"] == "PLA"
         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):
     def test_returns_empty_list_for_unparseable_file(self, tmp_path: Path):
         f = tmp_path / "bad.3mf"
         f = tmp_path / "bad.3mf"
         f.write_bytes(b"not a zip")
         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
             mock_instance.state.connected = False
             MockClient.return_value = mock_instance
             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["success"] is False
             assert result["state"] is None
             assert result["state"] is None
             mock_instance.disconnect.assert_called_once()
             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)
     # 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'name="exportType"' in body
         assert b"3mf" 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
     @pytest.mark.asyncio
     async def test_multi_filament_sends_one_part_per_profile(self):
     async def test_multi_filament_sends_one_part_per_profile(self):
         # Multi-color slicing requires N filament profiles, in plate-slot
         # Multi-color slicing requires N filament profiles, in plate-slot
@@ -683,6 +739,34 @@ class TestSliceWithBundle:
         # Bundle id round-trips on the wire.
         # Bundle id round-trips on the wire.
         assert b"2bd8722dd20a837e" in body
         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
     @pytest.mark.asyncio
     async def test_404_unknown_preset_maps_to_input_error(self):
     async def test_404_unknown_preset_maps_to_input_error(self):
         # Sidecar returns 404 when bundle exists but preset name doesn't.
         # 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:
 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._printer = SimpleNamespace(name=printer_name)
-        self._assignments = assignments
+        self._legacy = legacy or []
+        self._spoolman = spoolman or []
 
 
     async def __aenter__(self):
     async def __aenter__(self):
         return self
         return self
@@ -32,7 +41,10 @@ class _FakeSession:
         return self._printer
         return self._printer
 
 
     async def execute(self, statement):
     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
 @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_id"] == 1
     assert notify_kwargs["printer_name"] == "Printer A"
     assert notify_kwargs["printer_name"] == "Printer A"
     assert notify_kwargs["missing_slots"] == [{"slot": "A2", "profile": "Unknown", "color": "Unknown"}]
     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
 import pytest
 
 
 from backend.app.services.spoolman_tracking import (
 from backend.app.services.spoolman_tracking import (
+    _apply_spool_colors_to_archive,
     _get_fallback_spool_tag,
     _get_fallback_spool_tag,
     _global_tray_id_to_ams_slot,
     _global_tray_id_to_ams_slot,
     _hash_serial_to_hex32,
     _hash_serial_to_hex32,
@@ -300,3 +301,69 @@ class TestStorePrintData:
 
 
         # Tracking row was inserted — the fix is working.
         # Tracking row was inserted — the fix is working.
         db.add.assert_called_once()
         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 (
 from backend.app.services.usage_tracker import (
     PrintSession,
     PrintSession,
     _active_sessions,
     _active_sessions,
+    _archive_colors_from_spools,
+    _spool_color_to_hex,
     _track_from_3mf,
     _track_from_3mf,
     on_print_complete,
     on_print_complete,
     on_print_start,
     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."""
     """Create a mock Spool object."""
     spool = MagicMock()
     spool = MagicMock()
     spool.id = id
     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.last_used = None
     spool.cost_per_kg = None
     spool.cost_per_kg = None
     spool.material = "PLA"
     spool.material = "PLA"
+    spool.rgba = rgba
     return spool
     return spool
 
 
 
 
@@ -766,3 +769,194 @@ class TestSpoolAssignmentSnapshot:
         assert results[0]["weight_used"] == 14.2
         assert results[0]["weight_used"] == 14.2
         # Spool weight should be updated: 50 + 14.2 = 64.2
         # Spool weight should be updated: 50 + 14.2 = 64.2
         assert spool.weight_used == 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.
 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
 _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)
   built with gcc 14 (Debian 14.2.0-19)
@@ -66,3 +68,55 @@ def test_drops_blank_lines():
 def test_banner_only_returns_empty():
 def test_banner_only_returns_empty():
     """If ffmpeg prints only the banner (no errors), the summary should be empty."""
     """If ffmpeg prints only the banner (no errors), the summary should be empty."""
     assert _summarize_ffmpeg_stderr(_FAKE_BANNER) == ""
     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.
         """Verify all Python modules can be imported without errors.
 
 
         This catches syntax errors and missing dependencies.
         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 importlib
-        import sys
 
 
         # Modules to test importing
         # Modules to test importing
         modules = [
         modules = [
@@ -235,9 +253,6 @@ class TestModuleImports:
         errors = []
         errors = []
         for module_name in modules:
         for module_name in modules:
             try:
             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)
                 importlib.import_module(module_name)
             except Exception as e:
             except Exception as e:
                 errors.append(f"{module_name}: {type(e).__name__}: {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 s["history"] == []
         assert "low" in s["thresholds"] and "high" in s["thresholds"]
         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:
 class TestTestConnection:
     @pytest.mark.asyncio
     @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_print_registered_at,
     _expected_prints,
     _expected_prints,
     _print_ams_mappings,
     _print_ams_mappings,
+    _timelapse_baselines,
     register_expected_print,
     register_expected_print,
 )
 )
 
 
@@ -33,12 +34,14 @@ def _clear_dicts():
     _expected_print_creators.clear()
     _expected_print_creators.clear()
     _print_ams_mappings.clear()
     _print_ams_mappings.clear()
     _active_prints.clear()
     _active_prints.clear()
+    _timelapse_baselines.clear()
     yield
     yield
     _expected_prints.clear()
     _expected_prints.clear()
     _expected_print_registered_at.clear()
     _expected_print_registered_at.clear()
     _expected_print_creators.clear()
     _expected_print_creators.clear()
     _print_ams_mappings.clear()
     _print_ams_mappings.clear()
     _active_prints.clear()
     _active_prints.clear()
+    _timelapse_baselines.clear()
 
 
 
 
 @pytest.mark.asyncio
 @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.printer_id == 7
         assert mock_archive.status == "printing"
         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,
     STEEL_ROD_MODELS,
     get_rod_type,
     get_rod_type,
     has_ethernet,
     has_ethernet,
+    is_dual_nozzle_model,
     normalize_printer_model,
     normalize_printer_model,
     normalize_printer_model_id,
     normalize_printer_model_id,
 )
 )
@@ -104,3 +105,28 @@ class TestX2DModel:
         assert "N6" not in CARBON_ROD_MODELS
         assert "N6" not in CARBON_ROD_MODELS
         assert "X2D" in STEEL_ROD_MODELS
         assert "X2D" in STEEL_ROD_MODELS
         assert "N6" 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%)
         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:
 class TestBuildLoadedFilamentsTrayInfoIdx:
     """Test tray_info_idx extraction in _build_loaded_filaments."""
     """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)
             await sp.delete_slicer_bundle("missing", db=MagicMock(), _=None)
         assert exc.value.status_code == 404
         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

Деякі файли не було показано, через те що забагато файлів було змінено