Переглянути джерело

Merge branch 'dev' into feature/upload-prefer-filename-for-name

MartinNYHC 1 місяць тому
батько
коміт
e1ad0cb9c4
100 змінених файлів з 5864 додано та 803 видалено
  1. 36 1
      .github/workflows/ci.yml
  2. 35 2
      .github/workflows/security.yml
  3. 3 0
      BACKERS.md
  4. 7 1
      CHANGELOG.md
  5. 1 1
      Dockerfile
  6. 7 2
      backend/app/api/routes/archives.py
  7. 55 7
      backend/app/api/routes/camera.py
  8. 27 0
      backend/app/api/routes/inventory.py
  9. 108 20
      backend/app/api/routes/library.py
  10. 18 5
      backend/app/api/routes/pipeline_runs.py
  11. 42 12
      backend/app/api/routes/printers.py
  12. 3 3
      backend/app/api/routes/settings.py
  13. 12 7
      backend/app/api/routes/slice_jobs.py
  14. 63 8
      backend/app/api/routes/slicer_presets.py
  15. 3 2
      backend/app/api/routes/smart_plugs.py
  16. 1 1
      backend/app/core/config.py
  17. 86 0
      backend/app/core/database.py
  18. 55 0
      backend/app/main.py
  19. 16 0
      backend/app/models/library.py
  20. 6 4
      backend/app/models/print_queue.py
  21. 8 0
      backend/app/models/smart_plug.py
  22. 4 0
      backend/app/schemas/library.py
  23. 45 17
      backend/app/schemas/print_queue.py
  24. 11 8
      backend/app/schemas/settings.py
  25. 6 0
      backend/app/schemas/smart_plug.py
  26. 50 10
      backend/app/services/bambu_cloud.py
  27. 483 43
      backend/app/services/bambu_mqtt.py
  28. 46 8
      backend/app/services/external_camera.py
  29. 13 6
      backend/app/services/git_providers/gitea.py
  30. 6 2
      backend/app/services/ldap_service.py
  31. 18 0
      backend/app/services/log_reader.py
  32. 16 4
      backend/app/services/makerworld.py
  33. 58 14
      backend/app/services/print_scheduler.py
  34. 34 6
      backend/app/services/printer_manager.py
  35. 6 0
      backend/app/services/slice_dispatch.py
  36. 33 12
      backend/app/services/slicer_3mf_convert.py
  37. 60 22
      backend/app/services/slicer_api.py
  38. 16 7
      backend/app/services/smart_plug_manager.py
  39. 3 0
      backend/app/services/spool_assignment_notifications.py
  40. 73 5
      backend/app/services/virtual_printer/manager.py
  41. 138 0
      backend/tests/integration/test_external_folders_api.py
  42. 7 7
      backend/tests/integration/test_library_slice_api.py
  43. 220 0
      backend/tests/integration/test_ownership_permissions.py
  44. 30 30
      backend/tests/integration/test_print_queue_api.py
  45. 108 0
      backend/tests/integration/test_printers_api.py
  46. 32 14
      backend/tests/integration/test_settings_api.py
  47. 3 3
      backend/tests/integration/test_webhook_start_print.py
  48. 406 32
      backend/tests/unit/services/test_bambu_mqtt.py
  49. 26 0
      backend/tests/unit/services/test_makerworld.py
  50. 49 3
      backend/tests/unit/services/test_printer_manager.py
  51. 76 4
      backend/tests/unit/services/test_slicer_3mf_convert.py
  52. 101 1
      backend/tests/unit/services/test_slicer_api.py
  53. 155 0
      backend/tests/unit/services/test_smart_plug_manager.py
  54. 146 13
      backend/tests/unit/services/test_virtual_printer.py
  55. 227 0
      backend/tests/unit/test_a2l_ams_lite_2619.py
  56. 76 0
      backend/tests/unit/test_accessory_plug_queue_stall_2629.py
  57. 151 0
      backend/tests/unit/test_assignment_verification_2582.py
  58. 199 0
      backend/tests/unit/test_camera_usb_stream_cleanup.py
  59. 57 2
      backend/tests/unit/test_cloud_token_expiry.py
  60. 30 0
      backend/tests/unit/test_git_providers.py
  61. 13 0
      backend/tests/unit/test_launcher_shutdown_config.py
  62. 3 3
      backend/tests/unit/test_scheduler_cancel_race.py
  63. 3 3
      backend/tests/unit/test_scheduler_cleanup_library.py
  64. 150 0
      backend/tests/unit/test_scheduler_force_color_ams_fallback.py
  65. 3 3
      backend/tests/unit/test_scheduler_nozzle_mismatch.py
  66. 42 0
      backend/tests/unit/test_scheduler_power_plug_pick_2629.py
  67. 147 0
      backend/tests/unit/test_slicer_presets.py
  68. 184 0
      backend/tests/unit/test_smart_plug_power_flag_migration_2629.py
  69. 49 0
      backend/tests/unit/test_support_helpers.py
  70. 11 0
      backend/tests/unit/test_systemd_backup_paths.py
  71. 35 32
      backend/tests/unit/test_vp_mqtt_bridge.py
  72. 29 33
      frontend/package-lock.json
  73. 5 2
      frontend/package.json
  74. 1 0
      frontend/scripts/check-i18n-parity.mjs
  75. 124 0
      frontend/src/__tests__/components/FilamentHoverCard.test.tsx
  76. 43 0
      frontend/src/__tests__/components/FilamentMapping.test.tsx
  77. 28 0
      frontend/src/__tests__/components/ModelViewerModal.test.tsx
  78. 2 2
      frontend/src/__tests__/components/PrintModal.test.tsx
  79. 67 0
      frontend/src/__tests__/components/SkipObjectsModal.test.ts
  80. 80 0
      frontend/src/__tests__/components/SliceModal.test.tsx
  81. 28 0
      frontend/src/__tests__/components/SmartPlugCard.test.tsx
  82. 56 0
      frontend/src/__tests__/components/spool-form/PAProfileSectionNozzle.test.tsx
  83. 50 0
      frontend/src/__tests__/hooks/useWebSocket.test.ts
  84. 32 0
      frontend/src/__tests__/pages/FileManagerPage.test.tsx
  85. 112 6
      frontend/src/__tests__/pages/QueuePage.test.tsx
  86. 25 0
      frontend/src/__tests__/utils/getAmsLabel.test.ts
  87. 54 0
      frontend/src/__tests__/utils/installedNozzleDiameters.test.ts
  88. 41 1
      frontend/src/__tests__/utils/printer.test.ts
  89. 124 0
      frontend/src/__tests__/utils/slicerPrinterMatch.test.ts
  90. 33 16
      frontend/src/api/client.ts
  91. 24 0
      frontend/src/components/AddSmartPlugModal.tsx
  92. 27 1
      frontend/src/components/FilamentHoverCard.tsx
  93. 2 2
      frontend/src/components/ModelViewerModal.tsx
  94. 6 3
      frontend/src/components/PrintModal/FilamentMapping.tsx
  95. 73 24
      frontend/src/components/PrintModal/PrintOptions.tsx
  96. 5 2
      frontend/src/components/PrintModal/PrinterSelector.tsx
  97. 9 7
      frontend/src/components/PrintModal/types.ts
  98. 3 2
      frontend/src/components/PrinterQueueWidget.tsx
  99. 308 312
      frontend/src/components/SkipObjectsModal.tsx
  100. 23 0
      frontend/src/components/SmartPlugCard.tsx

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

@@ -201,15 +201,50 @@ jobs:
               if path and not info.get('dev') and not info.get('devOptional'):
               if path and not info.get('dev') and not info.get('devOptional'):
                   prod.add(path.split('node_modules/')[-1])
                   prod.add(path.split('node_modules/')[-1])
           vulns = data.get('vulnerabilities', {})
           vulns = data.get('vulnerabilities', {})
+          # Documented advisory exceptions: high/critical findings whose only offered
+          # 'fix' is a semver-major change and which do not apply to how Bambuddy ships.
+          # Keyed by GHSA id; RE-REVIEW ON EVERY react-router BUMP.
+          #   GHSA-qwww-vcr4-c8h2 - React Router RSC-mode CSRF. Bambuddy is a Vite SPA
+          #   using BrowserRouter with no RSC runtime (@react-router/server is NOT
+          #   installed), so the vulnerable code path is unreachable. No non-major fix
+          #   exists (7.18.1 is the most-patched 7.x - it clears 14 other advisories that
+          #   older 7.x carry - and the RSC fix landed only in the 8.3.0 major). react-router
+          #   /-dom are pinned to 7.18.1 in package.json. If a non-major fix ships, this stops
+          #   being exempt (major-only guard below) and the gate fails until we take it.
+          ALLOWLIST = {'GHSA-qwww-vcr4-c8h2'}
+          def advisory_ids(name, seen=None):
+              seen = seen if seen is not None else set()
+              if name in seen:
+                  return set()
+              seen.add(name)
+              ids = set()
+              for item in vulns.get(name, {}).get('via', []):
+                  if isinstance(item, dict):
+                      url = item.get('url', '')
+                      if '/advisories/' in url:
+                          ids.add(url.rsplit('/', 1)[-1])
+                  elif isinstance(item, str):
+                      ids |= advisory_ids(item, seen)
+              return ids
+          def fix_is_major(v):
+              fa = v.get('fixAvailable')
+              return isinstance(fa, dict) and fa.get('isSemVerMajor')
+          def exempt(name, v):
+              ids = advisory_ids(name)
+              return bool(ids) and ids <= ALLOWLIST and fix_is_major(v)
           fixable = {n: v for n, v in vulns.items()
           fixable = {n: v for n, v in vulns.items()
-                     if n in prod and v.get('severity') in ('high', 'critical') and v.get('fixAvailable')}
+                     if n in prod and v.get('severity') in ('high', 'critical')
+                     and v.get('fixAvailable') and not exempt(n, v)}
           skipped = len(vulns) - len({n: v for n, v in vulns.items() if n in prod})
           skipped = len(vulns) - len({n: v for n, v in vulns.items() if n in prod})
           if fixable:
           if fixable:
               for name, v in fixable.items():
               for name, v in fixable.items():
                   print(f'FIXABLE {v[\"severity\"].upper()}: {name}')
                   print(f'FIXABLE {v[\"severity\"].upper()}: {name}')
               sys.exit(1)
               sys.exit(1)
           total = sum(1 for n, v in vulns.items() if n in prod and v.get('severity') in ('high', 'critical'))
           total = sum(1 for n, v in vulns.items() if n in prod and v.get('severity') in ('high', 'critical'))
+          exempted = sorted(n for n, v in vulns.items() if n in prod and exempt(n, v))
           print(f'npm audit: {total} high/critical (0 fixable), {len(vulns)} total ({skipped} npm-internal filtered)')
           print(f'npm audit: {total} high/critical (0 fixable), {len(vulns)} total ({skipped} npm-internal filtered)')
+          if exempted:
+              print('exempted (documented, unreachable): ' + ', '.join(exempted))
           "
           "
 
 
   frontend-typecheck:
   frontend-typecheck:

+ 35 - 2
.github/workflows/security.yml

@@ -308,13 +308,46 @@ jobs:
               }
               }
             }
             }
             const vulns = results.vulnerabilities || {};
             const vulns = results.vulnerabilities || {};
+            // Documented advisory exceptions (keyed by GHSA id) - see ci.yml for the
+            // full rationale and the matching hard gate. GHSA-qwww-vcr4-c8h2: React
+            // Router RSC-mode CSRF, not reachable from Bambuddy's BrowserRouter SPA
+            // (@react-router/server not installed); react-router/-dom pinned to 7.18.1
+            // (the most-patched 7.x), no non-major fix exists. Auto-surfaces again if a
+            // non-major fix ships.
+            const ALLOWLIST = new Set(['GHSA-qwww-vcr4-c8h2']);
+            function advisoryIds(name, seen) {
+              seen = seen || new Set();
+              if (seen.has(name)) return new Set();
+              seen.add(name);
+              const ids = new Set();
+              for (const item of (vulns[name] || {}).via || []) {
+                if (item && typeof item === 'object') {
+                  const url = item.url || '';
+                  if (url.includes('/advisories/')) ids.add(url.split('/').pop());
+                } else if (typeof item === 'string') {
+                  for (const id of advisoryIds(item, seen)) ids.add(id);
+                }
+              }
+              return ids;
+            }
+            function fixIsMajor(info) {
+              const fa = info.fixAvailable;
+              return fa && typeof fa === 'object' && fa.isSemVerMajor;
+            }
+            function exempt(name, info) {
+              const ids = advisoryIds(name);
+              return ids.size > 0 && [...ids].every(id => ALLOWLIST.has(id)) && fixIsMajor(info);
+            }
             const filtered = {};
             const filtered = {};
+            const flagged = {};
             for (const [name, info] of Object.entries(vulns)) {
             for (const [name, info] of Object.entries(vulns)) {
-              if (prodDeps.has(name)) filtered[name] = info;
+              if (!prodDeps.has(name)) continue;
+              filtered[name] = info;
+              if (!exempt(name, info)) flagged[name] = info;
             }
             }
             results.vulnerabilities = filtered;
             results.vulnerabilities = filtered;
             fs.writeFileSync('npm-audit-results.json', JSON.stringify(results, null, 2));
             fs.writeFileSync('npm-audit-results.json', JSON.stringify(results, null, 2));
-            const count = Object.keys(filtered).length;
+            const count = Object.keys(flagged).length;
             console.log(count > 0
             console.log(count > 0
               ? count + ' production vulnerabilities found'
               ? count + ' production vulnerabilities found'
               : 'No production vulnerabilities (filtered ' + Object.keys(vulns).length + ' npm-internal entries)');
               : 'No production vulnerabilities (filtered ' + Object.keys(vulns).length + ' npm-internal entries)');

+ 3 - 0
BACKERS.md

@@ -65,6 +65,9 @@ If you sponsor and your name isn't here within 48h, please write an email to mar
 - [@hazzardr](https://github.com/hazzardr)
 - [@hazzardr](https://github.com/hazzardr)
 - [@Shihchiun](https://github.com/Shihchiun)
 - [@Shihchiun](https://github.com/Shihchiun)
 - [@kycrna](https://github.com/kycrna)
 - [@kycrna](https://github.com/kycrna)
+- [@iljur](https://github.com/iljur)
+- [@bhamiltoncx](https://github.com/bhamiltoncx)
+- [@g7ufo](https://github.com/g7ufo)
 
 
 ---
 ---
 
 

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


+ 1 - 1
Dockerfile

@@ -54,7 +54,7 @@ RUN setcap cap_net_bind_service=+ep "$(readlink -f /usr/local/bin/python3)"
 # wheels (so a hostile wheel could hijack stdlib imports during install).
 # wheels (so a hostile wheel could hijack stdlib imports during install).
 COPY requirements.txt ./
 COPY requirements.txt ./
 RUN --mount=type=cache,target=/root/.cache/pip \
 RUN --mount=type=cache,target=/root/.cache/pip \
-    pip install --root-user-action=ignore --upgrade 'pip>=26.1' \
+    pip install --root-user-action=ignore --upgrade 'pip>=26.1.2' \
  && pip install --root-user-action=ignore -r requirements.txt
  && pip install --root-user-action=ignore -r requirements.txt
 
 
 # Copy backend
 # Copy backend

+ 7 - 2
backend/app/api/routes/archives.py

@@ -3992,8 +3992,12 @@ async def slice_archive(
     )
     )
 
 
     archive = await db.get(PrintArchive, archive_id)
     archive = await db.get(PrintArchive, archive_id)
-    if archive is None:
-        raise HTTPException(status_code=404, detail="Archive not found")
+    # Per-row ownership gate — mirror the archive read routes. LIBRARY_UPLOAD
+    # alone let a READ_OWN caller slice another user's archive by raw id even
+    # though GET on that id returned 404. API-key / auth-disabled callers
+    # (current_user is None) keep can_read_all=True — no per-row identity.
+    can_read_all = current_user is None or current_user.has_permission(Permission.ARCHIVES_READ_ALL.value)
+    archive = _ensure_archive_visible(archive, current_user, can_read_all)
 
 
     src_relative = archive.source_3mf_path or archive.file_path
     src_relative = archive.source_3mf_path or archive.file_path
     if not src_relative:
     if not src_relative:
@@ -4064,6 +4068,7 @@ async def slice_archive(
         kind="archive",
         kind="archive",
         source_id=archive.id,
         source_id=archive.id,
         source_name=archive.print_name or archive.filename or f"archive {archive.id}",
         source_name=archive.print_name or archive.filename or f"archive {archive.id}",
+        owner_id=user_id,
         run=_run,
         run=_run,
     )
     )
     return {
     return {

+ 55 - 7
backend/app/api/routes/camera.py

@@ -666,6 +666,7 @@ async def camera_stream(
     # Check for external camera first
     # Check for external camera first
     if printer.external_camera_enabled and printer.external_camera_url:
     if printer.external_camera_enabled and printer.external_camera_url:
         import time
         import time
+        import uuid
 
 
         from backend.app.services.external_camera import generate_mjpeg_stream
         from backend.app.services.external_camera import generate_mjpeg_stream
 
 
@@ -675,21 +676,60 @@ async def camera_stream(
             "Using external camera (%s) for printer %s at %s fps", printer.external_camera_type, printer_id, fps
             "Using external camera (%s) for printer %s at %s fps", printer.external_camera_type, printer_id, fps
         )
         )
 
 
+        # Register the stream into the SAME registries the RTSP/chamber paths use
+        # (#2675) so `/camera/stop` and cleanup_orphaned_streams can find and kill
+        # a leaked ffmpeg holding a USB device open. Before this, external streams
+        # only tracked _active_external_streams and were structurally invisible to
+        # both the stop endpoint and the janitor. The stream_id keeps the
+        # `{printer_id}-` prefix both scanners key on, plus a unique suffix so two
+        # concurrent viewers of one printer don't clobber each other's entry.
+        stream_id = f"{printer_id}-ext-{uuid.uuid4().hex[:8]}"
+        stop_event = asyncio.Event()
+        _disconnect_events[stream_id] = stop_event
         # Track stream start
         # Track stream start
         _stream_start_times[printer_id] = time.time()
         _stream_start_times[printer_id] = time.time()
         _active_external_streams.add(printer_id)
         _active_external_streams.add(printer_id)
 
 
+        # Mutable holder so the wrapper's finally can unregister whatever process
+        # is currently registered (the RTSP path may respawn across reconnects).
+        current_proc: dict[str, asyncio.subprocess.Process] = {}
+
+        def _register_external_process(proc: asyncio.subprocess.Process) -> None:
+            prev = current_proc.get("proc")
+            if prev is not None and prev.pid != proc.pid:
+                _spawned_ffmpeg_pids.pop(prev.pid, None)
+            current_proc["proc"] = proc
+            _active_streams[stream_id] = proc
+            _spawned_ffmpeg_pids[proc.pid] = time.time()
+            _stream_last_frame_times[stream_id] = time.time()
+
         async def external_stream_wrapper():
         async def external_stream_wrapper():
             """Wrap external stream to track start/stop and update frame times."""
             """Wrap external stream to track start/stop and update frame times."""
             try:
             try:
                 async for frame in generate_mjpeg_stream(
                 async for frame in generate_mjpeg_stream(
-                    printer.external_camera_url, printer.external_camera_type, fps
+                    printer.external_camera_url,
+                    printer.external_camera_type,
+                    fps,
+                    on_process=_register_external_process,
+                    stop_event=stop_event,
                 ):
                 ):
                     # generate_mjpeg_stream already handles rate limiting;
                     # generate_mjpeg_stream already handles rate limiting;
-                    # just track frame times for stall detection
-                    _last_frame_times[printer_id] = time.time()
+                    # track frame times (per-printer + per-stream) for stall detection
+                    now = time.time()
+                    _last_frame_times[printer_id] = now
+                    _stream_last_frame_times[stream_id] = now
                     yield frame
                     yield frame
             finally:
             finally:
+                # Best-effort unregister. If an abrupt disconnect skips this
+                # finally, the registry entries persist — which is exactly what
+                # lets the stop endpoint / janitor reap the leaked process.
+                stop_event.set()
+                proc = current_proc.get("proc")
+                if proc is not None:
+                    _spawned_ffmpeg_pids.pop(proc.pid, None)
+                _active_streams.pop(stream_id, None)
+                _disconnect_events.pop(stream_id, None)
+                _stream_last_frame_times.pop(stream_id, None)
                 _active_external_streams.discard(printer_id)
                 _active_external_streams.discard(printer_id)
                 logger.info("External camera stream ended for printer %s", printer_id)
                 logger.info("External camera stream ended for printer %s", printer_id)
 
 
@@ -1529,9 +1569,14 @@ async def delete_reference(
 
 
 
 
 def _scan_bambu_ffmpeg_pids() -> list[int]:
 def _scan_bambu_ffmpeg_pids() -> list[int]:
-    """Scan /proc for ffmpeg processes with Bambu RTSP URLs.
+    """Scan /proc for ffmpeg processes that are ours.
+
+    Two shapes are matched, both unambiguously Bambuddy's:
+    - Bambu RTSP: no other software connects to ``rtsp(s)://bblp:``.
+    - External USB (V4L2): an ffmpeg spawned with ``-f v4l2`` is our USB camera
+      stream (#2675). Only orphans are killed — the caller excludes PIDs still in
+      ``_active_streams``, so a live USB stream (now registered there) is spared.
 
 
-    These are definitely ours — no other software connects to rtsp(s)://bblp:.
     This catches orphans that survive app restarts and are not in any tracking dict.
     This catches orphans that survive app restarts and are not in any tracking dict.
     """
     """
     import os
     import os
@@ -1544,8 +1589,11 @@ def _scan_bambu_ffmpeg_pids() -> list[int]:
             try:
             try:
                 with open(f"/proc/{entry}/cmdline", "rb") as f:
                 with open(f"/proc/{entry}/cmdline", "rb") as f:
                     cmdline = f.read()
                     cmdline = f.read()
-                # Match both rtsp:// (via TLS proxy) and rtsps:// (direct)
-                if b"ffmpeg" in cmdline and (b"rtsp://bblp:" in cmdline or b"rtsps://bblp:" in cmdline):
+                if b"ffmpeg" not in cmdline:
+                    continue
+                # Match both rtsp:// (via TLS proxy) and rtsps:// (direct), plus
+                # the `-f v4l2` input flag our USB camera command always carries.
+                if b"rtsp://bblp:" in cmdline or b"rtsps://bblp:" in cmdline or b"v4l2" in cmdline:
                     pids.append(int(entry))
                     pids.append(int(entry))
             except (OSError, PermissionError, ValueError):
             except (OSError, PermissionError, ValueError):
                 continue
                 continue

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

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

+ 108 - 20
backend/app/api/routes/library.py

@@ -751,24 +751,34 @@ async def list_folders(
     )
     )
     file_counts = dict(file_counts_result.all())
     file_counts = dict(file_counts_result.all())
 
 
-    # Latest immediate-child file activity per folder (#1770). Sibling of the
-    # file_counts subquery — same WHERE clause, MAX(updated_at) instead of
-    # COUNT(id). Subfolder descent is not aggregated here; the frontend's
-    # "sort by recent activity" mode is satisfied by immediate-parent bubble.
+    # Latest immediate-child file activity per folder (#1770/#2680). Real on-disk
+    # mtime when we have it (external scans populate ``fs_modified_at``), else the
+    # DB ``updated_at`` — COALESCE so external rows scanned before this field
+    # existed, and internal uploads, still contribute a signal. This is the
+    # per-folder *leaf* value; subtree descent is aggregated recursively below.
     latest_file_activity_result = await db.execute(
     latest_file_activity_result = await db.execute(
-        select(LibraryFile.folder_id, func.max(LibraryFile.updated_at))
+        select(
+            LibraryFile.folder_id,
+            func.max(func.coalesce(LibraryFile.fs_modified_at, LibraryFile.updated_at)),
+        )
         .where(LibraryFile.folder_id.isnot(None), LibraryFile.deleted_at.is_(None))
         .where(LibraryFile.folder_id.isnot(None), LibraryFile.deleted_at.is_(None))
         .group_by(LibraryFile.folder_id)
         .group_by(LibraryFile.folder_id)
     )
     )
     latest_file_activity = dict(latest_file_activity_result.all())
     latest_file_activity = dict(latest_file_activity_result.all())
 
 
-    # Build tree structure
+    # Build tree structure. Each folder's initial ``latest_activity_at`` is its own
+    # leaf activity: the newer of its real directory mtime (fallback updated_at)
+    # and its immediate files' mtime. The recursive bubble below then rolls each
+    # subtree's newest descendant up to its ancestors (#2680 — sorting must match
+    # ``ls -t`` recursively, so a freshly-added deep file lifts every parent).
     folder_map = {}
     folder_map = {}
     root_folders = []
     root_folders = []
 
 
     for folder, project_name, archive_name in rows:
     for folder, project_name, archive_name in rows:
+        own_activity = folder.fs_modified_at or folder.updated_at
         latest_file = latest_file_activity.get(folder.id)
         latest_file = latest_file_activity.get(folder.id)
-        latest_activity_at = max(folder.updated_at, latest_file) if latest_file is not None else folder.updated_at
+        if latest_file is not None and latest_file > own_activity:
+            own_activity = latest_file
         folder_item = FolderTreeItem(
         folder_item = FolderTreeItem(
             id=folder.id,
             id=folder.id,
             name=folder.name,
             name=folder.name,
@@ -781,7 +791,7 @@ async def list_folders(
             external_path=folder.external_path,
             external_path=folder.external_path,
             external_readonly=folder.external_readonly,
             external_readonly=folder.external_readonly,
             file_count=file_counts.get(folder.id, 0),
             file_count=file_counts.get(folder.id, 0),
-            latest_activity_at=latest_activity_at,
+            latest_activity_at=own_activity,
             children=[],
             children=[],
         )
         )
         folder_map[folder.id] = folder_item
         folder_map[folder.id] = folder_item
@@ -794,6 +804,28 @@ async def list_folders(
         elif folder.parent_id in folder_map:
         elif folder.parent_id in folder_map:
             folder_map[folder.parent_id].children.append(folder_item)
             folder_map[folder.parent_id].children.append(folder_item)
 
 
+    # Recursive newest-descendant bubble (#2680). Post-order: a folder's activity
+    # becomes the max of its own leaf activity and every descendant's, so sorting
+    # the tree by ``latest_activity_at`` surfaces the branch with the most recent
+    # activity anywhere inside it. Iterative stack keeps deep external mounts off
+    # Python's recursion limit.
+    def _bubble(root: FolderTreeItem) -> None:
+        order: list[FolderTreeItem] = []
+        stack = [root]
+        while stack:
+            node = stack.pop()
+            order.append(node)
+            stack.extend(node.children)
+        for node in reversed(order):  # deepest first
+            for child in node.children:
+                if child.latest_activity_at is not None and (
+                    node.latest_activity_at is None or child.latest_activity_at > node.latest_activity_at
+                ):
+                    node.latest_activity_at = child.latest_activity_at
+
+    for root in root_folders:
+        _bubble(root)
+
     return root_folders
     return root_folders
 
 
 
 
@@ -819,11 +851,12 @@ async def get_folders_by_project(
 
 
     folders = []
     folders = []
     for folder, project_name in rows:
     for folder, project_name in rows:
-        # Get file count + latest file activity (#1770) in one trip
+        # Get file count + latest file activity (#1770/#2680) in one trip. Prefer
+        # the real on-disk mtime (external scans), fall back to the DB updated_at.
         agg_result = await db.execute(
         agg_result = await db.execute(
             select(
             select(
                 func.count(LibraryFile.id),
                 func.count(LibraryFile.id),
-                func.max(LibraryFile.updated_at),
+                func.max(func.coalesce(LibraryFile.fs_modified_at, LibraryFile.updated_at)),
             ).where(
             ).where(
                 LibraryFile.folder_id == folder.id,
                 LibraryFile.folder_id == folder.id,
                 LibraryFile.deleted_at.is_(None),
                 LibraryFile.deleted_at.is_(None),
@@ -831,7 +864,8 @@ async def get_folders_by_project(
         )
         )
         file_count, latest_file = agg_result.one()
         file_count, latest_file = agg_result.one()
         file_count = file_count or 0
         file_count = file_count or 0
-        latest_activity_at = max(folder.updated_at, latest_file) if latest_file is not None else folder.updated_at
+        own_activity = folder.fs_modified_at or folder.updated_at
+        latest_activity_at = max(own_activity, latest_file) if latest_file is not None else own_activity
 
 
         folders.append(
         folders.append(
             FolderResponse(
             FolderResponse(
@@ -878,11 +912,12 @@ async def get_folders_by_archive(
 
 
     folders = []
     folders = []
     for folder, archive_name in rows:
     for folder, archive_name in rows:
-        # Get file count + latest file activity (#1770) in one trip
+        # Get file count + latest file activity (#1770/#2680) in one trip. Prefer
+        # the real on-disk mtime (external scans), fall back to the DB updated_at.
         agg_result = await db.execute(
         agg_result = await db.execute(
             select(
             select(
                 func.count(LibraryFile.id),
                 func.count(LibraryFile.id),
-                func.max(LibraryFile.updated_at),
+                func.max(func.coalesce(LibraryFile.fs_modified_at, LibraryFile.updated_at)),
             ).where(
             ).where(
                 LibraryFile.folder_id == folder.id,
                 LibraryFile.folder_id == folder.id,
                 LibraryFile.deleted_at.is_(None),
                 LibraryFile.deleted_at.is_(None),
@@ -890,7 +925,8 @@ async def get_folders_by_archive(
         )
         )
         file_count, latest_file = agg_result.one()
         file_count, latest_file = agg_result.one()
         file_count = file_count or 0
         file_count = file_count or 0
-        latest_activity_at = max(folder.updated_at, latest_file) if latest_file is not None else folder.updated_at
+        own_activity = folder.fs_modified_at or folder.updated_at
+        latest_activity_at = max(own_activity, latest_file) if latest_file is not None else own_activity
 
 
         folders.append(
         folders.append(
             FolderResponse(
             FolderResponse(
@@ -1482,6 +1518,16 @@ async def create_external_folder(
     )
     )
 
 
 
 
+def _mtime_to_datetime(mtime: float) -> datetime:
+    """Convert an ``os.stat().st_mtime`` epoch value to a naive-UTC datetime (#2680).
+
+    Naive UTC to match the other library timestamp columns (``created_at`` /
+    ``updated_at`` are naive ``func.now()``), so activity comparisons never mix
+    naive and aware values on either dialect.
+    """
+    return datetime.fromtimestamp(mtime, tz=timezone.utc).replace(tzinfo=None)
+
+
 @router.post("/folders/{folder_id}/scan")
 @router.post("/folders/{folder_id}/scan")
 async def scan_external_folder(
 async def scan_external_folder(
     folder_id: int,
     folder_id: int,
@@ -1557,6 +1603,8 @@ async def scan_external_folder(
     removed = 0
     removed = 0
     found_paths: set[str] = set()
     found_paths: set[str] = set()
     seen_rel_dirs: set[str] = set()
     seen_rel_dirs: set[str] = set()
+    # Real on-disk mtime per visited folder id (#2680), applied after the walk.
+    folder_mtimes: dict[int, datetime] = {}
 
 
     for dirpath, dirnames, filenames in os.walk(ext_path):
     for dirpath, dirnames, filenames in os.walk(ext_path):
         # Filter hidden directories unless configured
         # Filter hidden directories unless configured
@@ -1606,6 +1654,15 @@ async def scan_external_folder(
 
 
         target_folder_id = folder_cache.get(rel_dir, folder_id)
         target_folder_id = folder_cache.get(rel_dir, folder_id)
 
 
+        # Record this directory's own mtime (#2680). os.walk visits every
+        # directory once, so this covers the root external folder and every
+        # subfolder (existing or just created). Applied to the folder rows
+        # after the walk completes.
+        try:
+            folder_mtimes[target_folder_id] = _mtime_to_datetime(os.stat(dirpath).st_mtime)
+        except OSError:
+            pass
+
         for filename in filenames:
         for filename in filenames:
             # Skip hidden files unless configured
             # Skip hidden files unless configured
             if not folder.external_show_hidden and filename.startswith("."):
             if not folder.external_show_hidden and filename.startswith("."):
@@ -1634,7 +1691,17 @@ async def scan_external_folder(
             found_paths.add(file_path_str)
             found_paths.add(file_path_str)
 
 
             if file_path_str in existing_files:
             if file_path_str in existing_files:
-                continue  # Already tracked
+                # Already tracked — refresh its on-disk mtime (#2680) so a file
+                # edited/replaced over the mount (samba, etc.) re-sorts correctly
+                # and old rows scanned before this field existed get backfilled.
+                tracked = existing_files[file_path_str]
+                try:
+                    fs_mtime = _mtime_to_datetime(filepath.stat().st_mtime)
+                except OSError:
+                    fs_mtime = None
+                if fs_mtime is not None and tracked.fs_modified_at != fs_mtime:
+                    tracked.fs_modified_at = fs_mtime
+                continue
 
 
             # Get file info
             # Get file info
             try:
             try:
@@ -1717,6 +1784,7 @@ async def scan_external_folder(
                 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=_without_print_name(file_metadata),
                 file_metadata=_without_print_name(file_metadata),
+                fs_modified_at=_mtime_to_datetime(stat.st_mtime),  # #2680: real on-disk mtime
             )
             )
             db.add(db_file)
             db.add(db_file)
             added += 1
             added += 1
@@ -1767,6 +1835,16 @@ async def scan_external_folder(
                 sub_folder_obj = sub_folder_result.scalar_one_or_none()
                 sub_folder_obj = sub_folder_result.scalar_one_or_none()
                 if sub_folder_obj:
                 if sub_folder_obj:
                     await db.delete(sub_folder_obj)
                     await db.delete(sub_folder_obj)
+                    folder_mtimes.pop(sub_fid, None)
+
+    # Persist each visited folder's real directory mtime (#2680). Fetched in one
+    # trip; folders deleted by the cleanup above were dropped from folder_mtimes.
+    if folder_mtimes:
+        folders_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id.in_(list(folder_mtimes.keys()))))
+        for folder_obj in folders_result.scalars().all():
+            new_mtime = folder_mtimes.get(folder_obj.id)
+            if new_mtime is not None and folder_obj.fs_modified_at != new_mtime:
+                folder_obj.fs_modified_at = new_mtime
 
 
     await db.commit()
     await db.commit()
 
 
@@ -1928,6 +2006,7 @@ async def list_files(
                 created_by_id=f.created_by_id,
                 created_by_id=f.created_by_id,
                 created_by_username=f.created_by.username if f.created_by else None,
                 created_by_username=f.created_by.username if f.created_by else None,
                 created_at=f.created_at,
                 created_at=f.created_at,
+                fs_modified_at=f.fs_modified_at,
                 print_name=print_name,
                 print_name=print_name,
                 print_time_seconds=print_time,
                 print_time_seconds=print_time,
                 filament_used_grams=filament_grams,
                 filament_used_grams=filament_grams,
@@ -3590,9 +3669,11 @@ async def _run_slicer_with_fallback(
     # (e.g. ABS in slot 2 next to a PLA in the used slot 1) makes
     # (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
     # BambuStudio reject the slice with "the temperature difference of
     # the filaments used is too large" (exit 194) even though the G-code
     # the filaments used is too large" (exit 194) even though the G-code
-    # never touches the unused slot. Replace unused-slot entries with the
-    # slot-1 selection before the real slice so the loaded-filament set
-    # is materially homogeneous.
+    # never touches the unused slot; a default scoped to another printer
+    # gets it rejected with "filament preset (slot N) is not compatible
+    # with printer …" (#2628). Replace unused-slot entries with the
+    # plate's lowest used slot before the real slice so the loaded set is
+    # materially homogeneous and printer-correct.
     if is_3mf and request.plate is not None:
     if is_3mf and request.plate is not None:
         from backend.app.services.slicer_3mf_convert import substitute_unused_plate_filaments
         from backend.app.services.slicer_3mf_convert import substitute_unused_plate_filaments
 
 
@@ -4158,8 +4239,14 @@ async def slice_library_file(
 
 
     src_result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
     src_result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
     lib_file = src_result.scalar_one_or_none()
     lib_file = src_result.scalar_one_or_none()
-    if not lib_file:
-        raise HTTPException(status_code=404, detail="File not found")
+    # Per-row ownership gate. LIBRARY_UPLOAD alone let a READ_OWN caller (e.g. the
+    # built-in Operators group) slice another user's model by raw id even though
+    # GET on that id returned 404 — the sliced output was then attributed to and
+    # downloadable by the requester. Enforce the same visibility the read routes
+    # use before reading the source off disk. API-key / auth-disabled callers
+    # (current_user is None) keep can_read_all=True — no per-row identity.
+    can_read_all = current_user is None or current_user.has_permission(Permission.LIBRARY_READ_ALL.value)
+    lib_file = _ensure_library_file_visible(lib_file, current_user, can_read_all)
 
 
     src_lower = (lib_file.filename or "").lower()
     src_lower = (lib_file.filename or "").lower()
     if not (
     if not (
@@ -4231,6 +4318,7 @@ async def slice_library_file(
         kind="library_file",
         kind="library_file",
         source_id=lib_file.id,
         source_id=lib_file.id,
         source_name=lib_file.filename,
         source_name=lib_file.filename,
+        owner_id=user_id,
         run=_run,
         run=_run,
     )
     )
     return {
     return {

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

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

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

@@ -1054,7 +1054,8 @@ async def get_printer_cover(
     """Get the cover image for the current print job.
     """Get the cover image for the current print job.
 
 
     Args:
     Args:
-        view: Optional view type. Use "top" for top-down build plate view (useful for skip objects).
+        view: Optional view type. Use "top" for the top-down build plate view or
+              "pick" for the slicer's object-ID mask used by skip objects.
               Default returns angled 3D perspective view.
               Default returns angled 3D perspective view.
     """
     """
     # Fetch the printer in a short-lived session and release the pooled DB
     # Fetch the printer in a short-lived session and release the pooled DB
@@ -1297,7 +1298,14 @@ async def _produce_cover_image(
             # Try common thumbnail paths in 3MF files
             # Try common thumbnail paths in 3MF files
             # Use plate_num to get the correct plate's thumbnail for multi-plate projects
             # Use plate_num to get the correct plate's thumbnail for multi-plate projects
             # Use top-down view if requested (better for skip objects modal)
             # Use top-down view if requested (better for skip objects modal)
-            if view == "top":
+            if view == "pick":
+                # Only the active plate's mask, with no fallback: every other view
+                # falls back to plate 1 because a slightly wrong picture is better
+                # than none, but a mask is coordinates, not decoration. Plate 1's
+                # mask over plate 3's layout would resolve clicks to whichever
+                # object happened to occupy that pixel on a different plate.
+                thumbnail_paths = [f"Metadata/pick_{plate_num}.png"]
+            elif view == "top":
                 thumbnail_paths = [
                 thumbnail_paths = [
                     f"Metadata/top_{plate_num}.png",
                     f"Metadata/top_{plate_num}.png",
                     # Fall back to plate 1 if specific plate not found
                     # Fall back to plate 1 if specific plate not found
@@ -1328,14 +1336,21 @@ async def _produce_cover_image(
                 except KeyError:
                 except KeyError:
                     continue
                     continue
 
 
-            # If no specific thumbnail found, try any PNG in Metadata
-            for name in zf.namelist():
-                if name.startswith("Metadata/") and name.endswith(".png"):
-                    image_data = zf.read(name)
-                    if printer_id not in _cover_cache:
-                        _cover_cache[printer_id] = {}
-                    _cover_cache[printer_id][(subtask_name, view_key)] = image_data
-                    return image_data
+            # If no specific thumbnail found, try any PNG in Metadata. Never for
+            # "pick": handing back a rendered thumbnail in place of the object-ID
+            # mask is worse than nothing, because the caller can't tell the
+            # difference and decodes the render's pixel colours as object IDs —
+            # dark pixels yield small integers that collide with real IDs, so a
+            # click would select an arbitrary object and skip it irreversibly.
+            # A 404 is what tells the UI to fall back to the checklist.
+            if view != "pick":
+                for name in zf.namelist():
+                    if name.startswith("Metadata/") and name.endswith(".png"):
+                        image_data = zf.read(name)
+                        if printer_id not in _cover_cache:
+                            _cover_cache[printer_id] = {}
+                        _cover_cache[printer_id][(subtask_name, view_key)] = image_data
+                        return image_data
 
 
             _cover_404_cache.setdefault(printer_id, set()).add(cache_key)
             _cover_404_cache.setdefault(printer_id, set()).add(cache_key)
             raise HTTPException(404, "No thumbnail found in 3MF file")
             raise HTTPException(404, "No thumbnail found in 3MF file")
@@ -2720,6 +2735,17 @@ async def configure_ams_slot(
             except Exception:
             except Exception:
                 pass
                 pass
 
 
+    # Register a read-back verification (#2582) so the tray telemetry that the
+    # status push below returns can confirm the printer accepted this manual
+    # slot configuration. Mirrors the inventory/assignment path.
+    client.register_assignment_verification(
+        ams_id=ams_id,
+        tray_id=tray_id,
+        tray_info_idx=effective_tray_info_idx,
+        tray_color=tray_color,
+        cali_idx=cali_idx,
+    )
+
     # Request fresh status push from printer so frontend gets updated data via WebSocket
     # Request fresh status push from printer so frontend gets updated data via WebSocket
     logger.info("[configure_ams_slot] Requesting status update from printer")
     logger.info("[configure_ams_slot] Requesting status update from printer")
     update_result = client.request_status_update()
     update_result = client.request_status_update()
@@ -3914,8 +3940,12 @@ async def ams_load(
     - 254: external spool (single-external printers, or Ext-L on dual-nozzle H2D)
     - 254: external spool (single-external printers, or Ext-L on dual-nozzle H2D)
     - 255: Ext-R on dual-nozzle H2D
     - 255: Ext-R on dual-nozzle H2D
     """
     """
-    if tray_id not in range(16) and tray_id not in (254, 255):
-        raise HTTPException(400, "tray_id must be 0..15 (AMS slot), 254 (external / Ext-L), or 255 (Ext-R)")
+    # 24-27 are the A2L AMS-Lite slots (normalised unit 6 = 6*4+slot); see
+    # a2l-am-unit-16. They are valid global tray ids alongside the regular 0-15.
+    if tray_id not in range(16) and tray_id not in range(24, 28) and tray_id not in (254, 255):
+        raise HTTPException(
+            400, "tray_id must be 0..15 (AMS slot), 24..27 (A2L AMS-Lite), 254 (external / Ext-L), or 255 (Ext-R)"
+        )
 
 
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     printer = result.scalar_one_or_none()

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

@@ -105,12 +105,12 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
             "print_drying_enabled",
             "print_drying_enabled",
             "require_plate_clear",
             "require_plate_clear",
             "queue_shortest_first",
             "queue_shortest_first",
-            "default_bed_levelling",
-            "default_flow_cali",
+            # default_bed_levelling / default_flow_cali / default_nozzle_offset_cali
+            # are tri-state strings (off/on/auto) — parsed via the raw-string else
+            # branch; the TriState validator coerces legacy "true"/"false" rows.
             "default_vibration_cali",
             "default_vibration_cali",
             "default_layer_inspect",
             "default_layer_inspect",
             "default_timelapse",
             "default_timelapse",
-            "default_nozzle_offset_cali",
             "ldap_enabled",
             "ldap_enabled",
             "ldap_auto_provision",
             "ldap_auto_provision",
             "local_login_enabled",
             "local_login_enabled",

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

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

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

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

+ 3 - 2
backend/app/api/routes/smart_plugs.py

@@ -583,8 +583,9 @@ async def control_smart_plug(
         plug.last_state = expected_state
         plug.last_state = expected_state
         if expected_state == "ON":
         if expected_state == "ON":
             plug.auto_off_executed = False  # Reset flag when manually turning on
             plug.auto_off_executed = False  # Reset flag when manually turning on
-        elif expected_state == "OFF" and plug.printer_id:
-            # Mark printer offline immediately for faster UI update
+        elif expected_state == "OFF" and plug.printer_id and plug.controls_printer_power:
+            # Mark printer offline immediately for faster UI update. Skipped for
+            # accessory plugs, which are linked to a printer but don't feed it (#2629).
             printer_manager.mark_printer_offline(plug.printer_id)
             printer_manager.mark_printer_offline(plug.printer_id)
     plug.last_checked = utcnow_naive()
     plug.last_checked = utcnow_naive()
     await db.commit()
     await db.commit()

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

@@ -7,7 +7,7 @@ from pydantic import Field
 from pydantic_settings import BaseSettings
 from pydantic_settings import BaseSettings
 
 
 # Application version - single source of truth
 # Application version - single source of truth
-APP_VERSION = "1.2.5b2"
+APP_VERSION = "1.2.6b1"
 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")
 
 

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

@@ -1442,6 +1442,63 @@ async def run_migrations(conn):
     else:
     else:
         await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzle_offset_cali BOOLEAN DEFAULT TRUE")
         await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzle_offset_cali BOOLEAN DEFAULT TRUE")
 
 
+    # Migration: convert bed_levelling / flow_cali / nozzle_offset_cali from
+    # boolean to tri-state strings (off/on/auto). BambuStudio exposes a third
+    # "auto" state for these (skip the calibration if it was done recently); our
+    # booleans could only send force-on / off. Legacy rows map true->'on',
+    # false->'off'; the new default is 'auto'. Idempotent on both dialects:
+    # SQLite leans on column affinity (a BOOLEAN-declared column stores text
+    # fine) and only rewrites rows still holding 0/1; PostgreSQL alters the
+    # column type only while it is still boolean, so re-runs and fresh
+    # create_all() schemas (already VARCHAR) are skipped. Column names are
+    # hardcoded constants, not user input.
+    _tristate_cols = ("bed_levelling", "flow_cali", "nozzle_offset_cali")
+    if is_sqlite():
+        for _col in _tristate_cols:
+            async with conn.begin_nested():
+                # B608 is a false positive here: _col is a hardcoded constant
+                # from _tristate_cols, never user input, and SQL identifiers
+                # can't be bound as parameters. Suppressed inline below.
+                await conn.execute(
+                    text(f"UPDATE print_queue SET {_col} = 'on' WHERE {_col} IN (1, '1', 'true', 'True')")  # nosec B608
+                )
+                await conn.execute(
+                    text(f"UPDATE print_queue SET {_col} = 'off' WHERE {_col} IN (0, '0', 'false', 'False')")  # nosec B608
+                )
+    else:
+        for _col in _tristate_cols:
+            result = await conn.execute(
+                text(
+                    "SELECT data_type FROM information_schema.columns "
+                    "WHERE table_name = 'print_queue' AND column_name = :col"
+                ),
+                {"col": _col},
+            )
+            row = result.fetchone()
+            if row and row[0] == "boolean":
+                await _safe_execute(conn, f"ALTER TABLE print_queue ALTER COLUMN {_col} DROP DEFAULT")
+                await _safe_execute(
+                    conn,
+                    f"ALTER TABLE print_queue ALTER COLUMN {_col} TYPE VARCHAR(8) "
+                    f"USING (CASE WHEN {_col} THEN 'on' ELSE 'off' END)",
+                )
+                await _safe_execute(conn, f"ALTER TABLE print_queue ALTER COLUMN {_col} SET DEFAULT 'auto'")
+
+    # Migration: normalise the workflow-default settings rows that back these
+    # options from legacy "true"/"false" to the tri-state vocabulary so the API
+    # returns real values (the AppSettings validator also coerces on read, but
+    # rewriting keeps the stored data honest). Only these three became tri-state.
+    for _skey in ("default_bed_levelling", "default_flow_cali", "default_nozzle_offset_cali"):
+        async with conn.begin_nested():
+            await conn.execute(
+                text("UPDATE settings SET value = 'on' WHERE key = :k AND lower(value) IN ('true', '1')"),
+                {"k": _skey},
+            )
+            await conn.execute(
+                text("UPDATE settings SET value = 'off' WHERE key = :k AND lower(value) IN ('false', '0')"),
+                {"k": _skey},
+            )
+
     # Migration: Per-item preheat / heat-soak override (#1468). preheat_override
     # Migration: Per-item preheat / heat-soak override (#1468). preheat_override
     # is one of {inherit, on, off} — 'inherit' falls back to the global
     # is one of {inherit, on, off} — 'inherit' falls back to the global
     # preheat_enabled setting; 'on' / 'off' force the decision. The chamber
     # preheat_enabled setting; 'on' / 'off' force the decision. The chamber
@@ -3697,6 +3754,35 @@ async def run_migrations(conn):
     # #2603 archive plate_id backfill above so print_archives.plate_id is populated.
     # #2603 archive plate_id backfill above so print_archives.plate_id is populated.
     await _migrate_scope_run_filament_to_plate(conn)
     await _migrate_scope_run_filament_to_plate(conn)
 
 
+    # Migration: Add controls_printer_power to smart_plugs (#2629). Marks
+    # whether a plug actually feeds the printer's own power — only then may an
+    # auto-off mark the printer offline. Defaults to true so existing plugs
+    # keep the previous behaviour; accessory plugs (filter fan, lights) are
+    # opted out by the user. BOOLEAN literals differ per dialect (SQLite has
+    # no true/false keyword), so the default is dialect-branched.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN controls_printer_power BOOLEAN DEFAULT 1")
+    else:
+        await _safe_execute(
+            conn,
+            "ALTER TABLE smart_plugs ADD COLUMN IF NOT EXISTS controls_printer_power BOOLEAN DEFAULT true",
+        )
+
+    # Migration: real filesystem mtime for library files/folders (#2680). The
+    # folder tree's "sort by recent activity" and the file pane's date sort must
+    # track the on-disk mtime (``ls -t``), not Bambuddy's DB ``updated_at`` — for
+    # a bulk external scan every row's ``updated_at`` is the same scan instant, so
+    # ordering was arbitrary. Nullable; the timestamp type differs by dialect
+    # (SQLite DATETIME vs Postgres TIMESTAMP) so an existing-DB upgrade doesn't hit
+    # "type datetime does not exist" on Postgres. On a fresh DB create_all() already
+    # built the column, so the ALTER is swallowed as "already exists".
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN fs_modified_at DATETIME")
+        await _safe_execute(conn, "ALTER TABLE library_folders ADD COLUMN fs_modified_at DATETIME")
+    else:
+        await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN fs_modified_at TIMESTAMP")
+        await _safe_execute(conn, "ALTER TABLE library_folders ADD COLUMN fs_modified_at TIMESTAMP")
+
     # Migration: Disambiguate the four ``user_print_*`` notification template
     # Migration: Disambiguate the four ``user_print_*`` notification template
     # names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``.
     # names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``.
     await _migrate_rename_user_print_template_names(conn)
     await _migrate_rename_user_print_template_names(conn)

+ 55 - 0
backend/app/main.py

@@ -6360,6 +6360,61 @@ async def lifespan(app: FastAPI):
 
 
     printer_manager.set_drying_complete_callback(on_drying_complete)
     printer_manager.set_drying_complete_callback(on_drying_complete)
 
 
+    async def on_assignment_verified(printer_id: int, ams_id: int, tray_id: int, verified: bool, detail: dict):
+        """Surface the read-back result of a spool assignment to the UI (#2582).
+
+        The MQTT client confirms (or fails to confirm) that the tray telemetry
+        echoed back the filament id we pushed. We relay that as a websocket
+        event so the frontend can toast "loaded" / "assignment didn't take"
+        instead of the historic silent fire-and-forget, which made the
+        AMS→Studio hand-off feel random to users.
+        """
+        try:
+            from backend.app.services.spool_assignment_notifications import (
+                _slot_label_from_global_tray,
+            )
+
+            if ams_id == 255:
+                global_id = 254 + tray_id
+            elif ams_id >= 128:
+                global_id = ams_id
+            else:
+                global_id = ams_id * 4 + tray_id
+            slot_label = _slot_label_from_global_tray(global_id)
+
+            printer_info = printer_manager.get_printer(printer_id)
+            printer_name = printer_info.name if printer_info else f"Printer {printer_id}"
+
+            await ws_manager.broadcast(
+                {
+                    "type": "spool_assignment_verified",
+                    "printer_id": printer_id,
+                    "printer_name": printer_name,
+                    "ams_id": ams_id,
+                    "tray_id": tray_id,
+                    "slot": slot_label,
+                    "verified": verified,
+                    # Present on success: False means the filament setting landed
+                    # but the K-profile (cali_idx) did not — the reporter's exact
+                    # "loaded but no flow profile" symptom.
+                    "kprofile_applied": detail.get("kprofile_applied", True),
+                    # Present on failure: whether any tray telemetry was seen in
+                    # the window (distinguishes "printer silent" from "printer
+                    # stored something else").
+                    "saw_tray": detail.get("saw_tray", False),
+                }
+            )
+        except Exception as e:
+            logging.getLogger(__name__).warning(
+                "Failed to broadcast assignment verification for printer %d AMS%d-T%d: %s",
+                printer_id,
+                ams_id,
+                tray_id,
+                e,
+            )
+
+    printer_manager.set_assignment_verified_callback(on_assignment_verified)
+
     # Initialize MQTT relay from settings
     # Initialize MQTT relay from settings
     async with async_session() as db:
     async with async_session() as db:
         from backend.app.api.routes.settings import get_setting
         from backend.app.api.routes.settings import get_setting

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

@@ -31,6 +31,14 @@ class LibraryFolder(Base):
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 
 
+    # Real on-disk modification time of the directory this folder mirrors (#2680).
+    # For external folders this is captured from ``os.stat().st_mtime`` on scan so
+    # the tree's "sort by recent activity" matches ``ls -t`` instead of ordering by
+    # the DB row's ``updated_at`` (which is the scan instant, identical for every
+    # row of a bulk scan). Null for managed (internal) folders, which have no
+    # meaningful directory mtime — callers fall back to ``updated_at``/``created_at``.
+    fs_modified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+
     # Relationships
     # Relationships
     parent: Mapped["LibraryFolder | None"] = relationship(
     parent: Mapped["LibraryFolder | None"] = relationship(
         "LibraryFolder",
         "LibraryFolder",
@@ -102,6 +110,14 @@ class LibraryFile(Base):
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 
 
+    # Real on-disk modification time of the file (#2680). Captured from
+    # ``os.stat().st_mtime`` for external files on scan so the file pane's date
+    # sort and the folder tree's recursive "recent activity" bubble reflect the
+    # actual filesystem mtime (``ls -t``) rather than the DB ``updated_at`` (the
+    # scan instant, identical across a bulk scan). Null for managed uploads —
+    # callers fall back to ``created_at``.
+    fs_modified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+
     # Relationships
     # Relationships
     folder: Mapped["LibraryFolder | None"] = relationship(back_populates="files")
     folder: Mapped["LibraryFolder | None"] = relationship(back_populates="files")
     project: Mapped["Project | None"] = relationship()
     project: Mapped["Project | None"] = relationship()

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

@@ -89,15 +89,17 @@ class PrintQueueItem(Base):
     # true, the scheduler deletes the source row/files after archiving a copy.
     # true, the scheduler deletes the source row/files after archiving a copy.
     cleanup_library_after_dispatch: Mapped[bool] = mapped_column(Boolean, default=False)
     cleanup_library_after_dispatch: Mapped[bool] = mapped_column(Boolean, default=False)
 
 
-    # Print options
-    bed_levelling: Mapped[bool] = mapped_column(Boolean, default=True)
-    flow_cali: Mapped[bool] = mapped_column(Boolean, default=False)
+    # Print options. bed_levelling / flow_cali / nozzle_offset_cali are tri-state
+    # strings (off/on/auto) matching BambuStudio; "auto" = skip if recently done.
+    # The remaining three stay boolean (BambuStudio exposes no auto for them).
+    bed_levelling: Mapped[str] = mapped_column(String(8), default="auto")
+    flow_cali: Mapped[str] = mapped_column(String(8), default="auto")
     vibration_cali: Mapped[bool] = mapped_column(Boolean, default=True)
     vibration_cali: Mapped[bool] = mapped_column(Boolean, default=True)
     layer_inspect: Mapped[bool] = mapped_column(Boolean, default=False)
     layer_inspect: Mapped[bool] = mapped_column(Boolean, default=False)
     timelapse: Mapped[bool] = mapped_column(Boolean, default=False)
     timelapse: Mapped[bool] = mapped_column(Boolean, default=False)
     use_ams: Mapped[bool] = mapped_column(Boolean, default=True)
     use_ams: Mapped[bool] = mapped_column(Boolean, default=True)
     # Nozzle offset calibration — dual-nozzle printers only, MQTT-gated (#1682)
     # Nozzle offset calibration — dual-nozzle printers only, MQTT-gated (#1682)
-    nozzle_offset_cali: Mapped[bool] = mapped_column(Boolean, default=True)
+    nozzle_offset_cali: Mapped[str] = mapped_column(String(8), default="auto")
 
 
     # Preheat / heat-soak override (#1468). 'inherit' uses the global
     # Preheat / heat-soak override (#1468). 'inherit' uses the global
     # preheat_enabled setting; 'on' / 'off' force the per-item decision. The
     # preheat_enabled setting; 'on' / 'off' force the per-item decision. The

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

@@ -83,6 +83,14 @@ class SmartPlug(Base):
     # Link to printer (multiple plugs/scripts can be linked to one printer)
     # Link to printer (multiple plugs/scripts can be linked to one printer)
     printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="SET NULL"), nullable=True)
     printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="SET NULL"), nullable=True)
 
 
+    # Whether this plug actually feeds the printer's own power (#2629). The
+    # printer link is also used for accessories that merely follow the print
+    # cycle — filter fans, chamber lights, enclosure heaters. Only a plug that
+    # really cuts printer power may mark the printer offline on auto-off;
+    # doing it for an accessory blanks the printer state and stalls the queue.
+    # Defaults to True so existing plugs keep their previous behaviour.
+    controls_printer_power: Mapped[bool] = mapped_column(Boolean, default=True, server_default="1")
+
     # Automation settings
     # Automation settings
     enabled: Mapped[bool] = mapped_column(Boolean, default=True)
     enabled: Mapped[bool] = mapped_column(Boolean, default=True)
     auto_on: Mapped[bool] = mapped_column(Boolean, default=True)  # Turn on at print start
     auto_on: Mapped[bool] = mapped_column(Boolean, default=True)  # Turn on at print start

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

@@ -205,6 +205,10 @@ class FileListResponse(BaseModel):
     created_by_id: int | None = None
     created_by_id: int | None = None
     created_by_username: str | None = None
     created_by_username: str | None = None
     created_at: datetime
     created_at: datetime
+    # Real on-disk modification time (#2680). Populated for external files from
+    # their filesystem mtime; null for managed uploads. The file pane's date sort
+    # and the "Modified" column use ``fs_modified_at ?? created_at``.
+    fs_modified_at: datetime | None = None
 
 
     # Key metadata fields for display
     # Key metadata fields for display
     print_name: str | None = None
     print_name: str | None = None

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

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

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

@@ -2,6 +2,8 @@ import json
 
 
 from pydantic import BaseModel, Field, field_validator
 from pydantic import BaseModel, Field, field_validator
 
 
+from backend.app.schemas.print_queue import TriState
+
 
 
 class AppSettings(BaseModel):
 class AppSettings(BaseModel):
     """Application settings schema."""
     """Application settings schema."""
@@ -294,9 +296,10 @@ class AppSettings(BaseModel):
         description="Enable user email notifications for print job events (requires Advanced Authentication)",
         description="Enable user email notifications for print job events (requires Advanced Authentication)",
     )
     )
 
 
-    # Default print options
-    default_bed_levelling: bool = Field(default=True, description="Default bed levelling option for new prints")
-    default_flow_cali: bool = Field(default=False, description="Default flow calibration option for new prints")
+    # Default print options. bed_levelling / flow_cali / nozzle_offset_cali are
+    # tri-state (off/on/auto), defaulting to "auto" per BambuStudio.
+    default_bed_levelling: TriState = Field(default="auto", description="Default bed levelling option for new prints")
+    default_flow_cali: TriState = Field(default="auto", description="Default flow calibration option for new prints")
     default_vibration_cali: bool = Field(
     default_vibration_cali: bool = Field(
         default=True, description="Default vibration calibration option for new prints"
         default=True, description="Default vibration calibration option for new prints"
     )
     )
@@ -304,8 +307,8 @@ class AppSettings(BaseModel):
         default=False, description="Default first layer inspection option for new prints"
         default=False, description="Default first layer inspection option for new prints"
     )
     )
     default_timelapse: bool = Field(default=False, description="Default timelapse option for new prints")
     default_timelapse: bool = Field(default=False, description="Default timelapse option for new prints")
-    default_nozzle_offset_cali: bool = Field(
-        default=True,
+    default_nozzle_offset_cali: TriState = Field(
+        default="auto",
         description="Default nozzle offset calibration option for new prints (dual-nozzle printers only)",
         description="Default nozzle offset calibration option for new prints (dual-nozzle printers only)",
     )
     )
 
 
@@ -553,12 +556,12 @@ class AppSettingsUpdate(BaseModel):
     low_stock_threshold: float | None = Field(default=None, ge=0.1, le=99.9)
     low_stock_threshold: float | None = Field(default=None, ge=0.1, le=99.9)
     session_max_hours: int | None = Field(default=None, ge=1, le=720)
     session_max_hours: int | None = Field(default=None, ge=1, le=720)
     user_notifications_enabled: bool | None = None
     user_notifications_enabled: bool | None = None
-    default_bed_levelling: bool | None = None
-    default_flow_cali: bool | None = None
+    default_bed_levelling: TriState | None = None
+    default_flow_cali: TriState | None = None
     default_vibration_cali: bool | None = None
     default_vibration_cali: bool | None = None
     default_layer_inspect: bool | None = None
     default_layer_inspect: bool | None = None
     default_timelapse: bool | None = None
     default_timelapse: bool | None = None
-    default_nozzle_offset_cali: bool | None = None
+    default_nozzle_offset_cali: TriState | None = None
     stagger_group_size: int | None = Field(default=None, ge=1, le=50)
     stagger_group_size: int | None = Field(default=None, ge=1, le=50)
     stagger_interval_minutes: int | None = Field(default=None, ge=1, le=60)
     stagger_interval_minutes: int | None = Field(default=None, ge=1, le=60)
     require_plate_clear: bool | None = None
     require_plate_clear: bool | None = None

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

@@ -66,6 +66,10 @@ class SmartPlugBase(BaseModel):
     rest_energy_total_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)
     rest_energy_total_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)
 
 
     printer_id: int | None = None
     printer_id: int | None = None
+    # #2629: only a plug that really feeds the printer may mark it offline when
+    # it switches off. Accessory plugs (filter fan, lights) are linked to a
+    # printer purely to follow the print cycle.
+    controls_printer_power: bool = True
     enabled: bool = True
     enabled: bool = True
     auto_on: bool = True
     auto_on: bool = True
     auto_off: bool = True
     auto_off: bool = True
@@ -160,6 +164,8 @@ class SmartPlugUpdate(BaseModel):
     rest_energy_total_path: str | None = None
     rest_energy_total_path: str | None = None
     rest_energy_total_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
     rest_energy_total_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
     printer_id: int | None = None
     printer_id: int | None = None
+    # #2629: see SmartPlugBase.controls_printer_power.
+    controls_printer_power: bool | None = None
     enabled: bool | None = None
     enabled: bool | None = None
     auto_on: bool | None = None
     auto_on: bool | None = None
     auto_off: bool | None = None
     auto_off: bool | None = None

+ 50 - 10
backend/app/services/bambu_cloud.py

@@ -32,6 +32,33 @@ def _token_digest(token: str) -> str:
     return hashlib.sha256(token.encode("utf-8")).hexdigest()
     return hashlib.sha256(token.encode("utf-8")).hexdigest()
 
 
 
 
+def is_expiry_401(response: httpx.Response) -> bool:
+    """Whether a 401 is Bambu's genuine "token expired" signal.
+
+    Bambu answers an expired/revoked token with ``{"code":4,"error":"Please
+    login.","message":""}``. Not every 401 means that: individual endpoints
+    return 401 for resource-, region- or scope-specific reasons, and a working
+    token still draws the occasional transient 401 (Cloudflare edge, a brief
+    backend blip). Treating *any* 401 as a dead credential signs the user out on
+    a single stray rejection — the #2562 follow-up regression. We trust only the
+    documented expiry body, so a benign 401 no longer nukes the whole cloud
+    integration. An unparseable / unsigned 401 is deliberately NOT expiry.
+
+    Shared by the Bambu Cloud and MakerWorld services — both carry the same
+    token and see the same expiry body.
+    """
+    try:
+        body = response.json()
+    except Exception:
+        return False
+    if not isinstance(body, dict):
+        return False
+    if body.get("code") == 4:
+        return True
+    text = f"{body.get('error', '')} {body.get('message', '')}".lower()
+    return "please login" in text
+
+
 def invalidate_validation_cache(token: str | None = None) -> None:
 def invalidate_validation_cache(token: str | None = None) -> None:
     """Drop cached validation verdicts.
     """Drop cached validation verdicts.
 
 
@@ -197,16 +224,25 @@ class BambuCloudService:
             return False
             return False
         return not (self.token_expiry and datetime.now(timezone.utc) > self.token_expiry)
         return not (self.token_expiry and datetime.now(timezone.utc) > self.token_expiry)
 
 
-    async def _note_response(self, response: httpx.Response) -> None:
-        """Record a 401 from Bambu as "this stored credential is dead".
+    async def _note_response(self, response: httpx.Response) -> bool:
+        """Record Bambu's genuine token-expiry 401 as "this credential is dead".
 
 
-        Bambu answers an expired/revoked token with 401 and a body of
-        ``{"code":4,"error":"Please login.","message":""}``. Reported at most
-        once per service instance so a route that makes several calls doesn't
-        write the flag several times.
+        Returns ``True`` only for the real expiry signal (see
+        :meth:`_is_expiry_401`); a plain/transient 401 returns ``False`` and is
+        left alone so it can't durably sign the user out. The durable flag is
+        written at most once per service instance so a route making several
+        calls doesn't write it repeatedly.
         """
         """
-        if response.status_code != 401 or self._on_auth_failure is None or self._auth_failure_reported:
-            return
+        if response.status_code != 401:
+            return False
+        if not is_expiry_401(response):
+            logger.info(
+                "Bambu Cloud returned 401 without the expiry signature — treating as transient, "
+                "not signing the stored token out"
+            )
+            return False
+        if self._on_auth_failure is None or self._auth_failure_reported:
+            return True
         self._auth_failure_reported = True
         self._auth_failure_reported = True
         if self.access_token:
         if self.access_token:
             _validation_cache[_token_digest(self.access_token)] = (
             _validation_cache[_token_digest(self.access_token)] = (
@@ -219,6 +255,7 @@ class BambuCloudService:
             # Recording the failure is best-effort — the caller still needs the
             # Recording the failure is best-effort — the caller still needs the
             # real error (a 401) rather than a bookkeeping exception on top.
             # real error (a 401) rather than a bookkeeping exception on top.
             logger.exception("Failed to record Bambu Cloud auth failure")
             logger.exception("Failed to record Bambu Cloud auth failure")
+        return True
 
 
     async def validate_token(self) -> bool | None:
     async def validate_token(self) -> bool | None:
         """Ask Bambu whether the loaded token is still accepted.
         """Ask Bambu whether the loaded token is still accepted.
@@ -249,8 +286,11 @@ class BambuCloudService:
             return None
             return None
 
 
         if response.status_code == 401:
         if response.status_code == 401:
-            await self._note_response(response)
-            return False
+            # Only a 401 carrying Bambu's expiry signature is a real sign-out.
+            # A signature-less 401 here is transient/edge noise — report unknown
+            # (last-known state) rather than expiring a working session.
+            expired = await self._note_response(response)
+            return False if expired else None
         if response.status_code >= 500:
         if response.status_code >= 500:
             logger.info(
             logger.info(
                 "Bambu Cloud returned %s while validating the token — treating as unknown", response.status_code
                 "Bambu Cloud returned %s while validating the token — treating as unknown", response.status_code

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

@@ -58,6 +58,57 @@ def parse_ams_filament_backup_from_cfg(cfg_raw: object) -> bool | None:
         return None
         return None
 
 
 
 
+# ── A2L "AMS Lite" unit-id normalisation (issue capture 2026-07-20) ──────────
+# The A2L reports its 4-slot AMS Lite as physical unit **id 16**, but the
+# firmware is internally inconsistent about it:
+#   - its tray bitmasks (tray_exist_bits etc.) sit at **bit base 24**, i.e. the
+#     position for id 6 (6*4), NOT id 16 (which would be bit 64);
+#   - it reports `tray_now` as a **local** 0-3 slot, not a global id;
+#   - `ams_mapping2` and per-unit commands use the **physical** id 16.
+# So we normalise 16 -> 6 at the MQTT ingest boundary. Global tray ids then land
+# at 24-27, which every `ams_id*4+slot` consumer handles unchanged, collides with
+# nothing (regular AMS 0-15, AMS-HT 128-135, external 254/255) and passes the
+# `ams_id <= 7` DB constraint. We translate 6 -> 16 (and the local slot) back to
+# the physical form ONLY on the outbound wire. See memory a2l-am-unit-16.
+A2L_LITE_PHYSICAL_AMS_ID = 16
+A2L_LITE_NORMALIZED_AMS_ID = 6
+A2L_LITE_GLOBAL_BASE = A2L_LITE_NORMALIZED_AMS_ID * 4  # 24
+
+
+def normalize_am_unit_id(ams_id: int) -> int:
+    """Map the A2L AMS-Lite's physical unit id (16) to its normalised id (6).
+
+    Self-scoping: only id 16 is remapped, and no other Bambu device reports an
+    AMS unit at id 16 (regular AMS 0-3, AMS-HT 128-135). All other ids pass
+    through untouched.
+    """
+    return A2L_LITE_NORMALIZED_AMS_ID if ams_id == A2L_LITE_PHYSICAL_AMS_ID else ams_id
+
+
+def a2l_lite_wire_ids(ams_id: int, tray_id: int) -> tuple[int, int, int] | None:
+    """Translate a normalised A2L slot back to the physical wire form.
+
+    Returns ``(wire_ams_id, wire_slot_id, wire_global_tray)`` for the AMS-Lite
+    (normalised id 6), else ``None`` for every other unit.
+
+    CONFIRMED from the firmware's own `ams_mapping2` ({ams_id:16, slot_id:0-3}):
+    the wire uses the physical unit id 16 with a **local** 0-3 slot. NOT yet
+    confirmed by capture: the physical **global** tray value some commands put on
+    the wire (load `target`, extrusion_cali `tray_id`) — we extrapolate it as
+    16*4+slot = 64-67 to stay consistent with the physical unit id. This is the
+    single unverified encoding; a BambuStudio->A2L capture of a load or cali
+    command would settle it, and it lives only here.
+    """
+    if ams_id != A2L_LITE_NORMALIZED_AMS_ID:
+        return None
+    local_slot = tray_id % 4
+    return (
+        A2L_LITE_PHYSICAL_AMS_ID,
+        local_slot,
+        A2L_LITE_PHYSICAL_AMS_ID * 4 + local_slot,
+    )
+
+
 def apply_tray_exist_bits(
 def apply_tray_exist_bits(
     units: list,
     units: list,
     tray_exist_bits_str: str | int | None,
     tray_exist_bits_str: str | int | None,
@@ -89,8 +140,16 @@ def apply_tray_exist_bits(
     is valid idle-printer state (#1365 — X1C between prints) and MUST be applied
     is valid idle-printer state (#1365 — X1C between prints) and MUST be applied
     so spool removal is detected without requiring a manual reconnect.
     so spool removal is detected without requiring a manual reconnect.
 
 
-    AMS-HT units (``id >= 128``) use a separate addressing scheme and are
-    skipped here.
+    AMS-HT units (``id`` 128-135) are single-tray dry boxes whose presence bit
+    is packed as ONE consecutive bit starting at 16 (``16 + (ams_id - 128)``),
+    NOT ``ams_id * 4`` (which would overflow to bit 512+). This is the firmware's
+    authoritative empty signal for the HT — the only working clear path, since
+    the HT keeps echoing stale ``tray_type`` and its ``state`` is firmware-variant
+    (#2670). Verified against OrcaSlicer ``DevFilaSystem.cpp``
+    (``is_exists = tray_exist_bits >> (16 + (ams_id-128))``) and a live H2D
+    capture (HT-A → bit 16). The A2L-Lite (normalised to id 6 upstream) lands at
+    bits 24-27 via the regular ``ams_id * 4`` formula, matching OrcaSlicer's
+    ``AMS_LITE_MIXED`` offset, so it needs no special case here.
 
 
     `tray_exist_bits_str` is expected as a hex string (firmware sends it that
     `tray_exist_bits_str` is expected as a hex string (firmware sends it that
     way). Ints are tolerated for defensive symmetry but typically not seen
     way). Ints are tolerated for defensive symmetry but typically not seen
@@ -131,8 +190,13 @@ def apply_tray_exist_bits(
             ams_id = int(ams_id_raw) if isinstance(ams_id_raw, str) else ams_id_raw
             ams_id = int(ams_id_raw) if isinstance(ams_id_raw, str) else ams_id_raw
         except (ValueError, TypeError):
         except (ValueError, TypeError):
             continue
             continue
-        if not isinstance(ams_id, int) or ams_id >= 128:
-            # Skip AMS-HT (id >= 128) — separate addressing scheme.
+        if not isinstance(ams_id, int):
+            continue
+        # AMS-HT (n3s, id 128-135): single tray, presence bit at 16+(ams_id-128).
+        # Regular AMS (and the A2L-Lite normalised to id 6): ams_id*4 + tray_id.
+        # Anything outside those ranges has no known bit layout — don't guess it.
+        is_ht = 128 <= ams_id <= 135
+        if not is_ht and not (0 <= ams_id <= 15):
             continue
             continue
         for tray in ams_unit.get("tray", []):
         for tray in ams_unit.get("tray", []):
             if not isinstance(tray, dict):
             if not isinstance(tray, dict):
@@ -146,7 +210,7 @@ def apply_tray_exist_bits(
                 continue
                 continue
             if not isinstance(tray_id, int):
             if not isinstance(tray_id, int):
                 continue
                 continue
-            global_bit = ams_id * 4 + tray_id
+            global_bit = (16 + (ams_id - 128)) if is_ht else (ams_id * 4 + tray_id)
             slot_exists = (tray_exist_bits >> global_bit) & 1
             slot_exists = (tray_exist_bits >> global_bit) & 1
             if annotate_exists:
             if annotate_exists:
                 tray["exists"] = bool(slot_exists)
                 tray["exists"] = bool(slot_exists)
@@ -490,6 +554,12 @@ class BambuMQTTClient:
     # Counter for generating unique MQTT client IDs across instances.
     # Counter for generating unique MQTT client IDs across instances.
     _client_instance_counter: int = 0
     _client_instance_counter: int = 0
 
 
+    # #2582: how long to wait for the AMS telemetry to echo back an assignment
+    # before declaring it un-confirmed. The printer re-broadcasts tray state
+    # every few seconds (and register_assignment_verification nudges a fresh
+    # pushall), so this only has to survive a couple of idle push intervals.
+    ASSIGNMENT_VERIFY_TIMEOUT: float = 30.0
+
     def __init__(
     def __init__(
         self,
         self,
         ip_address: str,
         ip_address: str,
@@ -505,6 +575,7 @@ class BambuMQTTClient:
         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,
         on_print_running_observed: Callable[[dict], None] | None = None,
         on_finish_photo_moment: Callable[[dict], None] | None = None,
         on_finish_photo_moment: Callable[[dict], None] | None = None,
+        on_assignment_verified: Callable[[int, int, bool, dict], None] | None = None,
     ):
     ):
         self.ip_address = ip_address
         self.ip_address = ip_address
         self.serial_number = serial_number
         self.serial_number = serial_number
@@ -541,6 +612,19 @@ class BambuMQTTClient:
         # stage 22 never arrives (cancel mid-print, external-spool-
         # stage 22 never arrives (cancel mid-print, external-spool-
         # only prints, HMS halt before unload, firmware variants).
         # only prints, HMS halt before unload, firmware variants).
         self.on_finish_photo_moment = on_finish_photo_moment
         self.on_finish_photo_moment = on_finish_photo_moment
+        # #2582: fired after a spool assignment (ams_filament_setting +
+        # extrusion_cali_sel) once the tray's telemetry either confirms the
+        # push landed or a timeout elapses without it. Receives
+        # (ams_id, tray_id, verified: bool, detail: dict). Lets the frontend
+        # tell the user "loaded" vs "assignment didn't take" instead of the
+        # historic fire-and-forget silence that made the AMS/Studio hand-off
+        # feel random. See _check_assignment_verifications.
+        self.on_assignment_verified = on_assignment_verified
+        # Pending read-back verifications, keyed by (ams_id, tray_id). Each
+        # value is the desired end-state we just pushed plus a monotonic
+        # deadline. Populated by register_assignment_verification, drained by
+        # _check_assignment_verifications on every AMS push.
+        self._pending_assignments: dict[tuple[int, int], dict] = {}
         # 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] = {}
@@ -581,6 +665,11 @@ class BambuMQTTClient:
         # to once per client lifetime so the stale loop doesn't spam it (#1465).
         # to once per client lifetime so the stale loop doesn't spam it (#1465).
         self._report_messages_since_connect: int = 0
         self._report_messages_since_connect: int = 0
         self._zero_report_hint_logged: bool = False
         self._zero_report_hint_logged: bool = False
+        # Set by mark_power_off() to the gcode_state held just before we
+        # optimistically forced the printer to "unknown" (#2629). Restored on
+        # the next inbound message, because message traffic proves the power
+        # was never actually cut. None whenever no power-off is presumed.
+        self._state_before_power_off: str | None = None
         # 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.
@@ -619,6 +708,11 @@ class BambuMQTTClient:
         # Intercepts slicer/Bambuddy print commands to get the slot-to-tray mapping
         # Intercepts slicer/Bambuddy print commands to get the slot-to-tray mapping
         self._captured_ams_mapping: list[int] | None = None
         self._captured_ams_mapping: list[int] | None = None
 
 
+        # True once we've seen (and normalised 16->6) an A2L AMS-Lite unit in the
+        # AMS telemetry. Used to globalise the Lite's local `tray_now` to 24+slot.
+        # See normalize_am_unit_id / a2l_lite_wire_ids and memory a2l-am-unit-16.
+        self._has_a2l_am_unit: bool = False
+
         # Request topic subscription tracking
         # Request topic subscription tracking
         # Some printer MQTT brokers (e.g. P1S, A1) reject subscriptions to the request
         # Some printer MQTT brokers (e.g. P1S, A1) reject subscriptions to the request
         # topic by killing the TCP connection. We detect this and gracefully degrade.
         # topic by killing the TCP connection. We detect this and gracefully degrade.
@@ -682,6 +776,55 @@ class BambuMQTTClient:
         time_since_last = time.time() - self._last_message_time
         time_since_last = time.time() - self._last_message_time
         return time_since_last > self.STALE_TIMEOUT
         return time_since_last > self.STALE_TIMEOUT
 
 
+    def mark_power_off(self) -> bool:
+        """Presume the printer lost power (smart plug switched off).
+
+        Optimistic: it skips the MQTT stale timeout so the UI updates at once.
+        The presumption is undone by ``_on_message`` if the printer keeps
+        talking — inbound traffic proves the power was never cut (#2629).
+        Returns True when the state was actually changed.
+        """
+        if not self.state.connected:
+            return False
+        previous = self.state.state
+        # Blank the state BEFORE recording what to restore. This runs on the
+        # event loop while _on_message runs on the paho thread, and the restore
+        # is a two-step (read saved state, compare against "unknown"). Writing
+        # "unknown" first means an interleaved message either sees no saved
+        # state yet (and skips, leaving the next message to restore) or sees a
+        # consistent pair — never a saved state paired with a live state it
+        # then discards, which would strand the printer on "unknown".
+        self.state.connected = False
+        self.state.state = "unknown"
+        # Only the first mark wins: a second call before any message arrives
+        # must not overwrite the real state with the "unknown" it just wrote.
+        # Nothing to restore if the state was already blank.
+        if self._state_before_power_off is None and previous not in ("", "unknown"):
+            self._state_before_power_off = previous
+        return True
+
+    def _restore_state_after_false_power_off(self) -> bool:
+        """Undo a presumed power-off once the printer proves it is alive.
+
+        ``connected`` self-heals on the next message, but ``state`` does not:
+        it is only rewritten when a payload carries ``gcode_state``, and the
+        steady-state ``push_status`` frames are partial. Without this the
+        forced "unknown" sticks until a full pushall (a manual Force Refresh),
+        and the queue scheduler treats the printer as not idle the whole time
+        (#2629). Returns True when a state was restored.
+        """
+        previous = self._state_before_power_off
+        self._state_before_power_off = None
+        if previous is None or self.state.state != "unknown":
+            return False
+        logger.info(
+            "[%s] Printer still responding after presumed power-off — restoring state %s",
+            self.serial_number,
+            previous,
+        )
+        self.state.state = previous
+        return True
+
     # Minimum seconds between stale reconnect attempts.  Frontend polls
     # Minimum seconds between stale reconnect attempts.  Frontend polls
     # status every few seconds — without a cooldown, each poll would
     # status every few seconds — without a cooldown, each poll would
     # force-close the socket before paho has time to reconnect.
     # force-close the socket before paho has time to reconnect.
@@ -821,6 +964,12 @@ class BambuMQTTClient:
         if rc == 0:
         if rc == 0:
             self.state.connected = True
             self.state.connected = True
             self._stale_reconnecting = False  # Clear stale-reconnect flag on successful connect
             self._stale_reconnecting = False  # Clear stale-reconnect flag on successful connect
+            # A dropped-and-restored MQTT session means the presumed power-off was
+            # real (or at least that the printer restarted): there is nothing
+            # legitimate left to restore, and the printer will send a full status
+            # push shortly. Dropping the saved state keeps a stale one from being
+            # broadcast ahead of the first real report (#2629, #1679).
+            self._state_before_power_off = None
             # Reset per-connection warning state so warnings fire once per (re)connection
             # Reset per-connection warning state so warnings fire once per (re)connection
             self._ams_version_warned = set()
             self._ams_version_warned = set()
             # Preserve cached developer_mode across auto-reconnects to avoid
             # Preserve cached developer_mode across auto-reconnects to avoid
@@ -838,6 +987,11 @@ class BambuMQTTClient:
             self._report_messages_since_connect = 0
             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
+            # Drop any assignment verifications that were mid-flight before the
+            # reconnect — their deadlines are stale and the tray state we would
+            # compare against is about to be re-pushed from scratch (#2582).
+            # Dropping is silent (no failure event) on purpose.
+            self._pending_assignments.clear()
             client.subscribe(self.topic_subscribe)
             client.subscribe(self.topic_subscribe)
             # Subscribe to request topic for ams_mapping capture (if supported by broker)
             # Subscribe to request topic for ams_mapping capture (if supported by broker)
             if self._request_topic_supported:
             if self._request_topic_supported:
@@ -982,6 +1136,11 @@ class BambuMQTTClient:
             # "printer never sent a report" apart from a mid-session quiet gap.
             # "printer never sent a report" apart from a mid-session quiet gap.
             if msg.topic == self.topic_subscribe:
             if msg.topic == self.topic_subscribe:
                 self._report_messages_since_connect += 1
                 self._report_messages_since_connect += 1
+                # Only report-topic traffic proves the *printer* is alive — the
+                # request topic also carries slicer/Bambuddy commands.
+                if self._state_before_power_off is not None:
+                    if self._restore_state_after_false_power_off() and self.on_state_change:
+                        self.on_state_change(self.state)
 
 
             # Log message if logging is enabled
             # Log message if logging is enabled
             if self._logging_enabled:
             if self._logging_enabled:
@@ -1281,10 +1440,19 @@ class BambuMQTTClient:
                 elif cmd == "ams_filament_setting":
                 elif cmd == "ams_filament_setting":
                     self._last_ams_cmd_time = 0.0
                     self._last_ams_cmd_time = 0.0
                     self._ams_cmd_unanswered = 0
                     self._ams_cmd_unanswered = 0
-            if "command" in print_data and print_data.get("command") == "extrusion_cali_get":
+            is_kprofile_response = "command" in print_data and print_data.get("command") == "extrusion_cali_get"
+            if is_kprofile_response:
                 self._handle_kprofile_response(print_data)
                 self._handle_kprofile_response(print_data)
 
 
-            self._update_state(print_data)
+            # An extrusion_cali_get response echoes the *requested* nozzle
+            # diameter (get_kprofiles probes 0.2/0.4/0.6/0.8 in turn), not the
+            # installed hardware. Feeding it to _update_state clobbered the real
+            # nozzle size (#2663) — typically leaving 0.8, the last size probed,
+            # which then failed the #1899 dispatch guard. The response carries no
+            # status telemetry, so skip it; the true nozzle comes from pushall.
+            # (Same reasoning as get_accessories in _handle_system_response.)
+            if not is_kprofile_response:
+                self._update_state(print_data)
 
 
     def _handle_system_response(self, data: dict):
     def _handle_system_response(self, data: dict):
         """Handle system responses including accessories info.
         """Handle system responses including accessories info.
@@ -1731,6 +1899,34 @@ class BambuMQTTClient:
             )
             )
             self.on_ams_change(self.state.raw_data.get("ams") or [])
             self.on_ams_change(self.state.raw_data.get("ams") or [])
 
 
+    def _normalize_a2l_am_units(self, ams_list) -> None:
+        """A2L AMS-Lite normalisation (#a2l-am-unit-16): rewrite the physical unit
+        id 16 -> 6 in place, as early as possible, so every downstream reader —
+        the merge, apply_tray_exist_bits (bit base 24), the API, usage tracking,
+        the DB constraint — sees the normalised id and needs no special-casing.
+        ``tray_now`` (local) and the outbound wire are handled separately. Only id
+        16 is ever touched, so every other printer/AMS type is untouched. Runs on
+        both the dict-wrapped and bare-list AMS shapes.
+        """
+        if not isinstance(ams_list, list):
+            return
+        for unit in ams_list:
+            if not isinstance(unit, dict):
+                continue
+            try:
+                uid = int(unit.get("id"))
+            except (TypeError, ValueError):
+                continue
+            if uid == A2L_LITE_PHYSICAL_AMS_ID:
+                unit["id"] = A2L_LITE_NORMALIZED_AMS_ID
+                if not self._has_a2l_am_unit:
+                    logger.info(
+                        "[%s] A2L AMS-Lite detected (unit id 16) — normalising to id %d",
+                        self.serial_number,
+                        A2L_LITE_NORMALIZED_AMS_ID,
+                    )
+                self._has_a2l_am_unit = True
+
     def _handle_ams_data(self, ams_data):
     def _handle_ams_data(self, ams_data):
         """Handle AMS data changes for Spoolman integration.
         """Handle AMS data changes for Spoolman integration.
 
 
@@ -1745,6 +1941,7 @@ class BambuMQTTClient:
         if isinstance(ams_data, dict):
         if isinstance(ams_data, dict):
             if "ams" in ams_data:
             if "ams" in ams_data:
                 ams_list = ams_data["ams"]
                 ams_list = ams_data["ams"]
+                self._normalize_a2l_am_units(ams_list)
             # Log all AMS dict fields to debug tray_now for H2D dual-nozzle
             # Log all AMS dict fields to debug tray_now for H2D dual-nozzle
             non_list_fields = {k: v for k, v in ams_data.items() if k != "ams"}
             non_list_fields = {k: v for k, v in ams_data.items() if k != "ams"}
             if non_list_fields:
             if non_list_fields:
@@ -1978,9 +2175,26 @@ class BambuMQTTClient:
                             ams_exist = 0
                             ams_exist = 0
                         num_ams = bin(ams_exist).count("1")
                         num_ams = bin(ams_exist).count("1")
 
 
-                        if num_ams > 1:
+                        if self._has_a2l_am_unit and num_ams <= 1:
+                            # A2L AMS-Lite (normalised unit 6): the firmware reports
+                            # tray_now as a LOCAL 0-3 slot, so globalise to 24+slot —
+                            # otherwise usage tracking keys the wrong spool (it would
+                            # deduct from AMS 0's slot). Confirmed by capture:
+                            # tray_now="2" while printing physical slot 3.
+                            self.state.tray_now = A2L_LITE_GLOBAL_BASE + parsed_tray_now
+                        elif num_ams > 1:
                             # Multiple AMS on single-nozzle — tray_now is likely a local slot ID.
                             # Multiple AMS on single-nozzle — tray_now is likely a local slot ID.
                             # Cross-reference with MQTT mapping field to find the correct AMS unit.
                             # Cross-reference with MQTT mapping field to find the correct AMS unit.
+                            if self._has_a2l_am_unit:
+                                # A2L Lite + a regular AMS attached together is out of
+                                # scope: the flat mapping ids are unknown for that combo
+                                # and could collide with AMS 0. Fall through to the
+                                # mapping-based resolve, but warn — a capture is needed.
+                                logger.warning(
+                                    "[%s] A2L AMS-Lite alongside another AMS unit is unsupported — "
+                                    "tray_now resolution may be wrong (needs a mixed-setup capture)",
+                                    self.serial_number,
+                                )
                             mapping_raw = self.state.raw_data.get("mapping")
                             mapping_raw = self.state.raw_data.get("mapping")
                             resolved = self._resolve_local_slot_from_mapping(parsed_tray_now, mapping_raw)
                             resolved = self._resolve_local_slot_from_mapping(parsed_tray_now, mapping_raw)
                             if resolved is not None:
                             if resolved is not None:
@@ -2006,9 +2220,15 @@ class BambuMQTTClient:
                     self.state.tray_now = parsed_tray_now
                     self.state.tray_now = parsed_tray_now
 
 
                 # Track last valid tray for usage tracking (survives retract → 255 at print end)
                 # Track last valid tray for usage tracking (survives retract → 255 at print end)
-                # Valid physical trays: 0-15 (regular AMS), 128-135 (AMS-HT), 254 (external spool)
+                # Valid physical trays: 0-15 (regular AMS), 24-27 (A2L AMS-Lite,
+                # normalised unit 6), 128-135 (AMS-HT), 254 (external spool)
                 tn = self.state.tray_now
                 tn = self.state.tray_now
-                if (0 <= tn <= 15) or (128 <= tn <= 135) or tn == 254:
+                if (
+                    (0 <= tn <= 15)
+                    or (A2L_LITE_GLOBAL_BASE <= tn <= A2L_LITE_GLOBAL_BASE + 3)
+                    or (128 <= tn <= 135)
+                    or tn == 254
+                ):
                     # Log tray change for mid-print usage splitting. Gate on the
                     # Log tray change for mid-print usage splitting. Gate on the
                     # print-lifecycle flags (`_was_running` set on first RUNNING /
                     # print-lifecycle flags (`_was_running` set on first RUNNING /
                     # new print, `_completion_triggered` set when on_print_complete
                     # new print, `_completion_triggered` set when on_print_complete
@@ -2044,6 +2264,7 @@ class BambuMQTTClient:
                 return
                 return
         elif isinstance(ams_data, list):
         elif isinstance(ams_data, list):
             ams_list = ams_data
             ams_list = ams_data
+            self._normalize_a2l_am_units(ams_list)
         else:
         else:
             logger.warning("[%s] Unexpected AMS data format: %s", self.serial_number, type(ams_data))
             logger.warning("[%s] Unexpected AMS data format: %s", self.serial_number, type(ams_data))
             return
             return
@@ -2312,9 +2533,17 @@ class BambuMQTTClient:
                 if self.on_drying_complete:
                 if self.on_drying_complete:
                     self.on_drying_complete(ams_id)
                     self.on_drying_complete(ams_id)
 
 
-        # Create a hash of relevant AMS data to detect changes
+        # Create a hash of relevant AMS data to detect changes.
+        # Hash the MERGED state, not the raw incoming ams_list: a removal signalled
+        # only by tray_exist_bits (firmware still echoing the old tray_type in the
+        # payload, unchanged remain) clears merged_ams via apply_tray_exist_bits
+        # above but leaves the raw payload's tracked fields untouched — so a
+        # raw-based hash never flips and on_ams_change never fires, leaving the
+        # spool_assignment row bound to an emptied slot (#2670). merged_ams also
+        # always spans every unit, so a partial single-unit update can't produce a
+        # spuriously different hash from a full pushall.
         ams_hash_data = []
         ams_hash_data = []
-        for ams_unit in ams_list:
+        for ams_unit in merged_ams:
             for tray in ams_unit.get("tray", []):
             for tray in ams_unit.get("tray", []):
                 # Include fields that matter for filament tracking
                 # Include fields that matter for filament tracking
                 ams_hash_data.append(
                 ams_hash_data.append(
@@ -2332,6 +2561,147 @@ class BambuMQTTClient:
                 # may lack fields like 'remain' that the merged state preserves
                 # may lack fields like 'remain' that the merged state preserves
                 self.on_ams_change(merged_ams)
                 self.on_ams_change(merged_ams)
 
 
+        # #2582: read-back check runs on EVERY AMS push, not just hash changes.
+        # The change hash keys on tray_type/tag_uid/remain — NOT tray_info_idx
+        # or cali_idx — so an assignment that only swaps the filament id on an
+        # already-loaded slot would not flip the hash, and gating the check on
+        # it would miss exactly the confirmation we are after.
+        if self._pending_assignments:
+            self._check_assignment_verifications()
+
+    def register_assignment_verification(
+        self,
+        ams_id: int,
+        tray_id: int,
+        tray_info_idx: str,
+        tray_color: str,
+        cali_idx: int | None,
+    ) -> None:
+        """Record an assignment we just pushed so subsequent AMS telemetry can
+        confirm the tray actually accepted it (#2582).
+
+        Called right after ``ams_set_filament_setting`` + ``extrusion_cali_sel``.
+        ``tray_info_idx`` is the primary signal — the slicer/printer echoes the
+        accepted filament id back in the per-tray push, so a match means the
+        setting landed. ``cali_idx`` (when >= 0) is verified as a secondary
+        signal so we can specifically flag "filament loaded but K-profile not
+        applied", which is the exact symptom the reporter chased via flow-cal.
+
+        A blank ``tray_info_idx`` means we had nothing resolvable to send, so
+        there is nothing to verify and no record is stored.
+        """
+        want_idx = (tray_info_idx or "").strip().upper()
+        if not want_idx:
+            return
+        self._pending_assignments[(ams_id, tray_id)] = {
+            "tray_info_idx": want_idx,
+            "tray_color": (tray_color or "").strip().upper(),
+            "cali_idx": cali_idx,
+            "deadline": time.monotonic() + self.ASSIGNMENT_VERIFY_TIMEOUT,
+            "last_seen_idx": None,
+        }
+
+    def _find_verify_tray(self, ams_id: int, tray_id: int) -> dict | None:
+        """Locate the live tray dict for a pending verification.
+
+        External spools (ams_id 255) live in ``vt_tray`` under global ids
+        254/255; regular and HT AMS trays live under ``ams[].tray[]``. HT units
+        report a single tray whose id may not equal the logical tray_id, so fall
+        back to the sole tray when an id match fails.
+        """
+        raw = self.state.raw_data or {}
+        if ams_id == 255:
+            want_ext = 254 + tray_id
+            for vt in raw.get("vt_tray", []) or []:
+                if isinstance(vt, dict) and str(vt.get("id")) == str(want_ext):
+                    return vt
+            return None
+        for unit in raw.get("ams", []) or []:
+            if str(unit.get("id")) != str(ams_id):
+                continue
+            trays = unit.get("tray", []) or []
+            for tray in trays:
+                if str(tray.get("id")) == str(tray_id):
+                    return tray
+            if ams_id >= 128 and len(trays) == 1:
+                return trays[0]
+            return None
+        return None
+
+    def _check_assignment_verifications(self) -> None:
+        """Compare each pending assignment against live tray telemetry and fire
+        ``on_assignment_verified`` on a match or once the deadline passes.
+
+        Runs on every AMS push. Non-matching-but-still-within-window entries are
+        left in place for the next push. The timeout branch only fires when a
+        later push arrives after the deadline; if the printer goes silent we
+        simply never confirm, which is preferable to inventing a failure.
+        """
+        now = time.monotonic()
+        for key, want in list(self._pending_assignments.items()):
+            ams_id, tray_id = key
+            tray = self._find_verify_tray(ams_id, tray_id)
+            actual_idx = str((tray or {}).get("tray_info_idx") or "").strip().upper()
+            if tray is not None and actual_idx:
+                want["last_seen_idx"] = actual_idx
+            if actual_idx and actual_idx == want["tray_info_idx"]:
+                self._pending_assignments.pop(key, None)
+                kprofile_applied = True
+                want_cali = want.get("cali_idx")
+                if want_cali is not None and want_cali >= 0:
+                    actual_cali = tray.get("cali_idx")
+                    kprofile_applied = actual_cali == want_cali
+                self._fire_assignment_verified(
+                    ams_id,
+                    tray_id,
+                    True,
+                    {
+                        "tray_info_idx": actual_idx,
+                        "kprofile_applied": kprofile_applied,
+                    },
+                )
+            elif now >= want["deadline"]:
+                self._pending_assignments.pop(key, None)
+                self._fire_assignment_verified(
+                    ams_id,
+                    tray_id,
+                    False,
+                    {
+                        "expected_tray_info_idx": want["tray_info_idx"],
+                        "actual_tray_info_idx": want.get("last_seen_idx"),
+                        # True when we saw the tray at least once (so the push
+                        # channel is alive and the printer really stored a
+                        # different/blank id) vs never observing it at all.
+                        "saw_tray": want.get("last_seen_idx") is not None,
+                    },
+                )
+
+    def _fire_assignment_verified(self, ams_id: int, tray_id: int, verified: bool, detail: dict) -> None:
+        if verified:
+            logger.info(
+                "[%s] Assignment verified: AMS%d-T%d now reports %s (kprofile_applied=%s)",
+                self.serial_number,
+                ams_id,
+                tray_id,
+                detail.get("tray_info_idx"),
+                detail.get("kprofile_applied"),
+            )
+        else:
+            logger.warning(
+                "[%s] Assignment NOT confirmed: AMS%d-T%d expected %s, tray shows %s (saw_tray=%s)",
+                self.serial_number,
+                ams_id,
+                tray_id,
+                detail.get("expected_tray_info_idx"),
+                detail.get("actual_tray_info_idx"),
+                detail.get("saw_tray"),
+            )
+        if self.on_assignment_verified:
+            try:
+                self.on_assignment_verified(ams_id, tray_id, verified, detail)
+            except Exception:
+                logger.exception("[%s] on_assignment_verified callback failed", self.serial_number)
+
     def _update_state(self, data: dict):
     def _update_state(self, data: dict):
         """Update printer state from message data."""
         """Update printer state from message data."""
         _previous_state = self.state.state
         _previous_state = self.state.state
@@ -3486,7 +3856,12 @@ class BambuMQTTClient:
             # Clear and seed tray change log for mid-print usage splitting
             # Clear and seed tray change log for mid-print usage splitting
             self.state.tray_change_log.clear()
             self.state.tray_change_log.clear()
             tn = self.state.tray_now
             tn = self.state.tray_now
-            if (0 <= tn <= 15) or (128 <= tn <= 135) or tn == 254:
+            if (
+                (0 <= tn <= 15)
+                or (A2L_LITE_GLOBAL_BASE <= tn <= A2L_LITE_GLOBAL_BASE + 3)
+                or (128 <= tn <= 135)
+                or tn == 254
+            ):
                 self.state.tray_change_log.append((tn, 0))
                 self.state.tray_change_log.append((tn, 0))
             # Initialize timelapse tracking based on current state
             # Initialize timelapse tracking based on current state
             # NOTE: xcam data is parsed BEFORE this code runs in _process_message,
             # NOTE: xcam data is parsed BEFORE this code runs in _process_message,
@@ -3842,13 +4217,13 @@ class BambuMQTTClient:
         filename: str,
         filename: str,
         plate_id: int = 1,
         plate_id: int = 1,
         ams_mapping: list[int] | None = None,
         ams_mapping: list[int] | None = None,
-        bed_levelling: bool = True,
-        flow_cali: bool = False,
+        bed_levelling: str = "auto",
+        flow_cali: str = "auto",
         vibration_cali: bool = True,
         vibration_cali: bool = True,
         layer_inspect: bool = False,
         layer_inspect: bool = False,
         timelapse: bool = False,
         timelapse: bool = False,
         use_ams: bool = True,
         use_ams: bool = True,
-        nozzle_offset_cali: bool = False,
+        nozzle_offset_cali: str = "auto",
         nozzle_mapping: str | None = None,
         nozzle_mapping: str | None = None,
     ):
     ):
         """Start a print job on the printer.
         """Start a print job on the printer.
@@ -3861,12 +4236,13 @@ class BambuMQTTClient:
             ams_mapping: List of tray IDs for each filament slot in the 3MF.
             ams_mapping: List of tray IDs for each filament slot in the 3MF.
                          Global tray ID = (ams_id * 4) + slot_id, external = 254
                          Global tray ID = (ams_id * 4) + slot_id, external = 254
             timelapse: Record timelapse video
             timelapse: Record timelapse video
-            bed_levelling: Auto bed levelling before print
-            flow_cali: Flow/pressure advance calibration
+            bed_levelling: Bed levelling — tri-state "off"/"on"/"auto" (auto skips
+                if the bed was levelled recently, matching BambuStudio).
+            flow_cali: Flow/pressure advance calibration — "off"/"on"/"auto".
             vibration_cali: Vibration compensation calibration
             vibration_cali: Vibration compensation calibration
             layer_inspect: First layer AI inspection
             layer_inspect: First layer AI inspection
             use_ams: Use AMS for automatic filament changes
             use_ams: Use AMS for automatic filament changes
-            nozzle_offset_cali: Run nozzle offset calibration before print
+            nozzle_offset_cali: Nozzle offset calibration — "off"/"on"/"auto"
                 (dual-nozzle printers only — silently ignored on single-nozzle).
                 (dual-nozzle printers only — silently ignored on single-nozzle).
             nozzle_mapping: Opaque JSON string captured from BambuStudio's
             nozzle_mapping: Opaque JSON string captured from BambuStudio's
                 project_file for H2C rack-swap (O1C2) (#1780). When non-null
                 project_file for H2C rack-swap (O1C2) (#1780). When non-null
@@ -3957,6 +4333,14 @@ class BambuMQTTClient:
                         # AMS-HT: global tray ID IS the ams_id (single tray per unit)
                         # AMS-HT: global tray ID IS the ams_id (single tray per unit)
                         flat_ams_mapping.append(tray_id)
                         flat_ams_mapping.append(tray_id)
                         ams_mapping2.append({"ams_id": tray_id, "slot_id": 0})
                         ams_mapping2.append({"ams_id": tray_id, "slot_id": 0})
+                    elif (_a2l := a2l_lite_wire_ids(tray_id // 4, tray_id)) is not None:
+                        # A2L AMS-Lite (normalised global 24-27): flat mapping is the
+                        # LOCAL slot 0-3 and ams_mapping2 carries {ams_id:16, slot_id:0-3}
+                        # — both CONFIRMED against the firmware's own mapping
+                        # (flat [1], ams_mapping2 {ams_id:16, slot_id:1}).
+                        _wire_ams, _wire_slot, _ = _a2l
+                        flat_ams_mapping.append(_wire_slot)
+                        ams_mapping2.append({"ams_id": _wire_ams, "slot_id": _wire_slot})
                     else:
                     else:
                         # Regular AMS tray: Global tray ID = (ams_id * 4) + slot_id
                         # Regular AMS tray: Global tray ID = (ams_id * 4) + slot_id
                         ams_id = tray_id // 4
                         ams_id = tray_id // 4
@@ -4033,6 +4417,17 @@ class BambuMQTTClient:
             # the archive even before the printer echoes subtask_id back (#1485).
             # the archive even before the printer echoes subtask_id back (#1485).
             self.last_dispatch_subtask_id = submission_id
             self.last_dispatch_subtask_id = submission_id
 
 
+            # Tri-state calibration options → BambuStudio's getValueInt encoding:
+            # off=0 (never), on=1 (force every print), auto=2 (printer runs it
+            # only if it wasn't done recently). The paired bool field is true
+            # only for the explicit "on" state — for "auto" the bool is false and
+            # the int carries the intent, exactly as BambuStudio's SelectMachine
+            # sends it. Unknown values fall back to auto.
+            _tristate_wire = {"off": 0, "on": 1, "auto": 2}
+            bed_level_int = _tristate_wire.get(bed_levelling, 2)
+            flow_cali_int = _tristate_wire.get(flow_cali, 2)
+            nozzle_cali_int = _tristate_wire.get(nozzle_offset_cali, 2)
+
             command = {
             command = {
                 "print": {
                 "print": {
                     "sequence_id": "20000",
                     "sequence_id": "20000",
@@ -4043,32 +4438,34 @@ class BambuMQTTClient:
                     "md5": "",
                     "md5": "",
                     "bed_type": "auto",
                     "bed_type": "auto",
                     "timelapse": timelapse,
                     "timelapse": timelapse,
-                    "bed_leveling": bed_levelling,
-                    "auto_bed_leveling": 1 if bed_levelling else 0,
-                    "flow_cali": flow_cali,
+                    # bed_leveling stays a JSON bool (true only for "on") and
+                    # auto_bed_leveling carries the tri-state int — the exact
+                    # two-field shape BambuStudio sends. The int must stay a plain
+                    # number, never quoted (#1478 boolean-family concern applies to
+                    # the *_cali bools, not these companion ints).
+                    "bed_leveling": bed_levelling == "on",
+                    "auto_bed_leveling": bed_level_int,
+                    "flow_cali": flow_cali == "on",
                     "vibration_cali": vibration_cali,
                     "vibration_cali": vibration_cali,
                     "layer_inspect": layer_inspect,
                     "layer_inspect": layer_inspect,
                     "use_ams": use_ams,
                     "use_ams": use_ams,
                     "cfg": "0",
                     "cfg": "0",
                     # extrude_cali_flag gates flow-dynamics calibration:
                     # extrude_cali_flag gates flow-dynamics calibration:
-                    # 1 = run it, 0 = printer skips entirely (#1478 evidence).
-                    # 2 = "skip and reuse stored PA" was previously believed to
-                    # suppress the stage too, but #1721 testing on H2D 01.x
-                    # showed stage 8 ("Calibrating dynamic flow") still gets
-                    # queued when we send 2. A real BambuStudio Send-dialog
-                    # capture today also showed 0 when the user disables flow
-                    # calibration. Going with 0 to actually suppress the
-                    # pre-print calibration stage.
-                    "extrude_cali_flag": 1 if flow_cali else 0,
+                    # 0 = never, 1 = force every print, 2 = auto (run only if the
+                    # filament wasn't calibrated recently). #1721 saw stage 8
+                    # ("Calibrating dynamic flow") still queued when we send 2 —
+                    # that is exactly the auto contract (the printer queues the
+                    # stage and skips it at runtime if recent), not a bug, so 2 is
+                    # the right wire value for "auto". off/on remain 0/1.
+                    "extrude_cali_flag": flow_cali_int,
                     "extrude_cali_manual_mode": 0,
                     "extrude_cali_manual_mode": 0,
-                    # 1 = run, 0 = skip (matches BambuStudio's wire today). The
-                    # earlier 2 = "skip" reading from #1682 didn't actually
-                    # suppress stage 39 ("Nozzle offset calibration") on H2D
-                    # 01.x — captured live in #1721. BambuStudio exposes the
-                    # toggle only for dual-nozzle (H2D/H2D Pro/H2C/X2D); single-
-                    # nozzle prints still resolve to 0 here so firmware never
-                    # runs a calibration the head doesn't support.
-                    "nozzle_offset_cali": 1 if (nozzle_offset_cali and is_dual_nozzle) else 0,
+                    # 0 = never, 1 = force, 2 = auto (skip if recent). #1721 saw
+                    # stage 39 ("Nozzle offset calibration") still queued on 2 —
+                    # again the auto contract, not a failure to suppress.
+                    # BambuStudio exposes the toggle only for dual-nozzle
+                    # (H2D/H2D Pro/H2C/X2D); single-nozzle prints resolve to 0 so
+                    # firmware never runs a calibration the head doesn't support.
+                    "nozzle_offset_cali": nozzle_cali_int if is_dual_nozzle else 0,
                     "subtask_name": filename.replace(".3mf", "").replace(".gcode", ""),
                     "subtask_name": filename.replace(".3mf", "").replace(".gcode", ""),
                     "profile_id": "0",
                     "profile_id": "0",
                     "project_id": submission_id,
                     "project_id": submission_id,
@@ -4449,11 +4846,16 @@ class BambuMQTTClient:
         if not self._client:
         if not self._client:
             return False
             return False
         self._sequence_id += 1
         self._sequence_id += 1
+        # A2L AMS-Lite: normalised id 6 -> physical 16 on the wire (the Lite does
+        # not actually support drying, but keep the translation consistent). The
+        # _drying_targets dict below stays keyed by the normalised id so the
+        # on_drying_complete callback matches the telemetry.
+        wire_ams_id = a2l_lite_wire_ids(ams_id, 0)[0] if ams_id == A2L_LITE_NORMALIZED_AMS_ID else ams_id
         command = {
         command = {
             "print": {
             "print": {
                 "sequence_id": str(self._sequence_id),
                 "sequence_id": str(self._sequence_id),
                 "command": "ams_filament_drying",
                 "command": "ams_filament_drying",
-                "ams_id": ams_id,
+                "ams_id": wire_ams_id,
                 "temp": temp,
                 "temp": temp,
                 "cooling_temp": 20 if mode == 1 else 0,
                 "cooling_temp": 20 if mode == 1 else 0,
                 "duration": duration,
                 "duration": duration,
@@ -5252,6 +5654,7 @@ class BambuMQTTClient:
         #     BambuStudio uses slot_id=0 (extruder index, 0=right), and
         #     BambuStudio uses slot_id=0 (extruder index, 0=right), and
         #     curr_temp/tar_temp = the actual right-nozzle temp.  See #891.
         #     curr_temp/tar_temp = the actual right-nozzle temp.  See #891.
         self._sequence_id += 1
         self._sequence_id += 1
+        wire_target = tray_id
         if tray_id == 255:
         if tray_id == 255:
             ams_id = 255
             ams_id = 255
             slot_id = 0  # extruder index for the right nozzle
             slot_id = 0  # extruder index for the right nozzle
@@ -5265,6 +5668,13 @@ class BambuMQTTClient:
             slot_id = 254
             slot_id = 254
             curr_temp = -1
             curr_temp = -1
             tar_temp = -1
             tar_temp = -1
+        elif (_a2l := a2l_lite_wire_ids(tray_id // 4, tray_id)) is not None:
+            # A2L AMS-Lite: physical unit 16 + local slot confirmed; the wire
+            # `target` (physical global 64-67) is extrapolated (no A2L load
+            # capture yet). See a2l_lite_wire_ids.
+            ams_id, slot_id, wire_target = _a2l
+            curr_temp = -1
+            tar_temp = -1
         else:
         else:
             ams_id = tray_id // 4
             ams_id = tray_id // 4
             slot_id = tray_id % 4
             slot_id = tray_id % 4
@@ -5277,7 +5687,7 @@ class BambuMQTTClient:
                 "sequence_id": str(self._sequence_id),
                 "sequence_id": str(self._sequence_id),
                 "ams_id": ams_id,
                 "ams_id": ams_id,
                 "slot_id": slot_id,
                 "slot_id": slot_id,
-                "target": tray_id,
+                "target": wire_target,
                 "curr_temp": curr_temp,
                 "curr_temp": curr_temp,
                 "tar_temp": tar_temp,
                 "tar_temp": tar_temp,
             }
             }
@@ -5313,6 +5723,8 @@ class BambuMQTTClient:
         # Determine source ams_id for the unload command
         # Determine source ams_id for the unload command
         if tray_now == 255 or tray_now == 254:
         if tray_now == 255 or tray_now == 254:
             ams_id = 255  # No filament or external spool
             ams_id = 255  # No filament or external spool
+        elif (_a2l := a2l_lite_wire_ids(tray_now // 4, tray_now)) is not None:
+            ams_id = _a2l[0]  # A2L AMS-Lite: normalised 6 -> physical 16
         else:
         else:
             ams_id = tray_now // 4  # Source AMS
             ams_id = tray_now // 4  # Source AMS
 
 
@@ -5402,9 +5814,16 @@ class BambuMQTTClient:
             logger.warning("[%s] Cannot refresh AMS tray: filament loaded from %s", self.serial_number, loaded_tray)
             logger.warning("[%s] Cannot refresh AMS tray: filament loaded from %s", self.serial_number, loaded_tray)
             return False, f"Please unload filament first. Currently loaded: {loaded_tray}"
             return False, f"Please unload filament first. Currently loaded: {loaded_tray}"
 
 
+        # A2L AMS-Lite: physical unit 16 + local slot (matches ams_mapping2).
+        wire_ams_id, wire_slot_id = ams_id, tray_id
+        if (_a2l := a2l_lite_wire_ids(ams_id, tray_id)) is not None:
+            wire_ams_id, wire_slot_id, _ = _a2l
+
         # Use ams_get_rfid command to trigger RFID re-read
         # Use ams_get_rfid command to trigger RFID re-read
         # This command is used by Bambu Studio to re-read the RFID tag
         # This command is used by Bambu Studio to re-read the RFID tag
-        command = {"print": {"command": "ams_get_rfid", "ams_id": ams_id, "slot_id": tray_id, "sequence_id": "0"}}
+        command = {
+            "print": {"command": "ams_get_rfid", "ams_id": wire_ams_id, "slot_id": wire_slot_id, "sequence_id": "0"}
+        }
         self._client.publish(self.topic_publish, json.dumps(command), qos=1)
         self._client.publish(self.topic_publish, json.dumps(command), qos=1)
         logger.info("[%s] Triggering RFID re-read: AMS %s, slot %s", self.serial_number, ams_id, tray_id)
         logger.info("[%s] Triggering RFID re-read: AMS %s, slot %s", self.serial_number, ams_id, tray_id)
 
 
@@ -5466,6 +5885,11 @@ class BambuMQTTClient:
                 mqtt_ams_id = 255
                 mqtt_ams_id = 255
                 mqtt_tray_id = 254
                 mqtt_tray_id = 254
             slot_id = 0
             slot_id = 0
+        elif (_a2l := a2l_lite_wire_ids(ams_id, tray_id)) is not None:
+            # A2L AMS-Lite: physical unit 16, local 0-3 slot (matches the
+            # firmware's own ams_mapping2 {ams_id:16, slot_id:0-3}).
+            mqtt_ams_id, slot_id, _ = _a2l
+            mqtt_tray_id = slot_id
         elif ams_id <= 3:
         elif ams_id <= 3:
             mqtt_ams_id = ams_id
             mqtt_ams_id = ams_id
             mqtt_tray_id = tray_id
             mqtt_tray_id = tray_id
@@ -5532,6 +5956,10 @@ class BambuMQTTClient:
                 mqtt_ams_id = 255
                 mqtt_ams_id = 255
                 mqtt_tray_id = 254
                 mqtt_tray_id = 254
             slot_id = 0
             slot_id = 0
+        elif (_a2l := a2l_lite_wire_ids(ams_id, tray_id)) is not None:
+            # A2L AMS-Lite: physical unit 16, local 0-3 slot (matches ams_mapping2).
+            mqtt_ams_id, slot_id, _ = _a2l
+            mqtt_tray_id = slot_id
         elif ams_id <= 3:
         elif ams_id <= 3:
             mqtt_ams_id = ams_id
             mqtt_ams_id = ams_id
             mqtt_tray_id = tray_id
             mqtt_tray_id = tray_id
@@ -5616,6 +6044,11 @@ class BambuMQTTClient:
             mqtt_ams_id = ams_id
             mqtt_ams_id = ams_id
             mqtt_tray_id = ams_id * 4 + tray_id
             mqtt_tray_id = ams_id * 4 + tray_id
             slot_id = tray_id
             slot_id = tray_id
+        elif (_a2l := a2l_lite_wire_ids(ams_id, tray_id)) is not None:
+            # A2L AMS-Lite: physical unit 16 + local slot are confirmed; the GLOBAL
+            # tray_id this command wants (physical 16*4+slot) is extrapolated (no
+            # A2L cali_sel capture yet) — see a2l_lite_wire_ids.
+            mqtt_ams_id, slot_id, mqtt_tray_id = _a2l
         elif ams_id >= 128 and ams_id <= 135:
         elif ams_id >= 128 and ams_id <= 135:
             mqtt_ams_id = ams_id
             mqtt_ams_id = ams_id
             mqtt_tray_id = tray_id
             mqtt_tray_id = tray_id
@@ -5680,6 +6113,13 @@ class BambuMQTTClient:
 
 
         nozzle_id = f"HS00-{nozzle_diameter}"
         nozzle_id = f"HS00-{nozzle_diameter}"
 
 
+        # A2L AMS-Lite: a normalised global tray (24-27) must go out as the
+        # physical global (extrapolated 64-67; see a2l_lite_wire_ids). ams_id
+        # stays 0 (hardcoded, as for every other unit here).
+        wire_tray_id = tray_id
+        if 0 <= tray_id <= 253 and (_a2l := a2l_lite_wire_ids(tray_id // 4, tray_id)) is not None:
+            wire_tray_id = _a2l[2]
+
         filament_entry = {
         filament_entry = {
             "ams_id": 0,
             "ams_id": 0,
             "cali_idx": cali_idx,
             "cali_idx": cali_idx,
@@ -5691,7 +6131,7 @@ class BambuMQTTClient:
             "nozzle_diameter": nozzle_diameter,
             "nozzle_diameter": nozzle_diameter,
             "nozzle_id": nozzle_id,
             "nozzle_id": nozzle_id,
             "setting_id": setting_id,
             "setting_id": setting_id,
-            "tray_id": tray_id,
+            "tray_id": wire_tray_id,
         }
         }
 
 
         command = {
         command = {

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

@@ -11,7 +11,7 @@ import asyncio
 import logging
 import logging
 import re
 import re
 import shutil
 import shutil
-from collections.abc import AsyncGenerator
+from collections.abc import AsyncGenerator, Callable
 from pathlib import Path
 from pathlib import Path
 from urllib.parse import urlparse
 from urllib.parse import urlparse
 
 
@@ -592,13 +592,30 @@ async def test_connection(url: str, camera_type: str) -> dict:
         return {"success": False, "error": f"Connection failed: {error_type}"}
         return {"success": False, "error": f"Connection failed: {error_type}"}
 
 
 
 
-async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> AsyncGenerator[bytes, None]:
+async def generate_mjpeg_stream(
+    url: str,
+    camera_type: str,
+    fps: int = 10,
+    *,
+    on_process: Callable[[asyncio.subprocess.Process], None] | None = None,
+    stop_event: asyncio.Event | None = None,
+) -> AsyncGenerator[bytes, None]:
     """Generator yielding MJPEG frames for streaming.
     """Generator yielding MJPEG frames for streaming.
 
 
     Args:
     Args:
         url: Camera URL or USB device path
         url: Camera URL or USB device path
         camera_type: "mjpeg", "rtsp", "snapshot", or "usb"
         camera_type: "mjpeg", "rtsp", "snapshot", or "usb"
         fps: Target frames per second
         fps: Target frames per second
+        on_process: Called with the spawned ffmpeg process for the ``usb`` and
+            ``rtsp`` paths so the route layer can register it into the shared
+            stream registries — that's what lets ``/camera/stop`` and the orphan
+            janitor find and kill a leaked ffmpeg that's holding a USB device
+            open (#2675). Without it the process is reachable only from this
+            generator's own ``finally``, which an abrupt client disconnect can
+            skip (same cancellation-timing class as #776).
+        stop_event: When set, the reconnect loops stop retrying — so an explicit
+            stop (which kills the current ffmpeg) doesn't immediately respawn a
+            new process and reacquire the device.
 
 
     Yields:
     Yields:
         MJPEG frame data with HTTP multipart boundaries
         MJPEG frame data with HTTP multipart boundaries
@@ -617,7 +634,7 @@ async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> As
                 if current_time - last_frame_time >= frame_interval:
                 if current_time - last_frame_time >= frame_interval:
                     last_frame_time = current_time
                     last_frame_time = current_time
                     yield _format_mjpeg_frame(frame)
                     yield _format_mjpeg_frame(frame)
-            if not frame_yielded or attempt == max_retries:
+            if not frame_yielded or attempt == max_retries or (stop_event is not None and stop_event.is_set()):
                 break
                 break
             logger.warning(
             logger.warning(
                 "External MJPEG stream ended, reconnecting (attempt %d/%d)...",
                 "External MJPEG stream ended, reconnecting (attempt %d/%d)...",
@@ -631,10 +648,10 @@ async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> As
         max_retries = 3
         max_retries = 3
         for attempt in range(max_retries + 1):
         for attempt in range(max_retries + 1):
             frame_yielded = False
             frame_yielded = False
-            async for frame in _stream_rtsp(url, fps):
+            async for frame in _stream_rtsp(url, fps, on_process=on_process):
                 frame_yielded = True
                 frame_yielded = True
                 yield _format_mjpeg_frame(frame)
                 yield _format_mjpeg_frame(frame)
-            if not frame_yielded or attempt == max_retries:
+            if not frame_yielded or attempt == max_retries or (stop_event is not None and stop_event.is_set()):
                 break
                 break
             logger.warning(
             logger.warning(
                 "External RTSP stream ended, reconnecting (attempt %d/%d)...",
                 "External RTSP stream ended, reconnecting (attempt %d/%d)...",
@@ -645,7 +662,7 @@ async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> As
 
 
     elif camera_type == "usb":
     elif camera_type == "usb":
         # Use ffmpeg to stream from USB camera
         # Use ffmpeg to stream from USB camera
-        async for frame in _stream_usb(url, fps):
+        async for frame in _stream_usb(url, fps, on_process=on_process):
             yield _format_mjpeg_frame(frame)
             yield _format_mjpeg_frame(frame)
 
 
     elif camera_type == "snapshot":
     elif camera_type == "snapshot":
@@ -724,7 +741,12 @@ async def _stream_mjpeg(url: str) -> AsyncGenerator[bytes, None]:
         logger.error("MJPEG stream error: %s", e)
         logger.error("MJPEG stream error: %s", e)
 
 
 
 
-async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
+async def _stream_rtsp(
+    url: str,
+    fps: int,
+    *,
+    on_process: Callable[[asyncio.subprocess.Process], None] | None = None,
+) -> AsyncGenerator[bytes, None]:
     """Stream frames from RTSP URL via ffmpeg.
     """Stream frames from RTSP URL via ffmpeg.
 
 
     For rtsps:// URLs, a local TLS proxy (Python OpenSSL) is used instead
     For rtsps:// URLs, a local TLS proxy (Python OpenSSL) is used instead
@@ -805,6 +827,11 @@ async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
             stdout=asyncio.subprocess.PIPE,
             stdout=asyncio.subprocess.PIPE,
             stderr=asyncio.subprocess.PIPE,
             stderr=asyncio.subprocess.PIPE,
         )
         )
+        # Register immediately — before the startup probe below — so a process
+        # that hangs on connect (rather than exiting) is still reachable by the
+        # stop endpoint / orphan janitor (#2675).
+        if on_process is not None:
+            on_process(process)
 
 
         # Brief check for immediate startup failures
         # Brief check for immediate startup failures
         await asyncio.sleep(0.1)
         await asyncio.sleep(0.1)
@@ -865,7 +892,12 @@ async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
             await proxy_server.wait_closed()
             await proxy_server.wait_closed()
 
 
 
 
-async def _stream_usb(device: str, fps: int) -> AsyncGenerator[bytes, None]:
+async def _stream_usb(
+    device: str,
+    fps: int,
+    *,
+    on_process: Callable[[asyncio.subprocess.Process], None] | None = None,
+) -> AsyncGenerator[bytes, None]:
     """Stream frames from USB camera via ffmpeg."""
     """Stream frames from USB camera via ffmpeg."""
     ffmpeg = get_ffmpeg_path()
     ffmpeg = get_ffmpeg_path()
     if not ffmpeg:
     if not ffmpeg:
@@ -907,6 +939,12 @@ async def _stream_usb(device: str, fps: int) -> AsyncGenerator[bytes, None]:
             stdout=asyncio.subprocess.PIPE,
             stdout=asyncio.subprocess.PIPE,
             stderr=asyncio.subprocess.PIPE,
             stderr=asyncio.subprocess.PIPE,
         )
         )
+        # Register immediately — before the startup probe below — so a process
+        # that hangs in open()/ioctl on a still-locked device (rather than
+        # exiting with a "busy" error) is still reachable by the stop endpoint /
+        # orphan janitor (#2675).
+        if on_process is not None:
+            on_process(process)
 
 
         # Give ffmpeg a moment to start and check for immediate failures
         # Give ffmpeg a moment to start and check for immediate failures
         await asyncio.sleep(0.5)
         await asyncio.sleep(0.5)

+ 13 - 6
backend/app/services/git_providers/gitea.py

@@ -63,14 +63,21 @@ class GiteaBackend(GitHubBackend):
             return tree_node.get("sha")
             return tree_node.get("sha")
         return None
         return None
 
 
+    # Gitea/Forgejo can be hosted under a URL path prefix (ROOT_URL like
+    # https://host/gitea), so the repo lives at /<prefix...>/<owner>/<repo>
+    # rather than at the host root (#2642). Capture the scheme+host+prefix as
+    # one group and the final two path segments as owner/repo; the lazy prefix
+    # group is empty for a root-hosted instance. One shared pattern keeps
+    # parse_repo_url() and get_api_base() from drifting.
+    _HTTPS_REPO_RE = re.compile(
+        r"(https?://[\w.\-]+(?::\d+)?(?:/[\w.\-]+)*?)/([\w.\-]{1,100})/([\w.\-]{1,100})(?:\.git)?/?$"
+    )
+
     def parse_repo_url(self, url: str) -> tuple[str, str]:
     def parse_repo_url(self, url: str) -> tuple[str, str]:
         """Return (owner, repo) — accepts both https:// and http:// for self-hosted instances."""
         """Return (owner, repo) — accepts both https:// and http:// for self-hosted instances."""
         if not url or len(url) > 500:
         if not url or len(url) > 500:
             raise ValueError("Invalid Git URL: URL too long or empty")
             raise ValueError("Invalid Git URL: URL too long or empty")
-        match = re.match(
-            r"https?://[\w.\-]+(:\d+)?/([\w.\-]{1,100})/([\w.\-]{1,100})(?:\.git)?/?$",
-            url,
-        )
+        match = self._HTTPS_REPO_RE.match(url)
         if match:
         if match:
             return match.group(2), match.group(3).removesuffix(".git")
             return match.group(2), match.group(3).removesuffix(".git")
         match = re.match(
         match = re.match(
@@ -82,8 +89,8 @@ class GiteaBackend(GitHubBackend):
         raise ValueError(f"Cannot parse repository URL: {url}")
         raise ValueError(f"Cannot parse repository URL: {url}")
 
 
     def get_api_base(self, repo_url: str) -> str:
     def get_api_base(self, repo_url: str) -> str:
-        """Derive API base from the repository URL's scheme and host."""
-        match = re.match(r"(https?://[\w.\-]+(:\d+)?)/", repo_url)
+        """Derive API base from the repository URL's scheme, host and any path prefix."""
+        match = self._HTTPS_REPO_RE.match(repo_url)
         if match:
         if match:
             return f"{match.group(1)}/api/v1"
             return f"{match.group(1)}/api/v1"
         raise ValueError(f"Cannot derive API base from URL: {repo_url}")
         raise ValueError(f"Cannot derive API base from URL: {repo_url}")

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

@@ -256,10 +256,14 @@ def authenticate_ldap_user(config: LDAPConfig, username: str, password: str) ->
             return None
             return None
 
 
         info = _extract_user_info(service_conn, config, user_entry, username)
         info = _extract_user_info(service_conn, config, user_entry, username)
+        # Don't log the raw DN — its leaf CN is the user's real name (PII, #2681).
+        # The username + group count is enough to confirm a successful auth; the
+        # support-bundle sanitizer also redacts any DN that slips through (e.g. an
+        # ldap3 exception string), but keeping it out of the log at the source is
+        # the primary hygiene per the "no private data in logs" rule.
         logger.info(
         logger.info(
-            "LDAP authentication successful for user: %s (DN: %s, groups: %d)",
+            "LDAP authentication successful for user: %s (groups: %d)",
             info.username,
             info.username,
-            user_dn,
             len(info.groups),
             len(info.groups),
         )
         )
         return info
         return info

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

@@ -25,6 +25,21 @@ logger = logging.getLogger(__name__)
 # parse it out; the log-health scanner does not.
 # 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+(.*)$")
 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+(.*)$")
 
 
+# LDAP Distinguished Names carry PII — the leaf ``CN=`` is the user's real name
+# (#2681). Match a run of at least two ``attr=value`` RDN components joined by
+# commas, where ``attr`` is a known LDAP attribute type. Requiring two components
+# keeps this from clobbering an incidental ``key=value`` in an unrelated log line,
+# while still catching DNs wherever they surface — the deliberate "auth successful"
+# line, ldap3 exception strings, and group DNs alike. Bias is intentionally toward
+# redaction: over-redacting a rare debug line to ``[DN]`` is a safe failure; leaking
+# a name is not.
+# The value char class excludes `<>;+` — RFC 4514 requires those escaped inside a
+# DN value, so an unescaped one marks the end of the DN, not part of it. That stops
+# the final (comma-unbounded) component from greedily swallowing trailing log text
+# such as ``… -> GroupName``.
+_LDAP_RDN = r"(?:CN|OU|DC|UID|O|L|ST|C|SN|GN|DN|E|MAIL|STREET|GIVENNAME|SURNAME)=[^,\n<>;+]+"
+_LDAP_DN_PATTERN = re.compile(rf"(?i)\b{_LDAP_RDN}(?:\s*,\s*{_LDAP_RDN})+")
+
 
 
 class LogEntry(BaseModel):
 class LogEntry(BaseModel):
     """A single parsed log entry."""
     """A single parsed log entry."""
@@ -159,6 +174,9 @@ def sanitize_log_content(content: str, sensitive_strings: dict[str, str] | None
     # Replace email addresses
     # Replace email addresses
     content = re.sub(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "[EMAIL]", content)
     content = re.sub(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "[EMAIL]", content)
 
 
+    # Replace LDAP Distinguished Names (#2681) — PII on par with email.
+    content = _LDAP_DN_PATTERN.sub("[DN]", content)
+
     # Replace Bambu Lab printer serial numbers (format: 00M/01D/01S/01P/03W + alphanumeric, 12-16 chars total)
     # 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)
     content = re.sub(r"\b0[0-3][A-Z0-9][A-Z0-9]{9,13}\b", "[SERIAL]", content, flags=re.IGNORECASE)
 
 

+ 16 - 4
backend/app/services/makerworld.py

@@ -28,6 +28,8 @@ from urllib.parse import urlparse
 import certifi
 import certifi
 import httpx
 import httpx
 
 
+from backend.app.services.bambu_cloud import is_expiry_401
+
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
@@ -255,8 +257,18 @@ class MakerWorldService:
         if self._owns_client:
         if self._owns_client:
             await self._client.aclose()
             await self._client.aclose()
 
 
-    async def _note_auth_failure(self) -> None:
-        """Record that Bambu rejected the token we sent. Best-effort, once."""
+    async def _note_auth_failure(self, response: httpx.Response) -> None:
+        """Durably record a dead credential — only for Bambu's genuine expiry 401.
+
+        A MakerWorld 401 without the ``{"code":4,"error":"Please login."}``
+        signature is endpoint- or edge-specific noise, not an expired token;
+        invalidating on it would sign the user out of the whole cloud
+        integration on a single stray rejection (the #2562 follow-up
+        regression). Best-effort, once per service instance.
+        """
+        if not is_expiry_401(response):
+            logger.info("MakerWorld returned 401 without the expiry signature — not signing the stored token out")
+            return
         if self._on_auth_failure is None or self._auth_failure_reported:
         if self._on_auth_failure is None or self._auth_failure_reported:
             return
             return
         self._auth_failure_reported = True
         self._auth_failure_reported = True
@@ -307,7 +319,7 @@ class MakerWorldService:
                 # We sent a token and Bambu refused it — the credential is dead,
                 # We sent a token and Bambu refused it — the credential is dead,
                 # not merely absent. Record that before raising so the rest of the
                 # not merely absent. Record that before raising so the rest of the
                 # app stops claiming the user is connected.
                 # app stops claiming the user is connected.
-                await self._note_auth_failure()
+                await self._note_auth_failure(response)
                 raise MakerWorldAuthError(_SIGN_IN_EXPIRED_MESSAGE)
                 raise MakerWorldAuthError(_SIGN_IN_EXPIRED_MESSAGE)
             raise MakerWorldAuthError(f"Signing in to Bambu Cloud is required for {path}")
             raise MakerWorldAuthError(f"Signing in to Bambu Cloud is required for {path}")
         if response.status_code == 403:
         if response.status_code == 403:
@@ -469,7 +481,7 @@ class MakerWorldService:
             raise MakerWorldUnavailableError(f"Bambu Lab API request failed: {exc}") from exc
             raise MakerWorldUnavailableError(f"Bambu Lab API request failed: {exc}") from exc
 
 
         if response.status_code == 401:
         if response.status_code == 401:
-            await self._note_auth_failure()
+            await self._note_auth_failure(response)
             raise MakerWorldAuthError(_SIGN_IN_EXPIRED_MESSAGE)
             raise MakerWorldAuthError(_SIGN_IN_EXPIRED_MESSAGE)
         if response.status_code == 403:
         if response.status_code == 403:
             upstream = _extract_upstream_error(response)
             upstream = _extract_upstream_error(response)

+ 58 - 14
backend/app/services/print_scheduler.py

@@ -518,11 +518,13 @@ class PrintScheduler:
                         auto_on_plugs = [p for p in plugs if p.auto_on and p.enabled]
                         auto_on_plugs = [p for p in plugs if p.auto_on and p.enabled]
                         if auto_on_plugs:
                         if auto_on_plugs:
                             logger.info("Printer %s offline, attempting to power on via smart plug(s)", item.printer_id)
                             logger.info("Printer %s offline, attempting to power on via smart plug(s)", item.printer_id)
-                            # Power on using the first auto_on plug (the printer power plug)
-                            powered_on = await self._power_on_and_wait(auto_on_plugs[0], item.printer_id, db)
+                            # Power on using the plug that actually feeds the printer, and
+                            # wait for it to boot on that one only (#2629).
+                            primary_plug = self._pick_power_plug(auto_on_plugs)
+                            powered_on = await self._power_on_and_wait(primary_plug, item.printer_id, db)
                             if powered_on:
                             if powered_on:
                                 # Also turn on any remaining auto_on plugs (e.g., filter)
                                 # Also turn on any remaining auto_on plugs (e.g., filter)
-                                for extra_plug in auto_on_plugs[1:]:
+                                for extra_plug in [p for p in auto_on_plugs if p.id != primary_plug.id]:
                                     try:
                                     try:
                                         service = await smart_plug_manager.get_service_for_plug(extra_plug, db)
                                         service = await smart_plug_manager.get_service_for_plug(extra_plug, db)
                                         await service.turn_on(extra_plug)
                                         await service.turn_on(extra_plug)
@@ -1156,6 +1158,14 @@ class PrintScheduler:
         to carry ``force_color_match: True``.  The printer must have **every** such slot loaded
         to carry ``force_color_match: True``.  The printer must have **every** such slot loaded
         with an exact type+color match.
         with an exact type+color match.
 
 
+        When both the override and a candidate tray carry a ``tray_info_idx``, they must also
+        match on it: Bambu reports every PLA variant as ``tray_type == "PLA"``, so the
+        Basic/Matte/Silk distinction lives only in ``tray_info_idx`` (GFA00/GFA01/GFA06/...).
+        Without this, a job sliced for PLA Matte matched every white PLA regardless of variant
+        (#2650). If either side lacks an idx (custom/third-party spools report a blank one, and
+        older 3MFs carry none) we fall back to the historical type+colour behaviour so those
+        setups are unaffected.
+
         Returns:
         Returns:
             List of ``"TYPE (color)"`` strings for unmatched slots (empty list means all match).
             List of ``"TYPE (color)"`` strings for unmatched slots (empty list means all match).
         """
         """
@@ -1163,26 +1173,32 @@ class PrintScheduler:
         if not status:
         if not status:
             return [f"{o.get('type', '?')} ({o.get('color_name') or o.get('color', '?')})" for o in force_overrides]
             return [f"{o.get('type', '?')} ({o.get('color_name') or o.get('color', '?')})" for o in force_overrides]
 
 
-        # Build set of loaded type+colour pairs from AMS and external spool
-        loaded: set[tuple[str, str]] = set()
+        # Build loaded (type, colour, tray_info_idx) triples from AMS and external spool.
+        loaded: list[tuple[str, str, str]] = []
         for ams_unit in status.raw_data.get("ams", []):
         for ams_unit in status.raw_data.get("ams", []):
             for tray in ams_unit.get("tray", []):
             for tray in ams_unit.get("tray", []):
                 tray_type = tray.get("tray_type")
                 tray_type = tray.get("tray_type")
-                tray_color = tray.get("tray_color", "")
                 if tray_type:
                 if tray_type:
-                    color_norm = tray_color.replace("#", "").lower()[:6]
-                    loaded.add((_canonical_filament_type(tray_type), color_norm))
+                    color_norm = (tray.get("tray_color", "") or "").replace("#", "").lower()[:6]
+                    loaded.append(
+                        (_canonical_filament_type(tray_type), color_norm, tray.get("tray_info_idx", "") or "")
+                    )
         for vt in status.raw_data.get("vt_tray") or []:
         for vt in status.raw_data.get("vt_tray") or []:
             vt_type = vt.get("tray_type")
             vt_type = vt.get("tray_type")
             if vt_type:
             if vt_type:
                 color_norm = (vt.get("tray_color", "") or "").replace("#", "").lower()[:6]
                 color_norm = (vt.get("tray_color", "") or "").replace("#", "").lower()[:6]
-                loaded.add((_canonical_filament_type(vt_type), color_norm))
+                loaded.append((_canonical_filament_type(vt_type), color_norm, vt.get("tray_info_idx", "") or ""))
 
 
         missing = []
         missing = []
         for o in force_overrides:
         for o in force_overrides:
             o_type = _canonical_filament_type(o.get("type") or "")
             o_type = _canonical_filament_type(o.get("type") or "")
             o_color = (o.get("color") or "").replace("#", "").lower()[:6]
             o_color = (o.get("color") or "").replace("#", "").lower()[:6]
-            if (o_type, o_color) not in loaded:
+            o_idx = o.get("tray_info_idx") or ""
+            satisfied = any(
+                t_type == o_type and t_color == o_color and (not o_idx or not t_idx or o_idx == t_idx)
+                for t_type, t_color, t_idx in loaded
+            )
+            if not satisfied:
                 color_label = o.get("color_name") or o.get("color", "?")
                 color_label = o.get("color_name") or o.get("color", "?")
                 missing.append(f"{o_type} ({color_label})")
                 missing.append(f"{o_type} ({color_label})")
         return missing
         return missing
@@ -1372,9 +1388,18 @@ class PrintScheduler:
                         override = override_map[req["slot_id"]]
                         override = override_map[req["slot_id"]]
                         req["type"] = override["type"]
                         req["type"] = override["type"]
                         req["color"] = override["color"]
                         req["color"] = override["color"]
-                        # Clear tray_info_idx so matching uses type+color instead of
-                        # the original 3MF's tray_info_idx (which would match the old filament)
-                        req["tray_info_idx"] = ""
+                        # A manual/preference override SWAPS the slot's filament, so the
+                        # 3MF's original tray_info_idx now points at the old spool and must
+                        # be cleared — matching then falls back to type+colour. A
+                        # force_color_match override is not a swap: it carries the 3MF's
+                        # intended variant (Basic GFA00 / Matte GFA01 / Silk GFA06), so keep
+                        # it here too, letting the matcher pin the correct variant slot on a
+                        # printer holding two same-colour spools of different variants (#2650).
+                        # If that variant isn't loaded the matcher falls back to type+colour,
+                        # so an eligible printer never fails to map.
+                        req["tray_info_idx"] = (
+                            override.get("tray_info_idx", "") if override.get("force_color_match") else ""
+                        )
                         logger.debug(
                         logger.debug(
                             "Queue item %s: Override slot %d -> %s %s",
                             "Queue item %s: Override slot %d -> %s %s",
                             item.id,
                             item.id,
@@ -1438,7 +1463,11 @@ class PrintScheduler:
                 "slot_id": o["slot_id"],
                 "slot_id": o["slot_id"],
                 "type": o.get("type", ""),
                 "type": o.get("type", ""),
                 "color": o.get("color", ""),
                 "color": o.get("color", ""),
-                "tray_info_idx": "",
+                # These are all force_color_match overrides, so the idx (when the
+                # 3MF carried one) is the intended variant, not a stale swap —
+                # keep it so the matcher pins the right variant slot, falling back
+                # to type+colour when it isn't loaded (#2650).
+                "tray_info_idx": o.get("tray_info_idx", ""),
             }
             }
             for o in force_overrides
             for o in force_overrides
         ]
         ]
@@ -2407,6 +2436,21 @@ class PrintScheduler:
         result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
         result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
         return list(result.scalars().all())
         return list(result.scalars().all())
 
 
+    @staticmethod
+    def _pick_power_plug(auto_on_plugs: list[SmartPlug]) -> SmartPlug:
+        """Pick the plug to power-cycle a printer back online with (#2629).
+
+        Only a plug flagged ``controls_printer_power`` can actually bring the
+        printer back; waiting for a boot on an accessory (filter fan, lights)
+        just burns the power-on timeout and fails the dispatch. Falls back to
+        the first plug when none is flagged, which is the pre-#2629 behaviour.
+        Callers must pass a non-empty list.
+        """
+        for plug in auto_on_plugs:
+            if plug.controls_printer_power:
+                return plug
+        return auto_on_plugs[0]
+
     # Bundled defaults for preheat_filament_targets (#1468). Values are the
     # Bundled defaults for preheat_filament_targets (#1468). Values are the
     # chamber-temperature recommendations BambuStudio ships for the matching
     # chamber-temperature recommendations BambuStudio ships for the matching
     # filament profile; users can override via Settings → Workflow → Preheat
     # filament profile; users can override via Settings → Workflow → Preheat

+ 34 - 6
backend/app/services/printer_manager.py

@@ -327,6 +327,7 @@ class PrinterManager:
         self._on_layer_change: Callable[[int, int], None] | None = None
         self._on_layer_change: Callable[[int, int], None] | None = None
         self._on_bed_temp_update: Callable[[int, float], None] | None = None
         self._on_bed_temp_update: Callable[[int, float], None] | None = None
         self._on_drying_complete: Callable[[int, int], None] | None = None
         self._on_drying_complete: Callable[[int, int], None] | None = None
+        self._on_assignment_verified: Callable[[int, int, int, bool, dict], None] | None = None
         self._loop: asyncio.AbstractEventLoop | None = None
         self._loop: asyncio.AbstractEventLoop | None = None
         # Track who started the current print (Issue #206)
         # Track who started the current print (Issue #206)
         self._current_print_user: dict[int, dict] = {}  # {printer_id: {"user_id": int, "username": str}}
         self._current_print_user: dict[int, dict] = {}  # {printer_id: {"user_id": int, "username": str}}
@@ -513,6 +514,15 @@ class PrinterManager:
         """
         """
         self._on_drying_complete = callback
         self._on_drying_complete = callback
 
 
+    def set_assignment_verified_callback(self, callback: Callable[[int, int, int, bool, dict], None]):
+        """Set callback for spool-assignment read-back verification (#2582).
+
+        Receives ``(printer_id, ams_id, tray_id, verified, detail)``. Fires once
+        per assignment either when the tray telemetry confirms the pushed
+        filament id or when the verification window elapses without it.
+        """
+        self._on_assignment_verified = callback
+
     def _schedule_async(self, coro):
     def _schedule_async(self, coro):
         """Schedule an async coroutine from a sync context.
         """Schedule an async coroutine from a sync context.
 
 
@@ -576,6 +586,10 @@ class PrinterManager:
             if self._on_drying_complete:
             if self._on_drying_complete:
                 self._schedule_async(self._on_drying_complete(printer_id, ams_id))
                 self._schedule_async(self._on_drying_complete(printer_id, ams_id))
 
 
+        def on_assignment_verified(ams_id: int, tray_id: int, verified: bool, detail: dict):
+            if self._on_assignment_verified:
+                self._schedule_async(self._on_assignment_verified(printer_id, ams_id, tray_id, verified, detail))
+
         client = BambuMQTTClient(
         client = BambuMQTTClient(
             ip_address=printer.ip_address,
             ip_address=printer.ip_address,
             serial_number=printer.serial_number,
             serial_number=printer.serial_number,
@@ -590,6 +604,7 @@ class PrinterManager:
             on_drying_complete=on_drying_complete,
             on_drying_complete=on_drying_complete,
             on_print_running_observed=on_print_running_observed,
             on_print_running_observed=on_print_running_observed,
             on_finish_photo_moment=on_finish_photo_moment,
             on_finish_photo_moment=on_finish_photo_moment,
+            on_assignment_verified=on_assignment_verified,
         )
         )
 
 
         client.connect()
         client.connect()
@@ -683,6 +698,11 @@ class PrinterManager:
 
 
         This is used when we know the printer power was cut (e.g., smart plug turned off)
         This is used when we know the printer power was cut (e.g., smart plug turned off)
         to immediately update the UI without waiting for MQTT timeout.
         to immediately update the UI without waiting for MQTT timeout.
+
+        The mark is a presumption, not a fact: the plug may not actually feed
+        the printer. ``BambuMQTTClient.mark_power_off`` records the state it
+        overwrites so the client can undo it as soon as the printer sends
+        another report (#2629).
         """
         """
         import logging
         import logging
 
 
@@ -690,10 +710,8 @@ class PrinterManager:
 
 
         if printer_id in self._clients:
         if printer_id in self._clients:
             client = self._clients[printer_id]
             client = self._clients[printer_id]
-            if client.state.connected:
+            if client.mark_power_off():
                 logger.info("Marking printer %s as offline (smart plug power off)", printer_id)
                 logger.info("Marking printer %s as offline (smart plug power off)", printer_id)
-                client.state.connected = False
-                client.state.state = "unknown"
                 # Trigger the status change callback to broadcast via WebSocket
                 # Trigger the status change callback to broadcast via WebSocket
                 if self._on_status_change:
                 if self._on_status_change:
                     self._schedule_async(self._on_status_change(printer_id, client.state))
                     self._schedule_async(self._on_status_change(printer_id, client.state))
@@ -704,13 +722,13 @@ class PrinterManager:
         filename: str,
         filename: str,
         plate_id: int = 1,
         plate_id: int = 1,
         ams_mapping: list[int] | None = None,
         ams_mapping: list[int] | None = None,
-        bed_levelling: bool = True,
-        flow_cali: bool = False,
+        bed_levelling: str = "auto",
+        flow_cali: str = "auto",
         vibration_cali: bool = True,
         vibration_cali: bool = True,
         layer_inspect: bool = False,
         layer_inspect: bool = False,
         timelapse: bool = False,
         timelapse: bool = False,
         use_ams: bool = True,
         use_ams: bool = True,
-        nozzle_offset_cali: bool = False,
+        nozzle_offset_cali: str = "auto",
         nozzle_mapping: str | None = None,
         nozzle_mapping: str | None = None,
     ) -> bool:
     ) -> bool:
         """Start a print on a connected printer.
         """Start a print on a connected printer.
@@ -1052,6 +1070,9 @@ def resolve_expected_tray(
         return None
         return None
     if 4 <= raw_slot <= 15:
     if 4 <= raw_slot <= 15:
         return raw_slot
         return raw_slot
+    # 24-27 = A2L AMS-Lite (normalised unit 6) global tray ids, already resolved.
+    if 24 <= raw_slot <= 27:
+        return raw_slot
     return None
     return None
 
 
 
 
@@ -1133,6 +1154,13 @@ def printer_state_to_dict(
                         "drying_temp": tray.get("drying_temp"),
                         "drying_temp": tray.get("drying_temp"),
                         "drying_time": tray.get("drying_time"),
                         "drying_time": tray.get("drying_time"),
                         "state": state_val,
                         "state": state_val,
+                        # Firmware's authoritative presence bit (tray_exist_bits),
+                        # set by apply_tray_exist_bits. The REST serializer already
+                        # emits it (routes/printers.py); without it here the WS
+                        # shallow-merge drops `exists` after the first frame and
+                        # getEmptySlotKind falls back to the firmware-variant state
+                        # 9/10 heuristic — wrong for AMS-HT in both directions (#2670).
+                        "exists": tray.get("exists"),
                     }
                     }
                 )
                 )
             # Prefer humidity_raw (actual percentage) over humidity (index 1-5)
             # Prefer humidity_raw (actual percentage) over humidity (index 1-5)

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

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

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

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

+ 60 - 22
backend/app/services/slicer_api.py

@@ -9,7 +9,9 @@ under the hood, response body is raw G-code or 3MF with metadata in the
 """
 """
 
 
 import asyncio
 import asyncio
+import io
 import logging
 import logging
+import zipfile
 from collections.abc import Callable
 from collections.abc import Callable
 from typing import NamedTuple
 from typing import NamedTuple
 
 
@@ -74,6 +76,62 @@ def _format_sidecar_error(response: httpx.Response) -> str:
     return (message or details or response.text)[:500]
     return (message or details or response.text)[:500]
 
 
 
 
+def _handle_slice_response(response: httpx.Response, *, export_3mf: bool) -> SliceResult:
+    """Turn a sidecar ``/slice`` HTTP response into a validated ``SliceResult``.
+
+    Shared by ``slice_with_profiles`` / ``slice_without_profiles`` so the status
+    handling and output validation live in one place.
+
+    Beyond the status check, this guards against the sidecar (or a reverse proxy
+    in front of it) returning **HTTP 200 with a body that isn't a real slice**
+    (#2671): a stock/misconfigured sidecar, a proxy interstitial or truncated
+    response, or an OrcaSlicer/BambuStudio CLI crash that produces empty output.
+    Without this check Bambuddy would store that tiny blob as a ``.gcode.3mf``,
+    let it be queued, and FTP it to the printer — a silently-broken print. When
+    a 3MF export was requested the body must be a valid ZIP (3MF container);
+    anything else is treated as a sidecar failure.
+
+    Raises:
+        SlicerInputError: 4xx from the sidecar (bad input / proxy body limit).
+        SlicerApiServerError: 5xx, or a 2xx whose body is not a valid 3MF.
+    """
+    if response.status_code == 413:
+        # A 413 almost never comes from the slicer itself — it's a reverse proxy
+        # (nginx/SWAG/Traefik) or a CDN capping the multipart upload (model +
+        # profiles). Name the real fix so the user doesn't tweak the wrong layer.
+        raise SlicerInputError(
+            "The slice request was rejected as too large (HTTP 413). A reverse proxy "
+            "in front of the slicer sidecar is capping the request body — raise "
+            "'client_max_body_size' (nginx/SWAG) or the equivalent on the proxy that "
+            "sits directly in front of the sidecar, then reload it. If the sidecar is "
+            "behind Cloudflare, note its request-size cap."
+        )
+    if response.status_code >= 500:
+        raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {_format_sidecar_error(response)}")
+    if response.status_code >= 400:
+        raise SlicerInputError(f"Slicer rejected input ({response.status_code}): {_format_sidecar_error(response)}")
+
+    content = response.content
+    if export_3mf and not zipfile.is_zipfile(io.BytesIO(content)):
+        # 200 OK but the body is not a 3MF zip → the sidecar did not produce a
+        # usable slice. Surface it loudly instead of persisting a corrupt file.
+        detail = _format_sidecar_error(response) if len(content) <= 500 else ""
+        raise SlicerApiServerError(
+            f"Slicer sidecar returned HTTP {response.status_code} but the body is not a valid "
+            f"3MF ({len(content)} bytes). This usually means a misconfigured sidecar, an "
+            f"OrcaSlicer/BambuStudio CLI crash producing no output, or a reverse proxy returning "
+            f"an error page or truncating the response — verify the sidecar URL and any proxy in "
+            f"front of it." + (f" Body: {detail}" if detail else "")
+        )
+
+    return SliceResult(
+        content=content,
+        print_time_seconds=_safe_int(response.headers.get("x-print-time-seconds")),
+        filament_used_g=_safe_float(response.headers.get("x-filament-used-g")),
+        filament_used_mm=_safe_float(response.headers.get("x-filament-used-mm")),
+    )
+
+
 def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
 def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
     """Register an app-scoped client so per-request services can pool transport."""
     """Register an app-scoped client so per-request services can pool transport."""
     global _shared_http_client
     global _shared_http_client
@@ -294,17 +352,7 @@ class SlicerApiService:
                 except (asyncio.CancelledError, Exception):
                 except (asyncio.CancelledError, Exception):
                     pass  # Polling errors must not fail the slice.
                     pass  # Polling errors must not fail the slice.
 
 
-        if response.status_code >= 500:
-            raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {_format_sidecar_error(response)}")
-        if response.status_code >= 400:
-            raise SlicerInputError(f"Slicer rejected input ({response.status_code}): {_format_sidecar_error(response)}")
-
-        return SliceResult(
-            content=response.content,
-            print_time_seconds=_safe_int(response.headers.get("x-print-time-seconds")),
-            filament_used_g=_safe_float(response.headers.get("x-filament-used-g")),
-            filament_used_mm=_safe_float(response.headers.get("x-filament-used-mm")),
-        )
+        return _handle_slice_response(response, export_3mf=export_3mf)
 
 
     async def slice_without_profiles(
     async def slice_without_profiles(
         self,
         self,
@@ -372,17 +420,7 @@ class SlicerApiService:
                 except (asyncio.CancelledError, Exception):
                 except (asyncio.CancelledError, Exception):
                     pass
                     pass
 
 
-        if response.status_code >= 500:
-            raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {_format_sidecar_error(response)}")
-        if response.status_code >= 400:
-            raise SlicerInputError(f"Slicer rejected input ({response.status_code}): {_format_sidecar_error(response)}")
-
-        return SliceResult(
-            content=response.content,
-            print_time_seconds=_safe_int(response.headers.get("x-print-time-seconds")),
-            filament_used_g=_safe_float(response.headers.get("x-filament-used-g")),
-            filament_used_mm=_safe_float(response.headers.get("x-filament-used-mm")),
-        )
+        return _handle_slice_response(response, export_3mf=export_3mf)
 
 
 
 
 def _safe_int(value: str | None) -> int:
 def _safe_int(value: str | None) -> int:

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

@@ -215,8 +215,8 @@ class SmartPlugManager:
                             plug.last_state = "OFF"
                             plug.last_state = "OFF"
                             plug.last_checked = utcnow_naive()
                             plug.last_checked = utcnow_naive()
                             self._last_schedule_check[plug.id] = f"off:{current_time}"
                             self._last_schedule_check[plug.id] = f"off:{current_time}"
-                            # Mark printer offline if linked
-                            if plug.printer_id:
+                            # Mark printer offline if this plug feeds it (#2629)
+                            if plug.printer_id and plug.controls_printer_power:
                                 printer_manager.mark_printer_offline(plug.printer_id)
                                 printer_manager.mark_printer_offline(plug.printer_id)
 
 
             await db.commit()
             await db.commit()
@@ -410,6 +410,7 @@ class SmartPlugManager:
                 plug.password,
                 plug.password,
                 printer_id,
                 printer_id,
                 delay_seconds,
                 delay_seconds,
+                controls_printer_power=plug.controls_printer_power,
                 rest_off_url=plug.rest_off_url if plug.plug_type == "rest" else None,
                 rest_off_url=plug.rest_off_url if plug.plug_type == "rest" else None,
                 rest_off_body=plug.rest_off_body if plug.plug_type == "rest" else None,
                 rest_off_body=plug.rest_off_body if plug.plug_type == "rest" else None,
                 rest_method=plug.rest_method if plug.plug_type == "rest" else None,
                 rest_method=plug.rest_method if plug.plug_type == "rest" else None,
@@ -429,6 +430,7 @@ class SmartPlugManager:
         printer_id: int,
         printer_id: int,
         delay_seconds: int,
         delay_seconds: int,
         *,
         *,
+        controls_printer_power: bool = True,
         rest_off_url: str | None = None,
         rest_off_url: str | None = None,
         rest_off_body: str | None = None,
         rest_off_body: str | None = None,
         rest_method: str | None = None,
         rest_method: str | None = None,
@@ -476,8 +478,10 @@ class SmartPlugManager:
             # Mark auto_off_executed in database and update printer status
             # Mark auto_off_executed in database and update printer status
             if success:
             if success:
                 await self._mark_auto_off_executed(plug_id)
                 await self._mark_auto_off_executed(plug_id)
-                # Mark the printer as offline immediately
-                printer_manager.mark_printer_offline(printer_id)
+                # Mark the printer as offline immediately — but only when this
+                # plug actually feeds the printer (#2629).
+                if controls_printer_power:
+                    printer_manager.mark_printer_offline(printer_id)
 
 
         except asyncio.CancelledError:
         except asyncio.CancelledError:
             logger.debug("Delayed turn-off cancelled for plug %s", plug_id)
             logger.debug("Delayed turn-off cancelled for plug %s", plug_id)
@@ -504,6 +508,7 @@ class SmartPlugManager:
                 plug.password,
                 plug.password,
                 printer_id,
                 printer_id,
                 temp_threshold,
                 temp_threshold,
+                controls_printer_power=plug.controls_printer_power,
                 rest_off_url=plug.rest_off_url if plug.plug_type == "rest" else None,
                 rest_off_url=plug.rest_off_url if plug.plug_type == "rest" else None,
                 rest_off_body=plug.rest_off_body if plug.plug_type == "rest" else None,
                 rest_off_body=plug.rest_off_body if plug.plug_type == "rest" else None,
                 rest_method=plug.rest_method if plug.plug_type == "rest" else None,
                 rest_method=plug.rest_method if plug.plug_type == "rest" else None,
@@ -523,6 +528,7 @@ class SmartPlugManager:
         printer_id: int,
         printer_id: int,
         temp_threshold: int,
         temp_threshold: int,
         *,
         *,
+        controls_printer_power: bool = True,
         rest_off_url: str | None = None,
         rest_off_url: str | None = None,
         rest_off_body: str | None = None,
         rest_off_body: str | None = None,
         rest_method: str | None = None,
         rest_method: str | None = None,
@@ -603,8 +609,10 @@ class SmartPlugManager:
                         # Mark auto_off_executed in database and update printer status
                         # Mark auto_off_executed in database and update printer status
                         if success:
                         if success:
                             await self._mark_auto_off_executed(plug_id)
                             await self._mark_auto_off_executed(plug_id)
-                            # Mark the printer as offline immediately
-                            printer_manager.mark_printer_offline(printer_id)
+                            # Mark the printer as offline immediately — but only
+                            # when this plug actually feeds the printer (#2629).
+                            if controls_printer_power:
+                                printer_manager.mark_printer_offline(printer_id)
 
 
                         break
                         break
 
 
@@ -739,7 +747,8 @@ class SmartPlugManager:
                         success = await service.turn_off(plug)
                         success = await service.turn_off(plug)
                         if success:
                         if success:
                             await self._mark_auto_off_executed(plug.id)
                             await self._mark_auto_off_executed(plug.id)
-                            printer_manager.mark_printer_offline(plug.printer_id)
+                            if plug.controls_printer_power:
+                                printer_manager.mark_printer_offline(plug.printer_id)
 
 
                 if pending_plugs:
                 if pending_plugs:
                     logger.info("Resumed %s pending auto-off(s)", len(pending_plugs))
                     logger.info("Resumed %s pending auto-off(s)", len(pending_plugs))

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

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

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

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

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

@@ -542,6 +542,144 @@ class TestExternalFolderScan:
         assert subfolder["external_readonly"] is True
         assert subfolder["external_readonly"] is True
 
 
 
 
+class TestExternalFolderModifiedTime:
+    """Filesystem mtime capture + recursive activity sort (#2680).
+
+    The folder tree's "sort by recent activity" and the file pane's date sort
+    must track the real on-disk mtime (``ls -t``), not the DB ``updated_at`` (the
+    scan instant, identical across a bulk scan).
+    """
+
+    @staticmethod
+    def _set_mtime(path: Path, epoch: float) -> None:
+        os.utime(path, (epoch, epoch))
+
+    @pytest.fixture
+    async def make_folder(self, async_client, db_session):
+        async def _make(ext_dir: Path, name: str = "MTime Test") -> dict:
+            data = {
+                "name": name,
+                "external_path": str(ext_dir),
+                "readonly": True,
+                "show_hidden": False,
+            }
+            resp = await async_client.post("/api/v1/library/folders/external", json=data)
+            assert resp.status_code == 200
+            return resp.json()
+
+        return _make
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_scan_captures_file_fs_mtime(self, async_client, db_session, tmp_path, make_folder):
+        """Each scanned file carries its real on-disk mtime, not the scan time."""
+        ext = tmp_path / "prints"
+        ext.mkdir()
+        old = ext / "old.3mf"
+        new = ext / "new.3mf"
+        old.write_bytes(b"a")
+        new.write_bytes(b"b")
+        # old.3mf modified 2021-01-01, new.3mf modified 2024-01-01.
+        self._set_mtime(old, 1609459200.0)  # 2021-01-01T00:00:00Z
+        self._set_mtime(new, 1704067200.0)  # 2024-01-01T00:00:00Z
+
+        folder = await make_folder(ext)
+        await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
+
+        resp = await async_client.get(f"/api/v1/library/files?folder_id={folder['id']}")
+        files = {f["filename"]: f for f in resp.json()}
+        assert files["old.3mf"]["fs_modified_at"] is not None
+        assert files["new.3mf"]["fs_modified_at"] is not None
+        # The real mtime, not "now": the 2021 file must predate the 2024 file.
+        assert files["old.3mf"]["fs_modified_at"] < files["new.3mf"]["fs_modified_at"]
+        assert files["old.3mf"]["fs_modified_at"].startswith("2021")
+        assert files["new.3mf"]["fs_modified_at"].startswith("2024")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rescan_refreshes_changed_file_mtime(self, async_client, db_session, tmp_path, make_folder):
+        """A file edited over the mount re-sorts on the next scan (#2680)."""
+        ext = tmp_path / "prints"
+        ext.mkdir()
+        f = ext / "part.3mf"
+        f.write_bytes(b"a")
+        self._set_mtime(f, 1609459200.0)  # 2021
+
+        folder = await make_folder(ext)
+        await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
+
+        # File touched later (samba edit); re-scan must pick up the new mtime.
+        self._set_mtime(f, 1704067200.0)  # 2024
+        await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
+
+        resp = await async_client.get(f"/api/v1/library/files?folder_id={folder['id']}")
+        got = resp.json()[0]
+        assert got["fs_modified_at"].startswith("2024")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_recursive_activity_bubbles_deep_file_to_root(self, async_client, db_session, tmp_path, make_folder):
+        """A freshly-added deep file lifts every ancestor's activity (#2680).
+
+        ``a`` holds only an OLD file directly but a NEW file three levels down;
+        ``b`` holds a MIDDLE-aged file directly. Recursive bubble must rank ``a``
+        (newest descendant) ahead of ``b`` even though a's own direct file and
+        directory are older.
+        """
+        root = tmp_path / "root"
+        deep = root / "a" / "x" / "y"
+        deep.mkdir(parents=True)
+        (root / "b").mkdir()
+
+        a_direct = root / "a" / "shallow.3mf"
+        deep_file = deep / "deep.3mf"
+        b_direct = root / "b" / "mid.3mf"
+        for p, data in ((a_direct, b"1"), (deep_file, b"2"), (b_direct, b"3")):
+            p.write_bytes(data)
+
+        self._set_mtime(a_direct, 1609459200.0)  # 2021 (oldest)
+        self._set_mtime(b_direct, 1656633600.0)  # 2022-07 (middle)
+        self._set_mtime(deep_file, 1704067200.0)  # 2024 (newest, deep under a)
+        # Directory mtimes are all old so only the deep FILE can lift branch a.
+        for d in (root, root / "a", root / "a" / "x", deep, root / "b"):
+            self._set_mtime(d, 1609459200.0)
+
+        folder = await make_folder(root)
+        await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
+
+        tree = (await async_client.get("/api/v1/library/folders")).json()
+        top = find_folder_in_tree(tree, folder["name"])
+        assert top is not None
+        children = {c["name"]: c for c in top["children"]}
+        assert "a" in children and "b" in children
+        # Branch a's activity == the deep 2024 file; b's == its 2022 file.
+        assert children["a"]["latest_activity_at"] > children["b"]["latest_activity_at"]
+        assert children["a"]["latest_activity_at"].startswith("2024")
+        # The root itself bubbles up to the newest descendant anywhere inside it.
+        assert top["latest_activity_at"].startswith("2024")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_scan_captures_folder_fs_mtime(self, async_client, db_session, tmp_path, make_folder):
+        """An empty-but-recently-touched subfolder still carries a real mtime."""
+        root = tmp_path / "root"
+        sub = root / "sub"
+        sub.mkdir(parents=True)
+        # A file so the subfolder survives the empty-subfolder cleanup.
+        (sub / "keep.3mf").write_bytes(b"a")
+        self._set_mtime(sub / "keep.3mf", 1609459200.0)  # 2021
+        self._set_mtime(sub, 1704067200.0)  # dir touched 2024
+
+        folder = await make_folder(root)
+        await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
+
+        tree = (await async_client.get("/api/v1/library/folders")).json()
+        subfolder = find_folder_in_tree(tree, "sub")
+        assert subfolder is not None
+        # Dir mtime (2024) beats the single 2021 file → folder activity is 2024.
+        assert subfolder["latest_activity_at"].startswith("2024")
+
+
 class TestExternalFolderProtections:
 class TestExternalFolderProtections:
     """Tests for read-only protections on external folders."""
     """Tests for read-only protections on external folders."""
 
 

+ 7 - 7
backend/tests/integration/test_library_slice_api.py

@@ -206,7 +206,7 @@ class TestSliceLibraryFile:
             captured["url"] = str(request.url)
             captured["url"] = str(request.url)
             return httpx.Response(
             return httpx.Response(
                 status_code=200,
                 status_code=200,
-                content=b"PK\x03\x04 fake-3mf",
+                content=_make_3mf_with_settings(),  # #2671: real zip; validation rejects non-3MF bodies
                 headers={
                 headers={
                     "x-print-time-seconds": "656",
                     "x-print-time-seconds": "656",
                     "x-filament-used-g": "0.94",
                     "x-filament-used-g": "0.94",
@@ -249,7 +249,7 @@ class TestSliceLibraryFile:
             captured["body"] = bytes(request.content)
             captured["body"] = bytes(request.content)
             return httpx.Response(
             return httpx.Response(
                 status_code=200,
                 status_code=200,
-                content=b"PK\x03\x04 fake",
+                content=_make_3mf_with_settings(),  # #2671: real zip; validation rejects non-3MF bodies
                 headers={
                 headers={
                     "x-print-time-seconds": "10",
                     "x-print-time-seconds": "10",
                     "x-filament-used-g": "0.1",
                     "x-filament-used-g": "0.1",
@@ -291,7 +291,7 @@ class TestSliceLibraryFile:
             captured["body"] = bytes(request.content)
             captured["body"] = bytes(request.content)
             return httpx.Response(
             return httpx.Response(
                 status_code=200,
                 status_code=200,
-                content=b"PK\x03\x04 fake",
+                content=_make_3mf_with_settings(),  # #2671: real zip; validation rejects non-3MF bodies
                 headers={
                 headers={
                     "x-print-time-seconds": "10",
                     "x-print-time-seconds": "10",
                     "x-filament-used-g": "0.1",
                     "x-filament-used-g": "0.1",
@@ -416,7 +416,7 @@ class TestSliceLibraryFile:
             # Retry: no profile triplet → succeed with embedded settings
             # Retry: no profile triplet → succeed with embedded settings
             return httpx.Response(
             return httpx.Response(
                 status_code=200,
                 status_code=200,
-                content=b"PK\x03\x04 fake-3mf",
+                content=_make_3mf_with_settings(),  # #2671: real zip; validation rejects non-3MF bodies
                 headers={
                 headers={
                     "x-print-time-seconds": "100",
                     "x-print-time-seconds": "100",
                     "x-filament-used-g": "1.0",
                     "x-filament-used-g": "1.0",
@@ -499,7 +499,7 @@ class TestSliceLibraryFile:
             captured["body"] = request.content
             captured["body"] = request.content
             return httpx.Response(
             return httpx.Response(
                 status_code=200,
                 status_code=200,
-                content=b"PK\x03\x04 fake-3mf",
+                content=_make_3mf_with_settings(),  # #2671: real zip; validation rejects non-3MF bodies
                 headers={
                 headers={
                     "x-print-time-seconds": "1",
                     "x-print-time-seconds": "1",
                     "x-filament-used-g": "0",
                     "x-filament-used-g": "0",
@@ -564,7 +564,7 @@ class TestSliceLibraryFile:
             captured["body"] = request.content
             captured["body"] = request.content
             return httpx.Response(
             return httpx.Response(
                 status_code=200,
                 status_code=200,
-                content=b"PK\x03\x04 fake-3mf",
+                content=_make_3mf_with_settings(),  # #2671: real zip; validation rejects non-3MF bodies
                 headers={
                 headers={
                     "x-print-time-seconds": "100",
                     "x-print-time-seconds": "100",
                     "x-filament-used-g": "1.0",
                     "x-filament-used-g": "1.0",
@@ -607,7 +607,7 @@ class TestSliceLibraryFile:
             captured["body"] = request.content
             captured["body"] = request.content
             return httpx.Response(
             return httpx.Response(
                 status_code=200,
                 status_code=200,
-                content=b"PK\x03\x04 fake-3mf",
+                content=_make_3mf_with_settings(),  # #2671: real zip; validation rejects non-3MF bodies
                 headers={
                 headers={
                     "x-print-time-seconds": "1",
                     "x-print-time-seconds": "1",
                     "x-filament-used-g": "0",
                     "x-filament-used-g": "0",

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

@@ -1417,3 +1417,223 @@ class TestWriteSubResourceIDORClosure(TestOwnershipPermissionsSetup):
             headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
             headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
         )
         )
         assert response.status_code == 200
         assert response.status_code == 200
+
+
+class TestSliceOwnershipPermissions(TestOwnershipPermissionsSetup):
+    """IDOR regression: slicing and slice-job polling must honour per-row ownership.
+
+    Before the fix, ``POST /library/files/{id}/slice`` and
+    ``POST /archives/{id}/slice`` gated only on ``LIBRARY_UPLOAD``, so a
+    READ_OWN operator could slice another user's model by raw id even though a
+    direct GET on that id returned 404 — the sliced output was then attributed
+    to and downloadable by the requester. ``GET /slice-jobs/{id}`` had no owner
+    scoping at all. ``POST /slicer-pipelines/{id}/run`` (and check-eligibility)
+    resolved the source by raw id with the same gap.
+
+    The slice route enforces the gate before touching the source bytes, so the
+    owner/READ_ALL "control" cases reach the later on-disk check (a distinct 404
+    detail) rather than a real slice — enough to prove the gate lets them past.
+    """
+
+    # Any preset triplet: the ownership 404 fires before preset resolution.
+    _SLICE_BODY = {"printer_preset_id": 1, "process_preset_id": 2, "filament_preset_id": 3}
+
+    @pytest.fixture
+    async def library_file_factory(self, db_session):
+        _counter = [0]
+
+        async def _create_file(**kwargs):
+            from backend.app.models.library import LibraryFile
+
+            _counter[0] += 1
+            defaults = {
+                "filename": f"slice_src_{_counter[0]}.3mf",
+                "file_path": f"library/slice_src_{_counter[0]}.3mf",
+                "file_type": "3mf",
+                "file_size": 1024,
+            }
+            defaults.update(kwargs)
+            row = LibraryFile(**defaults)
+            db_session.add(row)
+            await db_session.commit()
+            await db_session.refresh(row)
+            return row
+
+        return _create_file
+
+    # --- library file slice ------------------------------------------------
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_slice_others_library_file(self, async_client, auth_setup, library_file_factory):
+        file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
+        resp = await async_client.post(
+            f"/api/v1/library/files/{file.id}/slice",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            json=self._SLICE_BODY,
+        )
+        assert resp.status_code == 404
+        # 404 (not 403) so a probing operator can't tell the id exists.
+        assert resp.json()["detail"] == "File not found"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_can_slice_own_library_file(self, async_client, auth_setup, library_file_factory):
+        file = await library_file_factory(created_by_id=auth_setup["operator_user"]["id"])
+        resp = await async_client.post(
+            f"/api/v1/library/files/{file.id}/slice",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            json=self._SLICE_BODY,
+        )
+        # Past the ownership gate — only the on-disk source is missing in tests.
+        assert resp.status_code == 404
+        assert resp.json()["detail"] == "Source file missing on disk"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_admin_can_slice_any_library_file(self, async_client, auth_setup, library_file_factory):
+        file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
+        resp = await async_client.post(
+            f"/api/v1/library/files/{file.id}/slice",
+            headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
+            json=self._SLICE_BODY,
+        )
+        # READ_ALL passes the gate even on another user's file.
+        assert resp.status_code == 404
+        assert resp.json()["detail"] == "Source file missing on disk"
+
+    # --- archive slice -----------------------------------------------------
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_slice_others_archive(
+        self, async_client, auth_setup, archive_factory, printer_factory
+    ):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, created_by_id=auth_setup["operator2_user"]["id"])
+        resp = await async_client.post(
+            f"/api/v1/archives/{archive.id}/slice",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            json=self._SLICE_BODY,
+        )
+        assert resp.status_code == 404
+        assert resp.json()["detail"] == "Archive not found"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_can_slice_own_archive(self, async_client, auth_setup, archive_factory, printer_factory):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, created_by_id=auth_setup["operator_user"]["id"])
+        resp = await async_client.post(
+            f"/api/v1/archives/{archive.id}/slice",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            json=self._SLICE_BODY,
+        )
+        # Past the gate — the archive's source file isn't on disk in tests.
+        assert resp.status_code == 404
+        assert resp.json()["detail"] == "Archive source file missing on disk"
+
+    # --- slice-job polling -------------------------------------------------
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slice_job_polling_is_owner_scoped(self, async_client, auth_setup):
+        from backend.app.services.slice_dispatch import slice_dispatch
+
+        async def _noop(_job_id):
+            return {}
+
+        job = await slice_dispatch.enqueue(
+            kind="library_file",
+            source_id=1,
+            source_name="secret_model.3mf",
+            owner_id=auth_setup["operator2_user"]["id"],
+            run=_noop,
+        )
+
+        # Non-owner without READ_ALL cannot see the job (404, not 403).
+        other = await async_client.get(
+            f"/api/v1/slice-jobs/{job.id}",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+        assert other.status_code == 404
+
+        # The owner and a READ_ALL admin can.
+        owner = await async_client.get(
+            f"/api/v1/slice-jobs/{job.id}",
+            headers={"Authorization": f"Bearer {auth_setup['operator2_token']}"},
+        )
+        assert owner.status_code == 200
+        admin = await async_client.get(
+            f"/api/v1/slice-jobs/{job.id}",
+            headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
+        )
+        assert admin.status_code == 200
+
+    # --- pipeline source resolution ----------------------------------------
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_pipeline_run_cannot_reference_others_library_file(
+        self, async_client, auth_setup, library_file_factory, db_session
+    ):
+        """A pipeline runner with READ_OWN cannot resolve another user's source.
+
+        The built-in Operators group has no pipeline permissions, so this uses a
+        custom group carrying PIPELINES_RUN + READ_OWN — the realistic shape of
+        the exposure. check-eligibility resolves the source before any
+        eligibility work, so the ownership gate is what returns 404.
+        """
+        from backend.app.models.slicer_pipeline import SlicerPipeline
+
+        admin_headers = {"Authorization": f"Bearer {auth_setup['admin_token']}"}
+        group_resp = await async_client.post(
+            "/api/v1/groups/",
+            headers=admin_headers,
+            json={
+                "name": "pipeline_runners",
+                "permissions": [
+                    "pipelines:read",
+                    "pipelines:run",
+                    "library:read_own",
+                    "archives:read_own",
+                ],
+            },
+        )
+        assert group_resp.status_code == 201, group_resp.text
+        group_id = group_resp.json()["id"]
+
+        await async_client.post(
+            "/api/v1/users/",
+            headers=admin_headers,
+            json={"username": "runner1", "password": "Runnerpass1!", "group_ids": [group_id]},
+        )
+        runner_login = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "runner1", "password": "Runnerpass1!"},
+        )
+        runner_token = runner_login.json()["access_token"]
+
+        pipeline = SlicerPipeline(
+            name="Cross-user pipeline",
+            printer_preset_source="local",
+            printer_preset_id="1",
+            process_preset_source="local",
+            process_preset_id="2",
+            filament_presets_json="[]",
+            target_kind="printer_class",
+            target_model_class="Bambu Lab X1 Carbon",
+        )
+        db_session.add(pipeline)
+        await db_session.commit()
+        await db_session.refresh(pipeline)
+
+        # Source owned by operator2, not the runner.
+        file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
+        resp = await async_client.post(
+            f"/api/v1/slicer-pipelines/{pipeline.id}/check-eligibility",
+            headers={"Authorization": f"Bearer {runner_token}"},
+            json={"source_library_file_id": file.id},
+        )
+        assert resp.status_code == 404
+        assert resp.json()["detail"] == "File not found"

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

@@ -289,8 +289,8 @@ class TestPrintQueueAPI:
         data = {
         data = {
             "printer_id": printer.id,
             "printer_id": printer.id,
             "archive_id": archive.id,
             "archive_id": archive.id,
-            "bed_levelling": False,
-            "flow_cali": True,
+            "bed_levelling": "off",
+            "flow_cali": "on",
             "vibration_cali": False,
             "vibration_cali": False,
             "layer_inspect": True,
             "layer_inspect": True,
             "timelapse": True,
             "timelapse": True,
@@ -299,8 +299,8 @@ class TestPrintQueueAPI:
         response = await async_client.post("/api/v1/queue/", json=data)
         response = await async_client.post("/api/v1/queue/", json=data)
         assert response.status_code == 200
         assert response.status_code == 200
         result = response.json()
         result = response.json()
-        assert result["bed_levelling"] is False
-        assert result["flow_cali"] is True
+        assert result["bed_levelling"] == "off"
+        assert result["flow_cali"] == "on"
         assert result["vibration_cali"] is False
         assert result["vibration_cali"] is False
         assert result["layer_inspect"] is True
         assert result["layer_inspect"] is True
         assert result["timelapse"] is True
         assert result["timelapse"] is True
@@ -324,13 +324,13 @@ class TestPrintQueueAPI:
         response = await async_client.patch(
         response = await async_client.patch(
             f"/api/v1/queue/{item.id}",
             f"/api/v1/queue/{item.id}",
             json={
             json={
-                "bed_levelling": False,
+                "bed_levelling": "off",
                 "timelapse": True,
                 "timelapse": True,
             },
             },
         )
         )
         assert response.status_code == 200
         assert response.status_code == 200
         result = response.json()
         result = response.json()
-        assert result["bed_levelling"] is False
+        assert result["bed_levelling"] == "off"
         assert result["timelapse"] is True
         assert result["timelapse"] is True
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
@@ -929,7 +929,7 @@ class TestQueueLibraryFileSupport:
             "library_file_id": lib_file.id,
             "library_file_id": lib_file.id,
             "ams_mapping": [1, 2, -1, -1],
             "ams_mapping": [1, 2, -1, -1],
             "plate_id": 2,
             "plate_id": 2,
-            "bed_levelling": False,
+            "bed_levelling": "off",
             "timelapse": True,
             "timelapse": True,
             "manual_start": True,
             "manual_start": True,
         }
         }
@@ -939,7 +939,7 @@ class TestQueueLibraryFileSupport:
         assert result["library_file_id"] == lib_file.id
         assert result["library_file_id"] == lib_file.id
         assert result["ams_mapping"] == [1, 2, -1, -1]
         assert result["ams_mapping"] == [1, 2, -1, -1]
         assert result["plate_id"] == 2
         assert result["plate_id"] == 2
-        assert result["bed_levelling"] is False
+        assert result["bed_levelling"] == "off"
         assert result["timelapse"] is True
         assert result["timelapse"] is True
         assert result["manual_start"] is True
         assert result["manual_start"] is True
 
 
@@ -1105,8 +1105,8 @@ class TestBulkUpdateEndpoint:
             defaults = {
             defaults = {
                 "status": "pending",
                 "status": "pending",
                 "position": 1,
                 "position": 1,
-                "bed_levelling": True,
-                "flow_cali": False,
+                "bed_levelling": "on",
+                "flow_cali": "off",
                 "vibration_cali": True,
                 "vibration_cali": True,
             }
             }
             defaults.update(kwargs)
             defaults.update(kwargs)
@@ -1123,12 +1123,12 @@ class TestBulkUpdateEndpoint:
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_bulk_update_single_field(self, async_client: AsyncClient, queue_item_factory, db_session):
     async def test_bulk_update_single_field(self, async_client: AsyncClient, queue_item_factory, db_session):
         """Verify bulk update can change a single field on multiple items."""
         """Verify bulk update can change a single field on multiple items."""
-        item1 = await queue_item_factory(bed_levelling=True)
-        item2 = await queue_item_factory(bed_levelling=True)
+        item1 = await queue_item_factory(bed_levelling="on")
+        item2 = await queue_item_factory(bed_levelling="on")
 
 
         response = await async_client.patch(
         response = await async_client.patch(
             "/api/v1/queue/bulk",
             "/api/v1/queue/bulk",
-            json={"item_ids": [item1.id, item2.id], "bed_levelling": False},
+            json={"item_ids": [item1.id, item2.id], "bed_levelling": "off"},
         )
         )
         assert response.status_code == 200
         assert response.status_code == 200
         result = response.json()
         result = response.json()
@@ -1138,22 +1138,22 @@ class TestBulkUpdateEndpoint:
         # Verify items were updated
         # Verify items were updated
         await db_session.refresh(item1)
         await db_session.refresh(item1)
         await db_session.refresh(item2)
         await db_session.refresh(item2)
-        assert item1.bed_levelling is False
-        assert item2.bed_levelling is False
+        assert item1.bed_levelling == "off"
+        assert item2.bed_levelling == "off"
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_bulk_update_multiple_fields(self, async_client: AsyncClient, queue_item_factory, db_session):
     async def test_bulk_update_multiple_fields(self, async_client: AsyncClient, queue_item_factory, db_session):
         """Verify bulk update can change multiple fields at once."""
         """Verify bulk update can change multiple fields at once."""
-        item1 = await queue_item_factory(bed_levelling=True, flow_cali=False, manual_start=False)
-        item2 = await queue_item_factory(bed_levelling=True, flow_cali=False, manual_start=False)
+        item1 = await queue_item_factory(bed_levelling="on", flow_cali="off", manual_start=False)
+        item2 = await queue_item_factory(bed_levelling="on", flow_cali="off", manual_start=False)
 
 
         response = await async_client.patch(
         response = await async_client.patch(
             "/api/v1/queue/bulk",
             "/api/v1/queue/bulk",
             json={
             json={
                 "item_ids": [item1.id, item2.id],
                 "item_ids": [item1.id, item2.id],
-                "bed_levelling": False,
-                "flow_cali": True,
+                "bed_levelling": "off",
+                "flow_cali": "on",
                 "manual_start": True,
                 "manual_start": True,
             },
             },
         )
         )
@@ -1162,23 +1162,23 @@ class TestBulkUpdateEndpoint:
         assert result["updated_count"] == 2
         assert result["updated_count"] == 2
 
 
         await db_session.refresh(item1)
         await db_session.refresh(item1)
-        assert item1.bed_levelling is False
-        assert item1.flow_cali is True
+        assert item1.bed_levelling == "off"
+        assert item1.flow_cali == "on"
         assert item1.manual_start is True
         assert item1.manual_start is True
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_bulk_update_skips_non_pending(self, async_client: AsyncClient, queue_item_factory, db_session):
     async def test_bulk_update_skips_non_pending(self, async_client: AsyncClient, queue_item_factory, db_session):
         """Verify bulk update skips non-pending items."""
         """Verify bulk update skips non-pending items."""
-        pending_item = await queue_item_factory(status="pending", bed_levelling=True)
-        printing_item = await queue_item_factory(status="printing", bed_levelling=True)
-        completed_item = await queue_item_factory(status="completed", bed_levelling=True)
+        pending_item = await queue_item_factory(status="pending", bed_levelling="on")
+        printing_item = await queue_item_factory(status="printing", bed_levelling="on")
+        completed_item = await queue_item_factory(status="completed", bed_levelling="on")
 
 
         response = await async_client.patch(
         response = await async_client.patch(
             "/api/v1/queue/bulk",
             "/api/v1/queue/bulk",
             json={
             json={
                 "item_ids": [pending_item.id, printing_item.id, completed_item.id],
                 "item_ids": [pending_item.id, printing_item.id, completed_item.id],
-                "bed_levelling": False,
+                "bed_levelling": "off",
             },
             },
         )
         )
         assert response.status_code == 200
         assert response.status_code == 200
@@ -1190,9 +1190,9 @@ class TestBulkUpdateEndpoint:
         await db_session.refresh(pending_item)
         await db_session.refresh(pending_item)
         await db_session.refresh(printing_item)
         await db_session.refresh(printing_item)
         await db_session.refresh(completed_item)
         await db_session.refresh(completed_item)
-        assert pending_item.bed_levelling is False
-        assert printing_item.bed_levelling is True
-        assert completed_item.bed_levelling is True
+        assert pending_item.bed_levelling == "off"
+        assert printing_item.bed_levelling == "on"
+        assert completed_item.bed_levelling == "on"
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
@@ -2250,7 +2250,7 @@ class TestAbortedStatusNormalisation:
             "printer_id": printer.id,
             "printer_id": printer.id,
             "archive_id": archive.id,
             "archive_id": archive.id,
             "quantity": 2,
             "quantity": 2,
-            "bed_levelling": False,
+            "bed_levelling": "off",
             "timelapse": True,
             "timelapse": True,
         }
         }
         response = await async_client.post("/api/v1/queue/", json=data)
         response = await async_client.post("/api/v1/queue/", json=data)
@@ -2261,7 +2261,7 @@ class TestAbortedStatusNormalisation:
         batch_items = [i for i in list_response.json() if i["batch_id"] == batch_id]
         batch_items = [i for i in list_response.json() if i["batch_id"] == batch_id]
         assert len(batch_items) == 2
         assert len(batch_items) == 2
         for item in batch_items:
         for item in batch_items:
-            assert item["bed_levelling"] is False
+            assert item["bed_levelling"] == "off"
             assert item["timelapse"] is True
             assert item["timelapse"] is True
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio

+ 108 - 0
backend/tests/integration/test_printers_api.py

@@ -450,6 +450,114 @@ class TestPrintersAPI:
         assert response.status_code == 200
         assert response.status_code == 200
         assert response.content == b"PLATE_4_PNG"
         assert response.content == b"PLATE_4_PNG"
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cover_pick_view_serves_active_plate_object_mask(
+        self, async_client: AsyncClient, printer_factory, db_session, tmp_path
+    ):
+        """The skip-items UI needs the slicer's exact object-ID mask, not an
+        inferred bounding box, so a plate click resolves to the firmware ID."""
+        import zipfile
+        from unittest.mock import MagicMock, patch
+
+        from backend.app.services.bambu_ftp import cache_3mf_download
+        from backend.app.services.bambu_mqtt import PrinterState
+
+        printer = await printer_factory()
+        threemf_path = tmp_path / "PickMask.3mf"
+        with zipfile.ZipFile(threemf_path, "w") as zf:
+            zf.writestr("Metadata/pick_1.png", b"PICK_ONE")
+            zf.writestr("Metadata/pick_3.png", b"PICK_THREE")
+            zf.writestr("Metadata/plate_3.gcode", "; active plate\n")
+
+        cache_3mf_download(printer.id, "PickMask.3mf", threemf_path)
+
+        state = PrinterState()
+        state.connected = True
+        state.state = "RUNNING"
+        state.subtask_name = "PickMask"
+        state.gcode_file = "PickMask.3mf"
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status = MagicMock(return_value=state)
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/cover?view=pick")
+
+        assert response.status_code == 200
+        assert response.content == b"PICK_THREE"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cover_pick_view_404s_rather_than_serving_a_render(
+        self, async_client: AsyncClient, printer_factory, db_session, tmp_path
+    ):
+        """A mask is coordinates, not decoration. Archives without pick_N.png
+        (Handy jobs, older slicers) must 404 so the UI drops to the checklist —
+        every other view's fallback to a rendered thumbnail would be decoded as
+        object IDs here, and a click would skip an arbitrary object."""
+        import zipfile
+        from unittest.mock import MagicMock, patch
+
+        from backend.app.services.bambu_ftp import cache_3mf_download
+        from backend.app.services.bambu_mqtt import PrinterState
+
+        printer = await printer_factory()
+        threemf_path = tmp_path / "NoMask.3mf"
+        with zipfile.ZipFile(threemf_path, "w") as zf:
+            zf.writestr("Metadata/top_1.png", b"TOP_RENDER")
+            zf.writestr("Metadata/plate_1.png", b"PLATE_RENDER")
+            zf.writestr("Metadata/plate_1.gcode", "; active plate\n")
+
+        cache_3mf_download(printer.id, "NoMask.3mf", threemf_path)
+
+        state = PrinterState()
+        state.connected = True
+        state.state = "RUNNING"
+        state.subtask_name = "NoMask"
+        state.gcode_file = "NoMask.3mf"
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status = MagicMock(return_value=state)
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/cover?view=pick")
+
+        assert response.status_code == 404
+        assert b"RENDER" not in response.content
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cover_pick_view_does_not_borrow_another_plates_mask(
+        self, async_client: AsyncClient, printer_factory, db_session, tmp_path
+    ):
+        """Plate 1's mask over plate 3's layout resolves clicks to whatever
+        occupied that pixel on a different plate, so the active plate's mask is
+        the only acceptable answer."""
+        import zipfile
+        from unittest.mock import MagicMock, patch
+
+        from backend.app.services.bambu_ftp import cache_3mf_download
+        from backend.app.services.bambu_mqtt import PrinterState
+
+        printer = await printer_factory()
+        threemf_path = tmp_path / "OtherPlate.3mf"
+        with zipfile.ZipFile(threemf_path, "w") as zf:
+            zf.writestr("Metadata/pick_1.png", b"PICK_ONE")
+            zf.writestr("Metadata/top_3.png", b"TOP_THREE")
+            zf.writestr("Metadata/plate_3.gcode", "; active plate\n")
+
+        cache_3mf_download(printer.id, "OtherPlate.3mf", threemf_path)
+
+        state = PrinterState()
+        state.connected = True
+        state.state = "RUNNING"
+        state.subtask_name = "OtherPlate"
+        state.gcode_file = "OtherPlate.3mf"
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status = MagicMock(return_value=state)
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/cover?view=pick")
+
+        assert response.status_code == 404
+        assert response.content != b"PICK_ONE"
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_cover_3mf_scan_fallback_for_per_plate_archive(
     async def test_cover_3mf_scan_fallback_for_per_plate_archive(

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

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

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

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

+ 406 - 32
backend/tests/unit/services/test_bambu_mqtt.py

@@ -642,6 +642,43 @@ class TestAMSDataMerging:
         assert tray["tray_sub_brands"] == "", "tray_sub_brands should be cleared"
         assert tray["tray_sub_brands"] == "", "tray_sub_brands should be cleared"
         assert tray["tag_uid"] == "0000000000000000", "tag_uid should be cleared"
         assert tray["tag_uid"] == "0000000000000000", "tag_uid should be cleared"
 
 
+    def test_bitmask_only_ht_removal_fires_on_ams_change(self, mqtt_client):
+        """#2670: an AMS-HT whose spool is removed can be signalled by
+        tray_exist_bits alone while the firmware keeps echoing the stale
+        tray_type/tag_uid/remain in the tray payload. apply_tray_exist_bits
+        clears the merged slot, but a change-hash built from the RAW payload
+        never flips (the echoed fields are unchanged), so on_ams_change would
+        not fire and the spool_assignment row would stay bound to an empty slot.
+        Hashing the MERGED state fixes it.
+        """
+        from unittest.mock import Mock
+
+        mqtt_client.on_ams_change = Mock()
+
+        # Loaded HT-A: bit 16 set (0x10000). Fires once as the initial state.
+        loaded = {
+            "ams": [{"id": 128, "tray": [{"id": 0, "tray_type": "PLA", "tag_uid": "C7EFC10300000100", "remain": 75}]}],
+            "tray_exist_bits": "10000",
+            "power_on_flag": True,
+        }
+        mqtt_client._handle_ams_data(loaded)
+        assert mqtt_client.state.raw_data["ams"][0]["tray"][0]["tray_type"] == "PLA"
+        mqtt_client.on_ams_change.reset_mock()
+
+        # Removal signalled ONLY by the bitmask: bit 16 now clear, but the tray
+        # payload STILL echoes the same PLA/tag/remain — a raw-based hash would
+        # be byte-identical to the loaded push above and never fire.
+        bitmask_only_removal = {
+            "ams": [{"id": 128, "tray": [{"id": 0, "tray_type": "PLA", "tag_uid": "C7EFC10300000100", "remain": 75}]}],
+            "tray_exist_bits": "0",
+            "power_on_flag": True,
+        }
+        mqtt_client._handle_ams_data(bitmask_only_removal)
+
+        # Merged slot cleared, and the callback fired off the merged-state hash.
+        assert mqtt_client.state.raw_data["ams"][0]["tray"][0]["tray_type"] == ""
+        mqtt_client.on_ams_change.assert_called_once()
+
     def test_partial_update_preserves_other_fields(self, mqtt_client):
     def test_partial_update_preserves_other_fields(self, mqtt_client):
         """Test that partial updates still preserve non-slot-status fields."""
         """Test that partial updates still preserve non-slot-status fields."""
         # Initial state with full data
         # Initial state with full data
@@ -1331,14 +1368,17 @@ class TestApplyTrayExistBitsHelper:
         assert units[0]["tray"][0]["state"] == 9
         assert units[0]["tray"][0]["state"] == 9
         assert isinstance(units[0]["tray"][0]["state"], int)
         assert isinstance(units[0]["tray"][0]["state"], int)
 
 
-    def test_ams_ht_unit_skipped(self):
-        """AMS-HT (id >= 128) uses a different addressing scheme."""
+    def test_ams_ht_unit_now_handled_not_skipped(self):
+        """AMS-HT (id 128-135) is no longer skipped: it uses its own bit at
+        16+(ams_id-128). With all bits 0 the HT slot clears like any other
+        (#2670 — the old skip left the HT permanently stale). See the dedicated
+        HT bit-math tests below for the encoding."""
         from backend.app.services.bambu_mqtt import apply_tray_exist_bits
         from backend.app.services.bambu_mqtt import apply_tray_exist_bits
 
 
         units = [{"id": 128, "tray": [{"id": 0, "tray_type": "PLA"}]}]
         units = [{"id": 128, "tray": [{"id": 0, "tray_type": "PLA"}]}]
         cleared = apply_tray_exist_bits(units, "0", power_on_flag=True)
         cleared = apply_tray_exist_bits(units, "0", power_on_flag=True)
-        assert cleared == 0
-        assert units[0]["tray"][0]["tray_type"] == "PLA"
+        assert cleared == 1
+        assert units[0]["tray"][0]["tray_type"] == ""
 
 
     def test_string_ids_handled(self):
     def test_string_ids_handled(self):
         """Bridge cache stores ids as strings (JSON wire format)."""
         """Bridge cache stores ids as strings (JSON wire format)."""
@@ -1421,6 +1461,82 @@ class TestApplyTrayExistBitsHelper:
         assert "exists" not in units[0]["tray"][0]
         assert "exists" not in units[0]["tray"][0]
         assert "exists" not in units[0]["tray"][1]
         assert "exists" not in units[0]["tray"][1]
 
 
+    def test_ht_unit_presence_bit_is_16_not_ams_id_times_4(self):
+        """#2670: AMS-HT (n3s, id 128) is single-tray; its presence bit is
+        16+(ams_id-128)=bit 16, NOT ams_id*4 (=512, which the old code skipped
+        outright, so the HT slot never cleared). Real H2D capture: loaded HT-A
+        reports tray_exist_bits 0x10f7f (bit 16 set); after unload it reports
+        0xf7f (bit 16 clear). Verified against OrcaSlicer DevFilaSystem.cpp."""
+        from backend.app.services.bambu_mqtt import apply_tray_exist_bits
+
+        # Loaded (bit 16 set) → slot preserved, exists=True.
+        loaded = [{"id": 128, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "000000FF"}]}]
+        assert apply_tray_exist_bits(loaded, "10f7f", power_on_flag=False, annotate_exists=True) == 0
+        assert loaded[0]["tray"][0]["tray_type"] == "PLA"
+        assert loaded[0]["tray"][0]["exists"] is True
+
+        # Empty (bit 16 clear) → slot cleared, state forced to 9, exists=False.
+        empty = [{"id": 128, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "000000FF"}]}]
+        assert apply_tray_exist_bits(empty, "f7f", power_on_flag=False, annotate_exists=True) == 1
+        assert empty[0]["tray"][0]["tray_type"] == ""
+        assert empty[0]["tray"][0]["state"] == 9
+        assert empty[0]["tray"][0]["exists"] is False
+
+    def test_ht_second_unit_is_bit_17_not_bit_20(self):
+        """#2670: multiple AMS-HT units pack into CONSECUTIVE bits — HT-A=16,
+        HT-B=17 — NOT the 4-strided bit 20 a naive ams_id*4-style extrapolation
+        would give. This pins the exact encoding OrcaSlicer uses and guards every
+        dual-HT printer. 0x20000 sets ONLY bit 17: a bit-20 implementation would
+        wrongly clear this loaded HT-B."""
+        from backend.app.services.bambu_mqtt import apply_tray_exist_bits
+
+        units = [{"id": 129, "tray": [{"id": 0, "tray_type": "PETG", "tray_color": "00FF00FF"}]}]
+        assert apply_tray_exist_bits(units, "20000", power_on_flag=False, annotate_exists=True) == 0
+        assert units[0]["tray"][0]["tray_type"] == "PETG"
+        assert units[0]["tray"][0]["exists"] is True
+
+    def test_ht_dual_unit_clears_only_the_empty_one(self):
+        """HT-A loaded + HT-B empty in one push, disambiguated by their
+        consecutive bits. 0x10000 = bit 16 only → HT-A (128) present, HT-B
+        (129, bit 17) empty."""
+        from backend.app.services.bambu_mqtt import apply_tray_exist_bits
+
+        units = [
+            {"id": 128, "tray": [{"id": 0, "tray_type": "PLA"}]},
+            {"id": 129, "tray": [{"id": 0, "tray_type": "PETG"}]},
+        ]
+        assert apply_tray_exist_bits(units, "10000", power_on_flag=False, annotate_exists=True) == 1
+        assert units[0]["tray"][0]["tray_type"] == "PLA"  # HT-A present
+        assert units[0]["tray"][0]["exists"] is True
+        assert units[1]["tray"][0]["tray_type"] == ""  # HT-B cleared
+        assert units[1]["tray"][0]["exists"] is False
+
+    def test_ht_and_regular_ams_use_their_own_formulas_together(self):
+        """Regular AMS keeps ams_id*4+tray_id while HT uses 16+(ams_id-128) in a
+        single call. bits 0x1 = regular AMS0 slot0 present (bit 0); HT-A empty
+        (bit 16 clear)."""
+        from backend.app.services.bambu_mqtt import apply_tray_exist_bits
+
+        units = [
+            {"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}]},
+            {"id": 128, "tray": [{"id": 0, "tray_type": "ASA"}]},
+        ]
+        assert apply_tray_exist_bits(units, "1", power_on_flag=True, annotate_exists=True) == 1
+        assert units[0]["tray"][0]["tray_type"] == "PLA"  # regular AMS0 slot0 (bit 0) present
+        assert units[0]["tray"][0]["exists"] is True
+        assert units[1]["tray"][0]["tray_type"] == ""  # HT-A (bit 16) empty
+        assert units[1]["tray"][0]["exists"] is False
+
+    def test_unknown_ams_id_range_is_left_untouched(self):
+        """An id outside regular (0-15) and HT (128-135) has no known bit layout
+        — the helper must NOT guess a bit and must NOT clear the slot."""
+        from backend.app.services.bambu_mqtt import apply_tray_exist_bits
+
+        units = [{"id": 200, "tray": [{"id": 0, "tray_type": "PLA"}]}]
+        assert apply_tray_exist_bits(units, "0", power_on_flag=True, annotate_exists=True) == 0
+        assert units[0]["tray"][0]["tray_type"] == "PLA"
+        assert "exists" not in units[0]["tray"][0]
+
 
 
 class TestNozzleRackData:
 class TestNozzleRackData:
     """Tests for nozzle rack data parsing from H2 series device.nozzle.info."""
     """Tests for nozzle rack data parsing from H2 series device.nozzle.info."""
@@ -4060,15 +4176,18 @@ class TestStartPrintAmsMapping:
         mqtt_client.start_print(
         mqtt_client.start_print(
             "test.3mf",
             "test.3mf",
             timelapse=True,
             timelapse=True,
-            bed_levelling=False,
-            flow_cali=True,
+            bed_levelling="off",
+            flow_cali="on",
             vibration_cali=False,
             vibration_cali=False,
             layer_inspect=True,
             layer_inspect=True,
         )
         )
 
 
         cmd = self._get_published_command(mqtt_client)
         cmd = self._get_published_command(mqtt_client)
         assert cmd["timelapse"] is True
         assert cmd["timelapse"] is True
+        # bed_leveling stays a bool (true only for "on"); the tri-state rides on
+        # the auto_bed_leveling int.
         assert cmd["bed_leveling"] is False
         assert cmd["bed_leveling"] is False
+        assert cmd["auto_bed_leveling"] == 0
         assert cmd["flow_cali"] is True
         assert cmd["flow_cali"] is True
         assert cmd["vibration_cali"] is False
         assert cmd["vibration_cali"] is False
         assert cmd["layer_inspect"] is True
         assert cmd["layer_inspect"] is True
@@ -4078,16 +4197,13 @@ class TestStartPrintAmsMapping:
     def test_p2s_uses_boolean_format(self, mqtt_client):
     def test_p2s_uses_boolean_format(self, mqtt_client):
         """P2S sends calibration fields as JSON booleans (single-nozzle, like X1C/A1/P1)."""
         """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="off")
 
 
         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=0 (firmware actually skips the
-        # pre-print calibration stage). #1721 test on H2D 01.x showed `2`
-        # didn't suppress stage 8 ("Calibrating dynamic flow") despite the
-        # earlier "skip and reuse stored PA" reading; `0` does — verified
-        # live against the stg queue.
+        # flow_cali "off" → extrude_cali_flag=0 (firmware skips the pre-print
+        # calibration stage entirely). "auto" would send 2 instead.
         assert cmd["extrude_cali_flag"] == 0
         assert cmd["extrude_cali_flag"] == 0
 
 
     def test_h2s_single_external_spool_uses_main_id(self, mqtt_client):
     def test_h2s_single_external_spool_uses_main_id(self, mqtt_client):
@@ -4132,8 +4248,8 @@ class TestStartPrintAmsMapping:
         mqtt_client.start_print(
         mqtt_client.start_print(
             "test.3mf",
             "test.3mf",
             timelapse=True,
             timelapse=True,
-            bed_levelling=False,
-            flow_cali=True,
+            bed_levelling="off",
+            flow_cali="on",
             vibration_cali=False,
             vibration_cali=False,
             layer_inspect=True,
             layer_inspect=True,
         )
         )
@@ -4148,13 +4264,48 @@ class TestStartPrintAmsMapping:
         # flow-dynamics calibration instead of reusing the stored PA value.
         # flow-dynamics calibration instead of reusing the stored PA value.
         assert cmd["extrude_cali_flag"] == 1
         assert cmd["extrude_cali_flag"] == 1
 
 
-    def test_nozzle_offset_cali_default_is_skip(self, mqtt_client):
-        """Default `nozzle_offset_cali=False` → wire value `0` (skip).
+    def test_bed_leveling_auto_sends_int_two(self, mqtt_client):
+        """`bed_levelling="auto"` → bool false + auto_bed_leveling=2.
+
+        Matches BambuStudio's ops_auto wire shape: the bool is true only for the
+        explicit "on" state; "auto" carries its intent in the int (2 = run only
+        if the bed wasn't levelled recently).
+        """
+        mqtt_client.model = "X1C"
+        mqtt_client.start_print("test.3mf", bed_levelling="auto")
+
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["bed_leveling"] is False
+        assert cmd["auto_bed_leveling"] == 2
+
+    def test_bed_leveling_on_sends_int_one(self, mqtt_client):
+        """`bed_levelling="on"` → bool true + auto_bed_leveling=1 (force)."""
+        mqtt_client.model = "X1C"
+        mqtt_client.start_print("test.3mf", bed_levelling="on")
+
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["bed_leveling"] is True
+        assert cmd["auto_bed_leveling"] == 1
+
+    def test_flow_cali_auto_sends_int_two(self, mqtt_client):
+        """`flow_cali="auto"` → bool false + extrude_cali_flag=2.
 
 
-        #1721 H2D 01.x test: `2` ("skip") didn't actually suppress stage 39
-        ("Nozzle offset calibration") — the stage stayed in the `stg` queue
-        and ran at print start. `0` does suppress it (verified live). Matches
-        what a BambuStudio Send-dialog echo on the same firmware shows.
+        #1721 saw stage 8 stay queued on 2 — that is the auto contract (queued,
+        skipped at runtime if the filament was calibrated recently), which is
+        exactly what "auto" should do.
+        """
+        mqtt_client.model = "X1C"
+        mqtt_client.start_print("test.3mf", flow_cali="auto")
+
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["flow_cali"] is False
+        assert cmd["extrude_cali_flag"] == 2
+
+    def test_nozzle_offset_cali_default_auto_gated_on_single_nozzle(self, mqtt_client):
+        """Default (auto) on a single-nozzle printer → wire value `0`.
+
+        The default is now "auto", but single-nozzle machines have no second
+        head to calibrate, so the MQTT layer gates any state to `0` there.
         """
         """
         mqtt_client.model = "P1S"
         mqtt_client.model = "P1S"
         mqtt_client.start_print("test.3mf")
         mqtt_client.start_print("test.3mf")
@@ -4163,44 +4314,50 @@ class TestStartPrintAmsMapping:
         assert cmd["nozzle_offset_cali"] == 0
         assert cmd["nozzle_offset_cali"] == 0
 
 
     def test_nozzle_offset_cali_ignored_on_single_nozzle(self, mqtt_client):
     def test_nozzle_offset_cali_ignored_on_single_nozzle(self, mqtt_client):
-        """Single-nozzle printer: `nozzle_offset_cali=True` is silently dropped.
+        """Single-nozzle printer: `nozzle_offset_cali="on"` is silently dropped.
 
 
         H2S is in the H2 firmware family but single-nozzle. The toggle has
         H2S is in the H2 firmware family but single-nozzle. The toggle has
         no physical meaning on single-nozzle machines and the UI gates it
         no physical meaning on single-nozzle machines and the UI gates it
         behind `nozzle_count==2`. Even if a stale queue item from when the
         behind `nozzle_count==2`. Even if a stale queue item from when the
         printer was misidentified as dual carries the flag, the MQTT layer
         printer was misidentified as dual carries the flag, the MQTT layer
         must downgrade it so firmware never tries to calibrate a head it
         must downgrade it so firmware never tries to calibrate a head it
-        doesn't have (#1682). `0` is the actually-honoured skip value
-        post-#1721; old `2` left the stage in the queue.
+        doesn't have (#1682).
         """
         """
         mqtt_client.model = "P1S"
         mqtt_client.model = "P1S"
-        mqtt_client.start_print("test.3mf", nozzle_offset_cali=True)
+        mqtt_client.start_print("test.3mf", nozzle_offset_cali="on")
 
 
         cmd = self._get_published_command(mqtt_client)
         cmd = self._get_published_command(mqtt_client)
         assert cmd["nozzle_offset_cali"] == 0
         assert cmd["nozzle_offset_cali"] == 0
 
 
     def test_nozzle_offset_cali_honored_on_dual_nozzle(self, mqtt_client):
     def test_nozzle_offset_cali_honored_on_dual_nozzle(self, mqtt_client):
-        """Dual-nozzle printer (H2D): `nozzle_offset_cali=True` → wire value `1`.
+        """Dual-nozzle printer (H2D): `nozzle_offset_cali="on"` → wire value `1`.
 
 
         H2D is in `DUAL_NOZZLE_MODELS`. The toggle controls whether the
         H2D is in `DUAL_NOZZLE_MODELS`. The toggle controls whether the
         printer runs the nozzle-offset calibration pass before the print
         printer runs the nozzle-offset calibration pass before the print
-        starts. `1`=run (#1682).
+        starts. "on"=1 (force), "auto"=2, "off"=0 (#1682).
         """
         """
         mqtt_client.model = "H2D"
         mqtt_client.model = "H2D"
-        mqtt_client.start_print("test.3mf", nozzle_offset_cali=True)
+        mqtt_client.start_print("test.3mf", nozzle_offset_cali="on")
 
 
         cmd = self._get_published_command(mqtt_client)
         cmd = self._get_published_command(mqtt_client)
         assert cmd["nozzle_offset_cali"] == 1
         assert cmd["nozzle_offset_cali"] == 1
 
 
-    def test_nozzle_offset_cali_false_on_dual_nozzle(self, mqtt_client):
-        """Dual-nozzle printer (H2D Pro): `nozzle_offset_cali=False` → `0` (skip).
+    def test_nozzle_offset_cali_auto_on_dual_nozzle(self, mqtt_client):
+        """Dual-nozzle printer (H2D): `nozzle_offset_cali="auto"` → wire value `2`."""
+        mqtt_client.model = "H2D"
+        mqtt_client.start_print("test.3mf", nozzle_offset_cali="auto")
+
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["nozzle_offset_cali"] == 2
+
+    def test_nozzle_offset_cali_off_on_dual_nozzle(self, mqtt_client):
+        """Dual-nozzle printer (H2D Pro): `nozzle_offset_cali="off"` → `0` (skip).
 
 
         Critical for users like #1682 who run diamond nozzles and need to
         Critical for users like #1682 who run diamond nozzles and need to
-        keep the calibration off. The wire value flipped from `2` to `0` in
-        #1721 after the H2D test showed `2` didn't actually suppress.
+        keep the calibration off.
         """
         """
         mqtt_client.model = "H2D Pro"
         mqtt_client.model = "H2D Pro"
-        mqtt_client.start_print("test.3mf", nozzle_offset_cali=False)
+        mqtt_client.start_print("test.3mf", nozzle_offset_cali="off")
 
 
         cmd = self._get_published_command(mqtt_client)
         cmd = self._get_published_command(mqtt_client)
         assert cmd["nozzle_offset_cali"] == 0
         assert cmd["nozzle_offset_cali"] == 0
@@ -6279,3 +6436,220 @@ class TestLastLayerFinishPhotoTrigger:
 
 
         assert len(events) == 1
         assert len(events) == 1
         assert len(completion_events) == 1
         assert len(completion_events) == 1
+
+
+class TestPresumedPowerOffRecovery:
+    """#2629: a smart-plug turn-off marks the printer offline optimistically.
+
+    When the plug does not actually feed the printer, the printer keeps
+    publishing — and the forced 'unknown' state must be undone, or it sticks
+    until the next full pushall and the queue scheduler stalls forever.
+    """
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST123",
+            access_code="12345678",
+        )
+        client.state.connected = True
+        client.state.state = "FINISH"
+        return client
+
+    @staticmethod
+    def _report(client, payload):
+        """Feed a report-topic message through the real _on_message path."""
+
+        class _Msg:
+            def __init__(self, topic, data):
+                self.topic = topic
+                self.payload = json.dumps(data).encode()
+
+        client._on_message(None, None, _Msg(client.topic_subscribe, payload))
+
+    def test_mark_power_off_blanks_state_and_remembers_it(self, mqtt_client):
+        assert mqtt_client.mark_power_off() is True
+
+        assert mqtt_client.state.connected is False
+        assert mqtt_client.state.state == "unknown"
+        assert mqtt_client._state_before_power_off == "FINISH"
+
+    def test_mark_power_off_noop_when_already_disconnected(self, mqtt_client):
+        mqtt_client.state.connected = False
+
+        assert mqtt_client.mark_power_off() is False
+        assert mqtt_client._state_before_power_off is None
+
+    def test_second_mark_does_not_overwrite_saved_state(self, mqtt_client):
+        mqtt_client.mark_power_off()
+        # Something flips connected back (a partial message) before the second mark
+        mqtt_client.state.connected = True
+        mqtt_client.mark_power_off()
+
+        assert mqtt_client._state_before_power_off == "FINISH"
+
+    def test_partial_report_restores_state(self, mqtt_client):
+        """The steady-state push_status carries no gcode_state — the pre-off
+        state must come back anyway, otherwise 'unknown' is permanent."""
+        mqtt_client.mark_power_off()
+
+        self._report(mqtt_client, {"print": {"wifi_signal": "-30dBm"}})
+
+        assert mqtt_client.state.connected is True
+        assert mqtt_client.state.state == "FINISH"
+        assert mqtt_client._state_before_power_off is None
+
+    def test_restore_broadcasts_state_change(self, mqtt_client):
+        broadcasts = []
+        mqtt_client.on_state_change = lambda state: broadcasts.append(state.state)
+        mqtt_client.mark_power_off()
+
+        self._report(mqtt_client, {"print": {"wifi_signal": "-30dBm"}})
+
+        assert "FINISH" in broadcasts
+
+    def test_fresh_gcode_state_wins_over_restored_state(self, mqtt_client):
+        """A report that does carry gcode_state is authoritative."""
+        mqtt_client.mark_power_off()
+
+        self._report(mqtt_client, {"print": {"gcode_state": "IDLE"}})
+
+        assert mqtt_client.state.state == "IDLE"
+
+    def test_restore_happens_only_once(self, mqtt_client):
+        """After recovery a later genuine blank must not be undone by a stale
+        saved state."""
+        mqtt_client.mark_power_off()
+        self._report(mqtt_client, {"print": {"wifi_signal": "-30dBm"}})
+
+        # Printer really loses power now: state blanked, nothing to restore from
+        mqtt_client.state.state = "unknown"
+        assert mqtt_client._restore_state_after_false_power_off() is False
+        assert mqtt_client.state.state == "unknown"
+
+    def test_request_topic_traffic_does_not_restore(self, mqtt_client):
+        """Only the printer's own report topic proves it is alive; the request
+        topic also carries slicer/Bambuddy commands."""
+        mqtt_client.mark_power_off()
+
+        class _Msg:
+            topic = mqtt_client.topic_publish
+            payload = json.dumps({"print": {"command": "project_file"}}).encode()
+
+        mqtt_client._on_message(None, None, _Msg())
+
+        assert mqtt_client.state.state == "unknown"
+        assert mqtt_client._state_before_power_off == "FINISH"
+
+    def test_reconnect_discards_saved_state(self, mqtt_client):
+        """A real power cut drops the MQTT session; on reconnect the saved state
+        is stale and must not be broadcast ahead of the printer's first report."""
+        from unittest.mock import MagicMock
+
+        mqtt_client.mark_power_off()
+
+        paho = MagicMock()
+        paho.subscribe.return_value = (0, 1)  # (MQTT_ERR_SUCCESS, mid)
+        mqtt_client._on_connect(paho, None, {}, 0)
+
+        assert mqtt_client._state_before_power_off is None
+
+        self._report(mqtt_client, {"print": {"wifi_signal": "-30dBm"}})
+
+        assert mqtt_client.state.state == "unknown"
+
+    def test_already_unknown_state_is_not_saved(self, mqtt_client):
+        """A printer that never reported has nothing to restore — saving
+        'unknown' would make the recovery broadcast a no-op state change."""
+        mqtt_client.state.state = "unknown"
+
+        assert mqtt_client.mark_power_off() is True
+        assert mqtt_client._state_before_power_off is None
+
+    def test_message_interleaved_with_mark_does_not_strand_unknown(self, mqtt_client):
+        """mark_power_off runs on the event loop, _on_message on the paho
+        thread. A message landing mid-mark must not consume the saved state and
+        leave the printer stuck on 'unknown' — the next message must recover."""
+        # Simulate the worst interleaving: a report is processed after the state
+        # was blanked but before the previous state was recorded.
+        mqtt_client.state.connected = False
+        mqtt_client.state.state = "unknown"
+        self._report(mqtt_client, {"print": {"wifi_signal": "-30dBm"}})
+        # ...now the rest of the mark completes.
+        mqtt_client._state_before_power_off = "FINISH"
+
+        self._report(mqtt_client, {"print": {"wifi_signal": "-30dBm"}})
+
+        assert mqtt_client.state.state == "FINISH"
+
+
+class TestKProfileResponseDoesNotClobberNozzle:
+    """#2663: the K-profile fetch (get_kprofiles) probes every nozzle size
+    0.2/0.4/0.6/0.8 with an ``extrusion_cali_get`` request. Each response
+    echoes the *requested* nozzle_diameter at the top level, which is NOT the
+    installed hardware. _process_message must not feed those responses to
+    _update_state, or the real nozzle size gets overwritten (typically to 0.8,
+    the last size probed) and the #1899 dispatch guard then blocks prints.
+    """
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="A1TEST",
+            access_code="12345678",
+        )
+
+    def test_kprofile_response_does_not_overwrite_nozzle_diameter(self, mqtt_client):
+        # A genuine pushall reports the real 0.4mm nozzle.
+        mqtt_client._process_message({"print": {"nozzle_diameter": "0.4"}})
+        assert mqtt_client.state.nozzles[0].nozzle_diameter == "0.4"
+
+        # The K-profile probe's 0.8mm response arrives (as it did on the
+        # reporter's A1s). It must NOT clobber the hardware nozzle.
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "command": "extrusion_cali_get",
+                    "nozzle_diameter": "0.8",
+                    "filaments": [],
+                    "sequence_id": "1501",
+                }
+            }
+        )
+        assert mqtt_client.state.nozzles[0].nozzle_diameter == "0.4"
+
+    def test_kprofile_response_is_still_parsed(self, mqtt_client):
+        # Skipping _update_state must not skip the K-profile handler: the
+        # response's profiles still populate state.kprofiles.
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "command": "extrusion_cali_get",
+                    "nozzle_diameter": "0.4",
+                    "filaments": [
+                        {
+                            "cali_idx": 0,
+                            "nozzle_diameter": "0.4",
+                            "filament_id": "GFA00",
+                            "name": "PLA",
+                            "k_value": "0.020000",
+                        }
+                    ],
+                }
+            }
+        )
+        assert len(mqtt_client.state.kprofiles) == 1
+        assert mqtt_client.state.kprofiles[0].filament_id == "GFA00"
+
+    def test_genuine_pushall_still_updates_nozzle(self, mqtt_client):
+        # The one legitimate source of the hardware nozzle still works, and a
+        # later pushall corrects a value an old build left wrong.
+        mqtt_client.state.nozzles[0].nozzle_diameter = "0.8"
+        mqtt_client._process_message({"print": {"nozzle_diameter": "0.4"}})
+        assert mqtt_client.state.nozzles[0].nozzle_diameter == "0.4"

+ 26 - 0
backend/tests/unit/services/test_makerworld.py

@@ -199,6 +199,32 @@ class TestGetDesign:
         assert "Profiles" in message
         assert "Profiles" in message
         assert marked == [True], "a rejected token must be recorded as dead"
         assert marked == [True], "a rejected token must be recorded as dead"
 
 
+    @pytest.mark.asyncio
+    async def test_transient_401_with_token_does_not_invalidate(self):
+        """A 401 WITHOUT Bambu's expiry signature (endpoint/edge noise) must fail
+        the request but NOT durably sign the user out — otherwise one stray 401
+        from any single MakerWorld call kills the whole cloud integration."""
+        marked: list[bool] = []
+
+        async def _on_auth_failure() -> None:
+            marked.append(True)
+
+        svc = MakerWorldService(
+            client=MagicMock(spec=httpx.AsyncClient),
+            auth_token="tok-abc",
+            on_auth_failure=_on_auth_failure,
+        )
+        svc._client.get = AsyncMock()
+        resp = MagicMock()
+        resp.status_code = 401
+        resp.json.return_value = {"code": 1, "error": "forbidden"}
+        svc._client.get.return_value = resp
+
+        with pytest.raises(MakerWorldAuthError):
+            await svc.get_design(1)
+
+        assert marked == [], "a benign 401 must not record the credential as dead"
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_maps_403_to_forbidden_with_upstream_reason(self, service):
     async def test_maps_403_to_forbidden_with_upstream_reason(self, service):
         """403 is distinct from 401: auth was valid, MakerWorld refuses the
         """403 is distinct from 401: auth was valid, MakerWorld refuses the

+ 49 - 3
backend/tests/unit/services/test_printer_manager.py

@@ -52,6 +52,19 @@ class TestPrinterManager:
         client.state.temperatures = {"nozzle": 25, "bed": 25}
         client.state.temperatures = {"nozzle": 25, "bed": 25}
         client.state.raw_data = {}
         client.state.raw_data = {}
         client.logging_enabled = False
         client.logging_enabled = False
+
+        # mark_power_off is real logic on BambuMQTTClient (#2629) — mirror it so
+        # the manager tests still exercise the state transition they assert on.
+        # The real implementation (and its recovery path) is covered in
+        # test_bambu_mqtt.py::TestPresumedPowerOffRecovery.
+        def _mark_power_off():
+            if not client.state.connected:
+                return False
+            client.state.connected = False
+            client.state.state = "unknown"
+            return True
+
+        client.mark_power_off.side_effect = _mark_power_off
         return client
         return client
 
 
     # ========================================================================
     # ========================================================================
@@ -374,12 +387,12 @@ class TestPrinterManager:
             1,
             1,
             ams_mapping=None,
             ams_mapping=None,
             timelapse=False,
             timelapse=False,
-            bed_levelling=True,
-            flow_cali=False,
+            bed_levelling="auto",
+            flow_cali="auto",
             vibration_cali=True,
             vibration_cali=True,
             layer_inspect=False,
             layer_inspect=False,
             use_ams=True,
             use_ams=True,
-            nozzle_offset_cali=False,
+            nozzle_offset_cali="auto",
             nozzle_mapping=None,
             nozzle_mapping=None,
         )
         )
         assert result is True
         assert result is True
@@ -973,6 +986,39 @@ class TestPrinterStateToDict:
 
 
         assert result["ams"][0]["tray"][0]["tag_uid"] is None
         assert result["ams"][0]["tray"][0]["tag_uid"] is None
 
 
+    def test_exists_bit_is_serialized_for_websocket(self, mock_state):
+        """#2670: the WS status payload must carry the firmware presence bit
+        `exists` (set by apply_tray_exist_bits) — the REST serializer already
+        does. Without it the frontend shallow-merge drops `exists` after the
+        first WS frame and getEmptySlotKind falls back to the firmware-variant
+        state 9/10 heuristic, which is wrong for AMS-HT in both directions.
+        """
+        mock_state.raw_data = {
+            "ams": [
+                {
+                    "id": 128,
+                    "tray": [
+                        # Empty HT: apply_tray_exist_bits cleared it and set exists=False.
+                        {"id": 0, "state": 9, "tray_type": "", "exists": False},
+                    ],
+                },
+                {
+                    "id": 0,
+                    "tray": [
+                        # Present non-RFID spool: exists=True, no tray_type ("?").
+                        {"id": 0, "state": 10, "tray_type": "", "exists": True},
+                    ],
+                },
+            ]
+        }
+
+        result = printer_state_to_dict(mock_state)
+
+        ht_tray = result["ams"][0]["tray"][0]
+        reg_tray = result["ams"][1]["tray"][0]
+        assert ht_tray["exists"] is False
+        assert reg_tray["exists"] is True
+
     def test_vt_tray_parsing(self, mock_state):
     def test_vt_tray_parsing(self, mock_state):
         """Verify virtual tray is parsed correctly as a list."""
         """Verify virtual tray is parsed correctly as a list."""
         mock_state.raw_data = {
         mock_state.raw_data = {

+ 76 - 4
backend/tests/unit/services/test_slicer_3mf_convert.py

@@ -253,10 +253,10 @@ def p1_project(zip_bytes: bytes) -> bytes:
 
 
 
 
 class TestSubstituteUnusedPlateFilaments:
 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."""
+    """Unused-slot entries are overwritten with the plate's lowest *used*
+    slot so BambuStudio's validators don't trip on loaded filaments the
+    plate's G-code never actually touches — neither the filament-temp
+    spread nor the preset-vs-printer compatibility check."""
 
 
     @staticmethod
     @staticmethod
     def _model_settings_xml(per_plate_extruders: list[tuple[int, list[int]]]) -> bytes:
     def _model_settings_xml(per_plate_extruders: list[tuple[int, list[int]]]) -> bytes:
@@ -371,3 +371,75 @@ class TestSubstituteUnusedPlateFilaments:
         items = ["pla.json", "abs_never_used.json"]
         items = ["pla.json", "abs_never_used.json"]
         result = substitute_unused_plate_filaments(zip_bytes, plate_id=1, items=items)
         result = substitute_unused_plate_filaments(zip_bytes, plate_id=1, items=items)
         assert result == ["pla.json", "pla.json"]
         assert result == ["pla.json", "pla.json"]
+
+    # ---- #2628: the anchor is the lowest USED slot, not slot 1 ----------
+
+    def test_substitutes_from_first_used_slot_when_slot_1_is_unused(self):
+        """michaelklos's report: plate 2 of a multi-plate project uses only
+        slot 2, while slot 1 carries an ``@Bambu Lab H2D`` preset baked into
+        the source 3MF. Anchoring on slot 1 made the substitution a no-op for
+        the one slot that needed it, and the CLI rejected the A1 slice with
+        "filament preset (slot 1) is not compatible with printer
+        Bambu Lab A1 0.4 nozzle"."""
+        zip_bytes = _make_3mf({"Metadata/model_settings.config": self._model_settings_xml([(1, [1]), (2, [2])])})
+        items = ["tpu_at_h2d.json", "pla_at_a1.json"]
+
+        result = substitute_unused_plate_filaments(zip_bytes, plate_id=2, items=items)
+
+        assert result == ["pla_at_a1.json", "pla_at_a1.json"]
+
+    def test_unused_slot_1_does_not_poison_the_other_unused_slots(self):
+        """With more than one unused slot, the old anchor propagated slot 1's
+        own (foreign-printer) preset into every other unused slot — the exact
+        poisoning #1851 removed from the picker."""
+        zip_bytes = _make_3mf({"Metadata/model_settings.config": self._model_settings_xml([(1, [1]), (2, [3])])})
+        items = ["tpu_at_h2d.json", "abs.json", "pla_at_a1.json"]
+
+        result = substitute_unused_plate_filaments(zip_bytes, plate_id=2, items=items)
+
+        assert result == ["pla_at_a1.json", "pla_at_a1.json", "pla_at_a1.json"]
+
+    def test_anchor_is_the_lowest_used_slot_not_merely_a_used_one(self):
+        """Deterministic pick so the same project always slices identically."""
+        zip_bytes = _make_3mf({"Metadata/model_settings.config": self._model_settings_xml([(1, [1]), (2, [2, 4])])})
+        items = ["slot1.json", "slot2.json", "slot3.json", "slot4.json"]
+
+        result = substitute_unused_plate_filaments(zip_bytes, plate_id=2, items=items)
+
+        assert result == ["slot2.json", "slot2.json", "slot2.json", "slot4.json"]
+
+    def test_no_op_when_every_used_slot_is_outside_the_submitted_list(self):
+        """A plate referencing only slots beyond the picked list leaves nothing
+        trustworthy to copy from — keep the user's picks rather than invent a
+        substitution from a slot the plate doesn't use."""
+        zip_bytes = _make_3mf({"Metadata/model_settings.config": self._model_settings_xml([(1, [1]), (2, [5])])})
+        items = ["a.json", "b.json"]
+
+        result = substitute_unused_plate_filaments(zip_bytes, plate_id=2, items=items)
+
+        assert result == ["a.json", "b.json"]
+
+    def test_support_slot_can_be_the_anchor(self):
+        """The support-filament union (#1881) feeds the same used-slot set, so
+        a plate whose only geometry slot is 2 with PVA supports in 3 anchors on
+        2 — never on the unused slot 1."""
+        model_settings = self._model_settings_xml([(1, [1]), (2, [2])])
+        project_settings = json.dumps(
+            {
+                "enable_support": "1",
+                "support_filament": "3",
+                "support_interface_filament": "3",
+                "filament_type": ["PLA", "PLA", "PVA"],
+            }
+        ).encode()
+        zip_bytes = _make_3mf(
+            {
+                "Metadata/model_settings.config": model_settings,
+                "Metadata/project_settings.config": project_settings,
+            }
+        )
+        items = ["tpu_at_h2d.json", "pla.json", "pva.json"]
+
+        result = substitute_unused_plate_filaments(zip_bytes, plate_id=2, items=items)
+
+        assert result == ["pla.json", "pla.json", "pva.json"]

+ 101 - 1
backend/tests/unit/services/test_slicer_api.py

@@ -226,9 +226,11 @@ class TestSliceWithProfiles:
 
 
         def handler(request: httpx.Request) -> httpx.Response:
         def handler(request: httpx.Request) -> httpx.Response:
             captured["body"] = request.content
             captured["body"] = request.content
+            # export_3mf=True → the response body must be a valid 3MF zip, or the
+            # #2671 output validation rejects it. This test is about the request.
             return httpx.Response(
             return httpx.Response(
                 status_code=200,
                 status_code=200,
-                content=b"3MF-BYTES",
+                content=_valid_3mf_zip(),
                 headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
                 headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
             )
             )
 
 
@@ -362,6 +364,104 @@ class TestSliceWithProfiles:
         assert result.filament_used_mm == 0.0
         assert result.filament_used_mm == 0.0
 
 
 
 
+def _valid_3mf_zip() -> bytes:
+    """Minimal-but-valid ZIP so is_zipfile() accepts it as a 3MF container."""
+    import io
+    import zipfile
+
+    buf = io.BytesIO()
+    with zipfile.ZipFile(buf, "w") as zf:
+        zf.writestr("[Content_Types].xml", "<Types/>")
+        zf.writestr("Metadata/plate_1.gcode", "; G-CODE\nG28\n")
+    return buf.getvalue()
+
+
+class TestSliceOutputValidation:
+    """#2671: a 200 with a non-3MF body must not be persisted as a slice."""
+
+    _SLICE_KW = {
+        "model_bytes": b"solid Cube\n",
+        "model_filename": "Cube.stl",
+        "printer_profile_json": "{}",
+        "process_profile_json": "{}",
+        "filament_profile_jsons": ["{}"],
+    }
+
+    @pytest.mark.asyncio
+    async def test_413_gives_actionable_reverse_proxy_message(self):
+        # A 413 is a proxy/CDN body-size cap, not the slicer — the message must
+        # point at the right layer so the user stops editing the wrong one.
+        def handler(request: httpx.Request) -> httpx.Response:
+            return httpx.Response(status_code=413, content=b"<html>413 Request Entity Too Large</html>")
+
+        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
+        with pytest.raises(SlicerInputError) as exc_info:
+            await service.slice_with_profiles(export_3mf=True, **self._SLICE_KW)
+        msg = str(exc_info.value)
+        assert "413" in msg
+        assert "client_max_body_size" in msg
+        assert "proxy" in msg.lower()
+
+    @pytest.mark.asyncio
+    async def test_export_3mf_rejects_non_zip_200_body(self):
+        # The exact failure from #2671: sidecar/proxy returns 200 with a tiny
+        # garbage body; Bambuddy must NOT accept it as a sliced 3MF.
+        body = b'{"detail":"Not Found"}xxxxxx'  # 28 bytes, not a zip
+        assert len(body) == 28
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            return httpx.Response(status_code=200, content=body)
+
+        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
+        with pytest.raises(SlicerApiServerError) as exc_info:
+            await service.slice_with_profiles(export_3mf=True, **self._SLICE_KW)
+        msg = str(exc_info.value)
+        assert "not a valid" in msg.lower()
+        assert "28 bytes" in msg
+
+    @pytest.mark.asyncio
+    async def test_export_3mf_accepts_valid_zip_body(self):
+        zip_bytes = _valid_3mf_zip()
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            return httpx.Response(
+                status_code=200,
+                content=zip_bytes,
+                headers={"x-print-time-seconds": "656"},
+            )
+
+        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
+        result = await service.slice_with_profiles(export_3mf=True, **self._SLICE_KW)
+        assert result.content == zip_bytes
+        assert result.print_time_seconds == 656
+
+    @pytest.mark.asyncio
+    async def test_raw_gcode_body_not_zip_validated(self):
+        # export_3mf defaults False (preview / raw-gcode callers): the body is
+        # legitimately not a zip, so the validation must NOT fire.
+        def handler(request: httpx.Request) -> httpx.Response:
+            return httpx.Response(status_code=200, content=b"; G-CODE\nG28\n")
+
+        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
+        result = await service.slice_with_profiles(**self._SLICE_KW)
+        assert result.content == b"; G-CODE\nG28\n"
+
+    @pytest.mark.asyncio
+    async def test_without_profiles_also_rejects_non_zip_200_body(self):
+        # The validation lives in the shared response handler, so the
+        # embedded-settings path (slice_without_profiles) is covered too.
+        def handler(request: httpx.Request) -> httpx.Response:
+            return httpx.Response(status_code=200, content=b"nope")
+
+        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
+        with pytest.raises(SlicerApiServerError):
+            await service.slice_without_profiles(
+                model_bytes=b"solid Cube\n",
+                model_filename="Cube.stl",
+                export_3mf=True,
+            )
+
+
 class TestHealth:
 class TestHealth:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_health_returns_body(self):
     async def test_health_returns_body(self):

+ 155 - 0
backend/tests/unit/services/test_smart_plug_manager.py

@@ -1051,3 +1051,158 @@ class TestActivePrintGuard:
             mock_task.cancel.assert_called_once()  # cancelled despite auto_on=False
             mock_task.cancel.assert_called_once()  # cancelled despite auto_on=False
             assert mock_plug.id not in manager._pending_off
             assert mock_plug.id not in manager._pending_off
             mock_tasmota.turn_on.assert_not_called()  # but not powered on
             mock_tasmota.turn_on.assert_not_called()  # but not powered on
+
+
+class TestAccessoryPlugDoesNotMarkPrinterOffline:
+    """#2629 — a plug linked to a printer is not necessarily its power supply.
+
+    Filter fans, chamber lights and enclosure heaters are linked so they follow
+    the print cycle. Marking the printer offline when one of those switches off
+    blanks the printer state and stalls the queue until a manual Force Refresh.
+    """
+
+    @pytest.fixture
+    def manager(self):
+        return SmartPlugManager()
+
+    @pytest.fixture
+    def accessory_plug(self):
+        plug = MagicMock()
+        plug.id = 1
+        plug.name = "BentoBox Filter"
+        plug.ip_address = "192.168.1.100"
+        plug.username = None
+        plug.password = None
+        plug.enabled = True
+        plug.auto_off = True
+        plug.off_delay_mode = "time"
+        plug.off_delay_minutes = 1
+        plug.off_temp_threshold = 70
+        plug.printer_id = 1
+        plug.plug_type = "tasmota"
+        plug.ha_entity_id = None
+        plug.controls_printer_power = False
+        return plug
+
+    @pytest.mark.asyncio
+    async def test_delayed_off_skips_offline_mark_for_accessory(self, manager):
+        mock_service = AsyncMock()
+        mock_service.turn_off = AsyncMock(return_value=True)
+        with (
+            patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
+            patch.object(manager, "get_service_for_plug", new_callable=AsyncMock, return_value=mock_service),
+            patch.object(manager, "_mark_auto_off_executed", new_callable=AsyncMock),
+        ):
+            mock_pm.is_print_active.return_value = False
+
+            await manager._delayed_off(
+                1, "tasmota", "1.2.3.4", None, None, None, printer_id=1, delay_seconds=0, controls_printer_power=False
+            )
+
+            mock_service.turn_off.assert_awaited_once()  # the plug still switches off
+            mock_pm.mark_printer_offline.assert_not_called()  # but the printer is untouched
+
+    @pytest.mark.asyncio
+    async def test_temp_based_off_skips_offline_mark_for_accessory(self, manager):
+        mock_service = AsyncMock()
+        mock_service.turn_off = AsyncMock(return_value=True)
+        with (
+            patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
+            patch("backend.app.services.smart_plug_manager.asyncio.sleep", new_callable=AsyncMock),
+            patch.object(manager, "get_service_for_plug", new_callable=AsyncMock, return_value=mock_service),
+            patch.object(manager, "_mark_auto_off_executed", new_callable=AsyncMock),
+        ):
+            mock_pm.get_status.return_value = MagicMock(state="FINISH", temperatures={"nozzle": 40})
+            mock_pm.is_print_active.return_value = False
+
+            await manager._temp_based_off(
+                1,
+                "tasmota",
+                "1.2.3.4",
+                None,
+                None,
+                None,
+                printer_id=1,
+                temp_threshold=55,
+                controls_printer_power=False,
+            )
+
+            mock_service.turn_off.assert_awaited_once()
+            mock_pm.mark_printer_offline.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_schedulers_forward_the_flag(self, manager, accessory_plug):
+        """The flag lives on the plug row; both schedulers must pass it into the
+        detached task, which only receives primitives."""
+        with (
+            patch.object(manager, "_mark_auto_off_pending", new_callable=AsyncMock),
+            patch.object(manager, "_delayed_off", new_callable=AsyncMock) as mock_delayed,
+            patch.object(manager, "_temp_based_off", new_callable=AsyncMock) as mock_temp,
+        ):
+            manager._schedule_delayed_off(accessory_plug, 1, 60)
+            manager._schedule_temp_based_off(accessory_plug, 1, 70)
+
+            assert mock_delayed.call_args.kwargs["controls_printer_power"] is False
+            assert mock_temp.call_args.kwargs["controls_printer_power"] is False
+
+    @pytest.mark.asyncio
+    async def test_scheduled_off_skips_offline_mark_for_accessory(self, manager, accessory_plug):
+        """The time-of-day schedule path has its own turn-off + offline mark."""
+        accessory_plug.schedule_enabled = True
+        accessory_plug.schedule_on_time = None
+        accessory_plug.schedule_off_time = "22:00"
+        with (
+            patch("backend.app.services.smart_plug_manager.datetime") as mock_datetime,
+            patch("backend.app.core.database.async_session") as mock_session_ctx,
+            patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
+            patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
+        ):
+            mock_now = MagicMock()
+            mock_now.strftime.return_value = "22:00"
+            mock_datetime.now.return_value = mock_now
+
+            mock_db = AsyncMock()
+            mock_result = MagicMock()
+            mock_result.scalars.return_value.all.return_value = [accessory_plug]
+            mock_db.execute = AsyncMock(return_value=mock_result)
+            mock_db.commit = AsyncMock()
+            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session_ctx.return_value.__aexit__ = AsyncMock()
+
+            mock_tasmota.turn_off = AsyncMock(return_value=True)
+
+            await manager._check_schedules()
+
+            mock_tasmota.turn_off.assert_awaited_once_with(accessory_plug)
+            mock_pm.mark_printer_offline.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_scheduled_off_still_marks_offline_for_power_plug(self, manager, accessory_plug):
+        """Default (a plug that really feeds the printer) keeps the old behaviour."""
+        accessory_plug.controls_printer_power = True
+        accessory_plug.schedule_enabled = True
+        accessory_plug.schedule_on_time = None
+        accessory_plug.schedule_off_time = "22:00"
+        with (
+            patch("backend.app.services.smart_plug_manager.datetime") as mock_datetime,
+            patch("backend.app.core.database.async_session") as mock_session_ctx,
+            patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota,
+            patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm,
+        ):
+            mock_now = MagicMock()
+            mock_now.strftime.return_value = "22:00"
+            mock_datetime.now.return_value = mock_now
+
+            mock_db = AsyncMock()
+            mock_result = MagicMock()
+            mock_result.scalars.return_value.all.return_value = [accessory_plug]
+            mock_db.execute = AsyncMock(return_value=mock_result)
+            mock_db.commit = AsyncMock()
+            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session_ctx.return_value.__aexit__ = AsyncMock()
+
+            mock_tasmota.turn_off = AsyncMock(return_value=True)
+
+            await manager._check_schedules()
+
+            mock_pm.mark_printer_offline.assert_called_once_with(1)

+ 146 - 13
backend/tests/unit/services/test_virtual_printer.py

@@ -717,8 +717,9 @@ class TestVirtualPrinterInstance:
         # settings values must flow through to the queue item exactly as stored.
         # settings values must flow through to the queue item exactly as stored.
         settings_map = {
         settings_map = {
             "virtual_printer_archive_name_source": None,
             "virtual_printer_archive_name_source": None,
-            "default_bed_levelling": "false",  # model default: True
-            "default_flow_cali": "true",  # model default: False
+            # Legacy boolean-string rows still coerce (false->off, true->on).
+            "default_bed_levelling": "false",  # tri-state default: auto
+            "default_flow_cali": "true",  # tri-state default: auto
             "default_vibration_cali": "false",  # model default: True
             "default_vibration_cali": "false",  # model default: True
             "default_layer_inspect": "true",  # model default: False
             "default_layer_inspect": "true",  # model default: False
             "default_timelapse": "true",  # model default: False
             "default_timelapse": "true",  # model default: False
@@ -746,8 +747,8 @@ class TestVirtualPrinterInstance:
 
 
         assert len(added_items) == 1
         assert len(added_items) == 1
         queue_item = added_items[0]
         queue_item = added_items[0]
-        assert queue_item.bed_levelling is False, "default_bed_levelling=false must flow through"
-        assert queue_item.flow_cali is True, "default_flow_cali=true must flow through"
+        assert queue_item.bed_levelling == "off", "default_bed_levelling=false must flow through"
+        assert queue_item.flow_cali == "on", "default_flow_cali=true must flow through"
         assert queue_item.vibration_cali is False, "default_vibration_cali=false must flow through"
         assert queue_item.vibration_cali is False, "default_vibration_cali=false must flow through"
         assert queue_item.layer_inspect is True, "default_layer_inspect=true must flow through"
         assert queue_item.layer_inspect is True, "default_layer_inspect=true must flow through"
         assert queue_item.timelapse is True, "default_timelapse=true must flow through"
         assert queue_item.timelapse is True, "default_timelapse=true must flow through"
@@ -806,8 +807,8 @@ class TestVirtualPrinterInstance:
         assert len(added_items) == 1
         assert len(added_items) == 1
         queue_item = added_items[0]
         queue_item = added_items[0]
         # These must match the AppSettings (Pydantic) defaults in schemas/settings.py
         # These must match the AppSettings (Pydantic) defaults in schemas/settings.py
-        assert queue_item.bed_levelling is True
-        assert queue_item.flow_cali is False
+        assert queue_item.bed_levelling == "auto"
+        assert queue_item.flow_cali == "auto"
         assert queue_item.vibration_cali is True
         assert queue_item.vibration_cali is True
         assert queue_item.layer_inspect is False
         assert queue_item.layer_inspect is False
         assert queue_item.timelapse is False
         assert queue_item.timelapse is False
@@ -896,8 +897,8 @@ class TestVirtualPrinterInstance:
         assert len(added_items) == 1
         assert len(added_items) == 1
         queue_item = added_items[0]
         queue_item = added_items[0]
         assert queue_item.timelapse is True, "Slicer's timelapse=True must override settings.default_timelapse=False"
         assert queue_item.timelapse is True, "Slicer's timelapse=True must override settings.default_timelapse=False"
-        assert queue_item.bed_levelling is False, "Slicer's bed_leveling=False must override default_bed_levelling=True"
-        assert queue_item.flow_cali is True
+        assert queue_item.bed_levelling == "off", "Slicer's bed_leveling=False must override default_bed_levelling"
+        assert queue_item.flow_cali == "on"
         assert queue_item.vibration_cali is False
         assert queue_item.vibration_cali is False
         assert queue_item.layer_inspect is True
         assert queue_item.layer_inspect is True
         # Capture is consumed — no lingering state for the next print of the same name.
         # Capture is consumed — no lingering state for the next print of the same name.
@@ -962,8 +963,75 @@ class TestVirtualPrinterInstance:
         assert len(added_items) == 1
         assert len(added_items) == 1
         queue_item = added_items[0]
         queue_item = added_items[0]
         assert queue_item.timelapse is True, "integer 1 must coerce to True"
         assert queue_item.timelapse is True, "integer 1 must coerce to True"
-        assert queue_item.bed_levelling is False, "integer 0 must coerce to False"
-        assert queue_item.flow_cali is True
+        assert queue_item.bed_levelling == "off", "integer 0 must coerce to off"
+        assert queue_item.flow_cali == "on"
+
+    @pytest.mark.asyncio
+    async def test_add_to_print_queue_captures_slicer_auto_from_int_companion(self, tmp_path):
+        """The slicer's tri-state rides on the int companion (auto_bed_leveling /
+        extrude_cali_flag). When the slicer picks "Auto" it sends bed_leveling
+        false + auto_bed_leveling 2; the VP must record "auto", not "off".
+        """
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        added_items = []
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock(side_effect=added_items.append)
+        mock_db.commit = AsyncMock()
+        mock_session_factory = MagicMock()
+        mock_session_ctx = AsyncMock()
+        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+        mock_session_factory.return_value = mock_session_ctx
+
+        inst = VirtualPrinterInstance(
+            vp_id=26,
+            name="SlicerAuto",
+            mode="queue",
+            model="C12",
+            access_code="12345678",
+            serial_suffix="391800026",
+            auto_dispatch=True,
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+        )
+
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(b"fake3mf")
+
+        await inst.on_print_command(
+            file_path.name,
+            {
+                "command": "project_file",
+                "bed_leveling": False,
+                "auto_bed_leveling": 2,
+                "flow_cali": False,
+                "extrude_cali_flag": 2,
+            },
+        )
+
+        mock_archive = MagicMock()
+        mock_archive.id = 1
+        mock_archive.print_name = "test"
+
+        with (
+            patch(
+                "backend.app.api.routes.settings.get_setting",
+                new_callable=AsyncMock,
+                return_value=None,
+            ),
+            patch(
+                "backend.app.services.archive.ArchiveService.archive_print",
+                new_callable=AsyncMock,
+                return_value=mock_archive,
+            ),
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+        assert len(added_items) == 1
+        queue_item = added_items[0]
+        assert queue_item.bed_levelling == "auto", "auto_bed_leveling=2 must record 'auto'"
+        assert queue_item.flow_cali == "auto", "extrude_cali_flag=2 must record 'auto'"
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_add_to_print_queue_populates_required_filament_types(self, tmp_path):
     async def test_add_to_print_queue_populates_required_filament_types(self, tmp_path):
@@ -1101,12 +1169,77 @@ class TestVirtualPrinterInstance:
         assert queue_item.filament_overrides is not None
         assert queue_item.filament_overrides is not None
         overrides = json.loads(queue_item.filament_overrides)
         overrides = json.loads(queue_item.filament_overrides)
         assert overrides == [
         assert overrides == [
-            {"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "force_color_match": True},
-            {"slot_id": 2, "type": "PLA", "color": "#FF00FF", "force_color_match": True},
+            {"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "tray_info_idx": "", "force_color_match": True},
+            {"slot_id": 2, "type": "PLA", "color": "#FF00FF", "tray_info_idx": "", "force_color_match": True},
         ]
         ]
         # required_filament_types still populated alongside overrides.
         # required_filament_types still populated alongside overrides.
         assert json.loads(queue_item.required_filament_types) == ["PLA"]
         assert json.loads(queue_item.required_filament_types) == ["PLA"]
 
 
+    @pytest.mark.asyncio
+    async def test_add_to_print_queue_force_color_match_carries_tray_info_idx(self, tmp_path):
+        """#2650: the force override must carry the 3MF's ``tray_info_idx`` so the
+        scheduler can tell Bambu PLA variants apart (Basic GFA00 / Matte GFA01 /
+        Silk GFA06) — they all report ``tray_type == "PLA"`` with the same colour,
+        so type+colour alone dispatches onto the wrong variant."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        added_items = []
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock(side_effect=added_items.append)
+        mock_db.commit = AsyncMock()
+        mock_session_factory = MagicMock()
+        mock_session_ctx = AsyncMock()
+        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+        mock_session_factory.return_value = mock_session_ctx
+
+        inst = VirtualPrinterInstance(
+            vp_id=24,
+            name="Variant",
+            mode="queue",
+            model="C12",
+            access_code="12345678",
+            serial_suffix="391800024",
+            auto_dispatch=True,
+            queue_force_color_match=True,
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+        )
+
+        file_path = tmp_path / "variant.3mf"
+        _write_3mf_with_filaments(
+            file_path,
+            [
+                # White PLA Matte — same colour as Basic/Silk, distinguished only by idx.
+                {"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "10.0", "tray_info_idx": "GFA01"},
+            ],
+            plate_index=1,
+        )
+
+        mock_archive = MagicMock()
+        mock_archive.id = 1
+        mock_archive.print_name = "variant"
+
+        with (
+            patch(
+                "backend.app.api.routes.settings.get_setting",
+                new_callable=AsyncMock,
+                return_value=None,
+            ),
+            patch(
+                "backend.app.services.archive.ArchiveService.archive_print",
+                new_callable=AsyncMock,
+                return_value=mock_archive,
+            ),
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+        assert len(added_items) == 1
+        overrides = json.loads(added_items[0].filament_overrides)
+        assert overrides == [
+            {"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "tray_info_idx": "GFA01", "force_color_match": True},
+        ]
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_add_to_print_queue_force_color_match_skips_when_3mf_unparseable(self, tmp_path):
     async def test_add_to_print_queue_force_color_match_skips_when_3mf_unparseable(self, tmp_path):
         """A malformed or fake-bytes 3MF must not crash the upload path —
         """A malformed or fake-bytes 3MF must not crash the upload path —
@@ -1895,7 +2028,7 @@ class TestVirtualPrinterInstance:
         params = dict(compiled.params)
         params = dict(compiled.params)
         assert _json.loads(params["nozzle_mapping"]) == [16, -1, -1, 1]
         assert _json.loads(params["nozzle_mapping"]) == [16, -1, -1, 1]
         assert params["timelapse"] is True
         assert params["timelapse"] is True
-        assert params["bed_levelling"] is False  # MQTT bed_leveling → column bed_levelling
+        assert params["bed_levelling"] == "off"  # MQTT bed_leveling → column bed_levelling (tri-state)
         # Recent-queue tracking dict is cleared after the patch.
         # Recent-queue tracking dict is cleared after the patch.
         assert file_path.name not in inst._recent_queue_items
         assert file_path.name not in inst._recent_queue_items
 
 

+ 227 - 0
backend/tests/unit/test_a2l_ams_lite_2619.py

@@ -0,0 +1,227 @@
+"""A2L "AMS Lite" unit-id normalisation (memory a2l-am-unit-16).
+
+The A2L reports its 4-slot AMS Lite as physical unit id 16, but its tray
+bitmasks sit at bit base 24 (= id 6) and it reports tray_now as a local 0-3
+slot. We normalise 16 -> 6 at the MQTT ingest boundary so global tray ids land
+at 24-27 and every ams_id*4+slot consumer works unchanged, and translate back to
+the physical id 16 only on the outbound wire.
+
+Field values here mirror the confirmed capture (2026-07-20): physical slots 1
+empty, 2 & 3 loaded, 4 empty; tray_exist_bits "6000000"; tray_now "2" while
+printing physical slot 3.
+"""
+
+import json
+from unittest.mock import MagicMock
+
+from backend.app.services.bambu_mqtt import (
+    A2L_LITE_GLOBAL_BASE,
+    A2L_LITE_NORMALIZED_AMS_ID,
+    A2L_LITE_PHYSICAL_AMS_ID,
+    BambuMQTTClient,
+    a2l_lite_wire_ids,
+    normalize_am_unit_id,
+)
+
+
+def _client(model: str = "A2L") -> BambuMQTTClient:
+    return BambuMQTTClient(ip_address="10.0.0.1", serial_number="A2L", access_code="c", model=model)
+
+
+def _wired(client: BambuMQTTClient) -> BambuMQTTClient:
+    client._client = MagicMock()
+    client.state.connected = True
+    return client
+
+
+def _capture_frame() -> dict:
+    """One push_status frame matching Mike's 2026-07-20 capture."""
+    return {
+        "ams": [
+            {
+                "id": 16,
+                "tray": [
+                    {"id": 0},
+                    {
+                        "id": 1,
+                        "state": 3,
+                        "remain": 100,
+                        "tray_type": "",
+                        "tray_info_idx": "",
+                        "tray_color": "FFFFFF00",
+                    },
+                    {
+                        "id": 2,
+                        "state": 3,
+                        "remain": 100,
+                        "tray_type": "",
+                        "tray_info_idx": "",
+                        "tray_color": "FFFFFF00",
+                    },
+                    {"id": 3},
+                ],
+            }
+        ],
+        "ams_exist_bits": "1000",
+        "tray_exist_bits": "6000000",
+        "tray_now": "2",
+        "tray_pre": "2",
+        "tray_tar": "2",
+    }
+
+
+def _last_payload(client: BambuMQTTClient) -> dict:
+    return json.loads(client._client.publish.call_args[0][1])["print"]
+
+
+class TestHelpers:
+    def test_normalize_touches_only_16(self):
+        assert normalize_am_unit_id(A2L_LITE_PHYSICAL_AMS_ID) == A2L_LITE_NORMALIZED_AMS_ID
+        for other in (0, 1, 2, 3, 6, 15, 128, 135, 254, 255):
+            assert normalize_am_unit_id(other) == other
+
+    def test_wire_ids_only_for_normalised_6(self):
+        # (physical ams id, local slot, physical global tray)
+        assert a2l_lite_wire_ids(6, 2) == (16, 2, 66)
+        assert a2l_lite_wire_ids(6, 0) == (16, 0, 64)
+        # tray_id is taken modulo 4, so a global tray works too.
+        assert a2l_lite_wire_ids(6, 26) == (16, 2, 66)
+        # Any other unit id is left alone (returns None).
+        for ams in (0, 3, 16, 128, 255):
+            assert a2l_lite_wire_ids(ams, 2) is None
+
+
+class TestIngestNormalisation:
+    def test_unit_id_16_normalised_to_6(self):
+        client = _client()
+        client._handle_ams_data(_capture_frame())
+        assert client.state.raw_data["ams"][0]["id"] == A2L_LITE_NORMALIZED_AMS_ID
+        assert client._has_a2l_am_unit is True
+
+    def test_exists_annotation_uses_bit_base_24(self):
+        # tray_exist_bits "6000000" = bits 25,26 -> global_bit 24+slot -> slots 1,2.
+        client = _client()
+        client._handle_ams_data(_capture_frame())
+        trays = {t["id"]: t for t in client.state.raw_data["ams"][0]["tray"]}
+        assert trays[1]["exists"] is True
+        assert trays[2]["exists"] is True
+        assert trays[0]["exists"] is False
+        assert trays[3]["exists"] is False
+
+    def test_regular_ams_untouched(self):
+        client = _client(model="X1C")
+        frame = {
+            "ams": [{"id": 0, "tray": [{"id": 0}, {"id": 1}, {"id": 2}, {"id": 3}]}],
+            "tray_exist_bits": "3",
+            "tray_now": "1",
+        }
+        client._handle_ams_data(frame)
+        assert client.state.raw_data["ams"][0]["id"] == 0
+        assert client._has_a2l_am_unit is False
+        assert client.state.tray_now == 1  # regular AMS 0 slot 1 == global 1
+
+    def test_bare_list_ams_shape_is_also_normalised(self):
+        # Some firmware/shapes deliver the unit list directly (no dict wrapper).
+        client = _client()
+        client._handle_ams_data([{"id": 16, "tray": [{"id": 0}, {"id": 1}, {"id": 2}, {"id": 3}]}])
+        assert client.state.raw_data["ams"][0]["id"] == A2L_LITE_NORMALIZED_AMS_ID
+        assert client._has_a2l_am_unit is True
+
+
+class TestTrayNowGlobalisation:
+    def test_local_tray_now_globalised_to_24_plus_slot(self):
+        client = _client()
+        client._handle_ams_data(_capture_frame())
+        # local slot 2 -> global 26 (24 + 2)
+        assert client.state.tray_now == A2L_LITE_GLOBAL_BASE + 2 == 26
+
+    def test_globalised_tray_passes_last_valid_guard(self):
+        # last_loaded_tray is only written when the valid-tray guard accepts tn.
+        client = _client()
+        client._handle_ams_data(_capture_frame())
+        assert client.state.last_loaded_tray == 26
+
+
+class TestOutboundTranslation:
+    def test_set_filament_setting_uses_physical_16_local_slot(self):
+        client = _wired(_client())
+        assert client.ams_set_filament_setting(
+            ams_id=6,
+            tray_id=2,
+            tray_info_idx="GFL05",
+            tray_type="PLA",
+            tray_sub_brands="PLA Basic",
+            tray_color="FF0000FF",
+            nozzle_temp_min=190,
+            nozzle_temp_max=230,
+        )
+        p = _last_payload(client)
+        assert p["ams_id"] == A2L_LITE_PHYSICAL_AMS_ID
+        assert p["tray_id"] == 2
+        assert p["slot_id"] == 2
+
+    def test_reset_slot_uses_physical_16_local_slot(self):
+        client = _wired(_client())
+        assert client.reset_ams_slot(ams_id=6, tray_id=3)
+        p = _last_payload(client)
+        assert p["ams_id"] == A2L_LITE_PHYSICAL_AMS_ID
+        assert p["tray_id"] == 3
+        assert p["slot_id"] == 3
+
+    def test_cali_sel_uses_physical_global_tray(self):
+        client = _wired(_client())
+        assert client.extrusion_cali_sel(ams_id=6, tray_id=2, cali_idx=1, filament_id="GFL05")
+        p = _last_payload(client)
+        assert p["ams_id"] == A2L_LITE_PHYSICAL_AMS_ID
+        assert p["tray_id"] == 66  # 16*4 + 2 (extrapolated physical global)
+        assert p["slot_id"] == 2
+
+    def test_cali_set_remaps_global_tray(self):
+        client = _wired(_client())
+        assert client.extrusion_cali_set(tray_id=26, k_value=0.02, filament_id="GFL05")
+        p = _last_payload(client)
+        assert p["filaments"][0]["tray_id"] == 66  # 26 (normalised) -> 66 (physical)
+
+    def test_load_filament_target_and_ams(self):
+        client = _wired(_client())
+        assert client.ams_load_filament(tray_id=26)
+        p = _last_payload(client)
+        assert p["ams_id"] == A2L_LITE_PHYSICAL_AMS_ID
+        assert p["slot_id"] == 2
+        assert p["target"] == 66
+
+    def test_unload_uses_physical_ams(self):
+        client = _wired(_client())
+        client.state.tray_now = 26
+        assert client.ams_unload_filament()
+        assert _last_payload(client)["ams_id"] == A2L_LITE_PHYSICAL_AMS_ID
+
+    def test_refresh_tray_uses_physical_16(self):
+        client = _wired(_client())
+        client.state.tray_now = 255  # nothing loaded, so refresh is allowed
+        ok, _ = client.ams_refresh_tray(ams_id=6, tray_id=2)
+        assert ok
+        p = _last_payload(client)
+        assert p["ams_id"] == A2L_LITE_PHYSICAL_AMS_ID
+        assert p["slot_id"] == 2
+
+    def test_drying_uses_physical_16(self):
+        client = _wired(_client())
+        assert client.send_drying_command(ams_id=6, temp=55, duration=4, mode=1, filament="PLA")
+        assert _last_payload(client)["ams_id"] == A2L_LITE_PHYSICAL_AMS_ID
+
+    def test_regular_ams_command_unchanged(self):
+        client = _wired(_client(model="X1C"))
+        assert client.ams_set_filament_setting(
+            ams_id=0,
+            tray_id=2,
+            tray_info_idx="GFL05",
+            tray_type="PLA",
+            tray_sub_brands="PLA Basic",
+            tray_color="FF0000FF",
+            nozzle_temp_min=190,
+            nozzle_temp_max=230,
+        )
+        p = _last_payload(client)
+        assert p["ams_id"] == 0
+        assert p["tray_id"] == 2

+ 76 - 0
backend/tests/unit/test_accessory_plug_queue_stall_2629.py

@@ -0,0 +1,76 @@
+"""End-to-end regression test for the #2629 queue stall.
+
+Exercises the real objects rather than mocks: a real ``BambuMQTTClient``
+registered on the real ``printer_manager`` singleton, driven through the real
+``_on_message`` path, and read back through the scheduler's own idle check.
+That chain — presume power off, printer keeps talking, scheduler sees it as
+dispatchable again — is what actually broke for the reporter, and no single
+unit test covers it.
+"""
+
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from backend.app.services.bambu_mqtt import BambuMQTTClient
+from backend.app.services.print_scheduler import PrintScheduler
+from backend.app.services.printer_manager import printer_manager
+
+PRINTER_ID = 9629  # unlikely to collide with any other test's registrations
+
+
+class _Msg:
+    def __init__(self, topic: str, data: dict):
+        self.topic = topic
+        self.payload = json.dumps(data).encode()
+
+
+@pytest.fixture
+def registered_client():
+    """A connected client sitting on FINISH, as after a completed print."""
+    client = BambuMQTTClient(ip_address="10.0.0.5", serial_number="SER2629", access_code="12345678")
+    client.state.connected = True
+    client.state.state = "FINISH"
+    printer_manager._clients[PRINTER_ID] = client
+    try:
+        yield client
+    finally:
+        printer_manager._clients.pop(PRINTER_ID, None)
+
+
+def _partial_push(client: BambuMQTTClient) -> None:
+    """A steady-state push_status carrying no gcode_state — the frame shape the
+    reporter's P1S sends between state transitions."""
+    client._on_message(None, None, _Msg(client.topic_subscribe, {"print": {"wifi_signal": "-30dBm"}}))
+
+
+def test_printer_recovers_and_queue_can_dispatch_again(registered_client):
+    scheduler = PrintScheduler()
+    assert scheduler._is_printer_idle(PRINTER_ID, require_plate_clear=False) is True
+
+    # An accessory plug (filter fan) switches off; Bambuddy presumes power loss.
+    printer_manager.mark_printer_offline(PRINTER_ID)
+    assert printer_manager.get_status(PRINTER_ID).state == "unknown"
+    assert scheduler._is_printer_idle(PRINTER_ID, require_plate_clear=False) is False
+
+    # The printer never stopped talking.
+    _partial_push(registered_client)
+
+    assert printer_manager.get_status(PRINTER_ID).state == "FINISH"
+    assert printer_manager.is_connected(PRINTER_ID) is True
+    assert scheduler._is_printer_idle(PRINTER_ID, require_plate_clear=False) is True
+
+
+def test_real_power_cut_still_leaves_printer_unavailable(registered_client):
+    """The recovery must key off actual traffic, not off time passing — a plug
+    that really cut power produces silence, and the printer stays offline."""
+    scheduler = PrintScheduler()
+
+    printer_manager.mark_printer_offline(PRINTER_ID)
+
+    # No messages arrive at all.
+    assert printer_manager.get_status(PRINTER_ID).state == "unknown"
+    assert printer_manager.is_connected(PRINTER_ID) is False
+    assert scheduler._is_printer_idle(PRINTER_ID, require_plate_clear=False) is False

+ 151 - 0
backend/tests/unit/test_assignment_verification_2582.py

@@ -0,0 +1,151 @@
+"""Read-back verification of AMS spool assignments (#2582).
+
+After Bambuddy pushes an assignment (``ams_filament_setting`` +
+``extrusion_cali_sel``) it registers the desired end-state and watches the
+periodic AMS telemetry to confirm the tray actually accepted it. Historically
+this was fire-and-forget, so a silently-dropped assignment (the reporter's
+"assigned in Bambuddy but Studio never saw it") produced no feedback at all.
+
+These tests lock in the matcher: a tray_info_idx echo confirms the push landed,
+cali_idx is a secondary "K-profile applied" signal, and a timeout without a
+matching echo reports a non-confirmation instead of inventing success.
+"""
+
+import time
+from unittest.mock import MagicMock
+
+from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+
+def _client(on_verified=None) -> BambuMQTTClient:
+    return BambuMQTTClient(
+        ip_address="10.0.0.1",
+        serial_number="SERIAL",
+        access_code="code",
+        model="P1S",
+        on_assignment_verified=on_verified,
+    )
+
+
+def _ams_frame(tray_id=0, ams_id=0, **tray_fields):
+    """One AMS unit with the given tray carrying content fields."""
+    tray = {"id": tray_id}
+    tray.update(tray_fields)
+    return {"ams": [{"id": ams_id, "tray": [tray]}]}
+
+
+class TestAssignmentMatch:
+    def test_matching_tray_info_idx_fires_verified(self):
+        cb = MagicMock()
+        client = _client(cb)
+        client.register_assignment_verification(
+            ams_id=0, tray_id=0, tray_info_idx="GFL05", tray_color="FF0000FF", cali_idx=-1
+        )
+        client._handle_ams_data(_ams_frame(tray_info_idx="GFL05", tray_type="PLA"))
+
+        cb.assert_called_once()
+        ams_id, tray_id, verified, detail = cb.call_args.args
+        assert (ams_id, tray_id, verified) == (0, 0, True)
+        assert detail["kprofile_applied"] is True
+        # Pending entry is cleared once resolved.
+        assert (0, 0) not in client._pending_assignments
+
+    def test_match_is_case_insensitive(self):
+        cb = MagicMock()
+        client = _client(cb)
+        client.register_assignment_verification(
+            ams_id=0, tray_id=0, tray_info_idx="gfl05", tray_color="", cali_idx=None
+        )
+        client._handle_ams_data(_ams_frame(tray_info_idx="GFL05"))
+        assert cb.call_args.args[2] is True
+
+    def test_kprofile_mismatch_flags_not_applied(self):
+        cb = MagicMock()
+        client = _client(cb)
+        client.register_assignment_verification(ams_id=0, tray_id=0, tray_info_idx="GFL05", tray_color="", cali_idx=3)
+        # Filament id landed but the printer kept a different cali_idx.
+        client._handle_ams_data(_ams_frame(tray_info_idx="GFL05", cali_idx=1))
+
+        verified, detail = cb.call_args.args[2], cb.call_args.args[3]
+        assert verified is True
+        assert detail["kprofile_applied"] is False
+
+    def test_kprofile_match_flags_applied(self):
+        cb = MagicMock()
+        client = _client(cb)
+        client.register_assignment_verification(ams_id=0, tray_id=0, tray_info_idx="GFL05", tray_color="", cali_idx=3)
+        client._handle_ams_data(_ams_frame(tray_info_idx="GFL05", cali_idx=3))
+        assert cb.call_args.args[3]["kprofile_applied"] is True
+
+
+class TestAssignmentPendingAndTimeout:
+    def test_divergent_idx_within_window_keeps_waiting(self):
+        cb = MagicMock()
+        client = _client(cb)
+        client.register_assignment_verification(ams_id=0, tray_id=0, tray_info_idx="GFL05", tray_color="", cali_idx=-1)
+        # Tray still shows the previous filament — no callback, stay pending.
+        client._handle_ams_data(_ams_frame(tray_info_idx="GFU00"))
+        cb.assert_not_called()
+        assert (0, 0) in client._pending_assignments
+
+    def test_timeout_after_seeing_divergent_tray_reports_failure(self):
+        cb = MagicMock()
+        client = _client(cb)
+        client.register_assignment_verification(ams_id=0, tray_id=0, tray_info_idx="GFL05", tray_color="", cali_idx=-1)
+        # First push observes a divergent id (records last_seen_idx).
+        client._handle_ams_data(_ams_frame(tray_info_idx="GFU00"))
+        # Force the deadline into the past, then another push evaluates it.
+        client._pending_assignments[(0, 0)]["deadline"] = time.monotonic() - 1
+        client._handle_ams_data(_ams_frame(tray_info_idx="GFU00"))
+
+        verified, detail = cb.call_args.args[2], cb.call_args.args[3]
+        assert verified is False
+        assert detail["saw_tray"] is True
+        assert detail["actual_tray_info_idx"] == "GFU00"
+        assert (0, 0) not in client._pending_assignments
+
+    def test_timeout_without_ever_seeing_tray_reports_no_tray(self):
+        cb = MagicMock()
+        client = _client(cb)
+        client.register_assignment_verification(ams_id=1, tray_id=2, tray_info_idx="GFL05", tray_color="", cali_idx=-1)
+        client._pending_assignments[(1, 2)]["deadline"] = time.monotonic() - 1
+        # A push for an unrelated AMS unit still triggers deadline evaluation.
+        client._handle_ams_data(_ams_frame(ams_id=0, tray_id=0, tray_info_idx="GFL05"))
+
+        verified, detail = cb.call_args.args[2], cb.call_args.args[3]
+        assert verified is False
+        assert detail["saw_tray"] is False
+        assert detail["actual_tray_info_idx"] is None
+
+
+class TestRegistrationGuards:
+    def test_blank_tray_info_idx_is_not_registered(self):
+        client = _client()
+        client.register_assignment_verification(
+            ams_id=0, tray_id=0, tray_info_idx="", tray_color="FF0000FF", cali_idx=-1
+        )
+        assert not client._pending_assignments
+
+    def test_reconnect_clears_pending(self):
+        client = _client()
+        client.register_assignment_verification(ams_id=0, tray_id=0, tray_info_idx="GFL05", tray_color="", cali_idx=-1)
+        assert client._pending_assignments
+        # Mirror the on_connect reset path.
+        client._pending_assignments.clear()
+        assert not client._pending_assignments
+
+
+class TestExternalSpool:
+    def test_external_tray_matches_via_vt_tray(self):
+        cb = MagicMock()
+        client = _client(cb)
+        # External-left spool: logical ams_id 255 / tray 0 lives at vt_tray id 254.
+        client.state.raw_data["vt_tray"] = [{"id": 254, "tray_info_idx": "GFL05"}]
+        client.register_assignment_verification(
+            ams_id=255, tray_id=0, tray_info_idx="GFL05", tray_color="", cali_idx=-1
+        )
+        # Any AMS push drives the check; the tray is resolved from vt_tray.
+        client._handle_ams_data(_ams_frame(tray_info_idx="GFU00"))
+
+        cb.assert_called_once()
+        assert cb.call_args.args[2] is True

+ 199 - 0
backend/tests/unit/test_camera_usb_stream_cleanup.py

@@ -0,0 +1,199 @@
+"""External/USB camera ffmpeg-leak cleanup (#2675, reporter @bitbarista).
+
+An external USB (V4L2) camera's ffmpeg used to be reachable only from its own
+stream generator's ``finally`` — which an abrupt client disconnect can skip
+(same cancellation-timing class as #776). Because external streams never
+registered into ``_active_streams`` / ``_disconnect_events`` / the spawned-PID
+map, both ``/camera/stop`` and ``cleanup_orphaned_streams`` were structurally
+blind to the leak, leaving ``/dev/videoN`` locked. The fix registers the external
+ffmpeg into the same registries the RTSP path uses.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import time
+from contextlib import suppress
+from unittest.mock import mock_open, patch
+
+import pytest
+
+from backend.app.api.routes import camera
+from backend.app.services import external_camera
+
+
+async def _instant_sleep(*_args, **_kwargs) -> None:
+    """Drop-in for asyncio.sleep that returns immediately (no self-recursion)."""
+    return None
+
+
+class _CleanProc:
+    """ffmpeg that terminates cleanly when asked."""
+
+    def __init__(self, pid: int) -> None:
+        self.pid = pid
+        self.returncode = None
+
+    def terminate(self) -> None:
+        self.returncode = 0
+
+    def kill(self) -> None:
+        self.returncode = -9
+
+    async def wait(self) -> int:
+        return self.returncode if self.returncode is not None else 0
+
+
+class _ImmediateEOFReader:
+    async def read(self, _size: int = -1) -> bytes:
+        return b""
+
+
+class _UsbProc:
+    """ffmpeg for a USB stream: yields no frames, exits at first read."""
+
+    def __init__(self, pid: int = 52001) -> None:
+        self.pid = pid
+        self.returncode = None
+        self.stdout = _ImmediateEOFReader()
+        self.stderr = _ImmediateEOFReader()
+
+    def terminate(self) -> None:
+        self.returncode = 0
+
+    def kill(self) -> None:
+        self.returncode = -9
+
+    async def wait(self) -> int:
+        return 0
+
+
+# ---------------------------------------------------------------------------
+# 1. The stream generator hands its ffmpeg process to the on_process callback
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_stream_usb_registers_process_via_on_process(monkeypatch):
+    """``_stream_usb`` must call ``on_process`` with the spawned ffmpeg so the
+    route can register it — this is the linchpin of the whole fix."""
+
+    class _FakePath:
+        def __init__(self, _p: str) -> None:
+            pass
+
+        def exists(self) -> bool:
+            return True
+
+    proc = _UsbProc()
+
+    async def fake_create_subprocess_exec(*_args, **_kwargs):
+        return proc
+
+    monkeypatch.setattr(external_camera, "get_ffmpeg_path", lambda: "/fake/ffmpeg")
+    monkeypatch.setattr(external_camera, "Path", _FakePath)
+    monkeypatch.setattr(external_camera.asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
+    monkeypatch.setattr(external_camera.asyncio, "sleep", _instant_sleep)
+
+    captured: list[object] = []
+    stream = external_camera._stream_usb("/dev/video0", 10, on_process=captured.append)
+    try:
+        async for _frame in stream:
+            pass
+    finally:
+        with suppress(Exception):
+            await stream.aclose()
+
+    assert captured == [proc], "the spawned ffmpeg process must be handed to on_process"
+
+
+# ---------------------------------------------------------------------------
+# 2. /camera/stop now finds and kills a registered external USB process
+#    (the reported {"stopped": 0} → {"stopped": 1})
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_stop_endpoint_terminates_registered_external_process(monkeypatch):
+    monkeypatch.setattr(camera, "_FFMPEG_KILL_TIMEOUT", 0.05)
+    monkeypatch.setattr(camera, "get_subscriber_count", lambda _key: 0)
+
+    async def fake_shutdown(_key):
+        return False
+
+    monkeypatch.setattr(camera, "shutdown_broadcaster", fake_shutdown)
+
+    printer_id = 7
+    sid = f"{printer_id}-ext-abc12345"
+    proc = _CleanProc(pid=52010)
+    event = asyncio.Event()
+    camera._active_streams[sid] = proc
+    camera._disconnect_events[sid] = event
+    camera._spawned_ffmpeg_pids[proc.pid] = time.time()
+    camera._stream_last_frame_times[sid] = time.time()
+
+    try:
+        result = await camera.stop_camera_stream(printer_id, _=None)
+        assert result["stopped"] == 1
+        assert proc.returncode is not None, "the external ffmpeg must be terminated"
+        assert event.is_set(), "the stream's stop event must be signalled"
+        # Registry fully cleaned so it can't be double-reaped.
+        assert sid not in camera._active_streams
+        assert sid not in camera._disconnect_events
+        assert proc.pid not in camera._spawned_ffmpeg_pids
+    finally:
+        camera._active_streams.pop(sid, None)
+        camera._disconnect_events.pop(sid, None)
+        camera._spawned_ffmpeg_pids.pop(proc.pid, None)
+        camera._stream_last_frame_times.pop(sid, None)
+
+
+# ---------------------------------------------------------------------------
+# 3. The orphan janitor reaps a stale registered external USB stream
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_cleanup_janitor_reaps_stale_external_usb_stream(monkeypatch):
+    monkeypatch.setattr(camera, "_FFMPEG_KILL_TIMEOUT", 0.05)
+    monkeypatch.setattr(camera, "_scan_bambu_ffmpeg_pids", lambda: [])
+
+    import os
+
+    proc = _CleanProc(pid=os.getpid())  # real pid so layer-2 existence check keeps it
+    sid = "7-ext-deadbeef"
+    now = time.time()
+    camera._active_streams[sid] = proc
+    camera._spawned_ffmpeg_pids[proc.pid] = now - 120  # spawned long ago
+    camera._stream_last_frame_times[sid] = now - 60  # stale: no frames >30s
+    camera._disconnect_events[sid] = asyncio.Event()
+
+    try:
+        await asyncio.wait_for(camera.cleanup_orphaned_streams(), timeout=2.0)
+        assert proc.returncode is not None, "stale external ffmpeg must be killed"
+        assert sid not in camera._active_streams
+    finally:
+        camera._active_streams.pop(sid, None)
+        camera._spawned_ffmpeg_pids.pop(proc.pid, None)
+        camera._stream_last_frame_times.pop(sid, None)
+        camera._disconnect_events.pop(sid, None)
+
+
+# ---------------------------------------------------------------------------
+# 4. The /proc "nuclear net" now matches USB (v4l2) ffmpeg from prior sessions
+# ---------------------------------------------------------------------------
+
+
+def test_scan_matches_v4l2_ffmpeg(monkeypatch):
+    cmdline = b"ffmpeg\x00-f\x00v4l2\x00-i\x00/dev/video0\x00-f\x00mjpeg\x00-\x00"
+    monkeypatch.setattr("os.listdir", lambda _p: ["52020"])
+    with patch("builtins.open", mock_open(read_data=cmdline)):
+        assert 52020 in camera._scan_bambu_ffmpeg_pids()
+
+
+def test_scan_ignores_unrelated_ffmpeg(monkeypatch):
+    # A transcode of a local file is not ours — must not be reaped.
+    cmdline = b"ffmpeg\x00-i\x00/home/user/movie.mp4\x00out.mkv\x00"
+    monkeypatch.setattr("os.listdir", lambda _p: ["52021"])
+    with patch("builtins.open", mock_open(read_data=cmdline)):
+        assert camera._scan_bambu_ffmpeg_pids() == []

+ 57 - 2
backend/tests/unit/test_cloud_token_expiry.py

@@ -45,10 +45,27 @@ def _clear_validation_cache():
     bc.invalidate_validation_cache()
     bc.invalidate_validation_cache()
 
 
 
 
-def _service(status_code: int = 200, *, on_auth_failure=None, raises: Exception | None = None):
+# Bambu's genuine "token expired" 401 body — the only 401 that means sign-out.
+_EXPIRY_401_BODY = {"code": 4, "error": "Please login.", "message": ""}
+
+
+def _service(
+    status_code: int = 200,
+    *,
+    on_auth_failure=None,
+    raises: Exception | None = None,
+    body: object | None = None,
+    json_raises: bool = False,
+):
     svc = BambuCloudService(client=MagicMock(spec=httpx.AsyncClient), on_auth_failure=on_auth_failure)
     svc = BambuCloudService(client=MagicMock(spec=httpx.AsyncClient), on_auth_failure=on_auth_failure)
     resp = MagicMock()
     resp = MagicMock()
     resp.status_code = status_code
     resp.status_code = status_code
+    # A 401 defaults to Bambu's expiry body so existing "rejected token" cases
+    # mean a real expiry; pass body= to exercise a transient/benign 401.
+    if json_raises:
+        resp.json = MagicMock(side_effect=ValueError("not json"))
+    else:
+        resp.json = MagicMock(return_value=_EXPIRY_401_BODY if (body is None and status_code == 401) else (body or {}))
     svc._client.get = AsyncMock(side_effect=raises) if raises else AsyncMock(return_value=resp)
     svc._client.get = AsyncMock(side_effect=raises) if raises else AsyncMock(return_value=resp)
     return svc
     return svc
 
 
@@ -79,7 +96,30 @@ class TestValidateToken:
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_rejected_token_returns_false(self):
     async def test_rejected_token_returns_false(self):
-        svc = _service(401)
+        svc = _service(401)  # defaults to Bambu's genuine expiry body
+        svc.set_token("dead-token")
+        assert await svc.validate_token() is False
+
+    @pytest.mark.asyncio
+    async def test_transient_401_is_unknown_not_invalid(self):
+        """A 401 WITHOUT Bambu's expiry signature is edge/endpoint noise, not a
+        dead token — it must read as unknown, never sign the user out. This is
+        the regression that logged users out on a single stray 401."""
+        svc = _service(401, body={"code": 1, "error": "forbidden"})
+        svc.set_token("good-token")
+        assert await svc.validate_token() is None
+
+    @pytest.mark.asyncio
+    async def test_unparseable_401_is_unknown_not_invalid(self):
+        svc = _service(401, json_raises=True)
+        svc.set_token("good-token")
+        assert await svc.validate_token() is None
+
+    @pytest.mark.asyncio
+    async def test_expiry_signature_via_please_login_text(self):
+        """The `code:4` field is primary, but the "Please login." text alone
+        (no/other code) is still accepted as the expiry signal."""
+        svc = _service(401, body={"error": "Please login.", "message": ""})
         svc.set_token("dead-token")
         svc.set_token("dead-token")
         assert await svc.validate_token() is False
         assert await svc.validate_token() is False
 
 
@@ -170,11 +210,26 @@ class TestAuthFailureCallback:
         svc.set_token("dead-token")
         svc.set_token("dead-token")
         resp = MagicMock()
         resp = MagicMock()
         resp.status_code = 401
         resp.status_code = 401
+        resp.json = MagicMock(return_value=_EXPIRY_401_BODY)
         await svc._note_response(resp)
         await svc._note_response(resp)
         await svc._note_response(resp)
         await svc._note_response(resp)
         await svc._note_response(resp)
         await svc._note_response(resp)
         assert calls == [1]
         assert calls == [1]
 
 
+    @pytest.mark.asyncio
+    async def test_transient_401_does_not_fire_the_callback(self):
+        """A benign 401 must not durably invalidate — the callback that persists
+        the dead-token flag stays untouched."""
+        calls: list[int] = []
+
+        async def _cb() -> None:
+            calls.append(1)
+
+        svc = _service(401, on_auth_failure=_cb, body={"code": 1, "error": "forbidden"})
+        svc.set_token("good-token")
+        await svc.validate_token()
+        assert calls == []
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_success_does_not_fire_the_callback(self):
     async def test_success_does_not_fire_the_callback(self):
         calls: list[int] = []
         calls: list[int] = []

+ 30 - 0
backend/tests/unit/test_git_providers.py

@@ -463,6 +463,26 @@ class TestGiteaBackendApiBase:
         assert owner == "owner"
         assert owner == "owner"
         assert repo == "repo"
         assert repo == "repo"
 
 
+    def test_parse_url_subpath_hosted(self):
+        # Gitea under a ROOT_URL path prefix, e.g. https://host/gitea (#2642)
+        owner, repo = self.backend.parse_repo_url("https://DOMAIN/gitea/user/repo")
+        assert owner == "user"
+        assert repo == "repo"
+
+    def test_parse_url_subpath_hosted_with_git_suffix(self):
+        owner, repo = self.backend.parse_repo_url("https://DOMAIN/gitea/user/repo.git")
+        assert owner == "user"
+        assert repo == "repo"
+
+    def test_derives_api_base_subpath_hosted(self):
+        # API base must keep the path prefix so calls hit /gitea/api/v1 (#2642)
+        result = self.backend.get_api_base("https://DOMAIN/gitea/user/repo")
+        assert result == "https://DOMAIN/gitea/api/v1"
+
+    def test_derives_api_base_subpath_hosted_with_port(self):
+        result = self.backend.get_api_base("https://DOMAIN:3000/gitea/user/repo")
+        assert result == "https://DOMAIN:3000/gitea/api/v1"
+
 
 
 class TestGiteaBackendPushFiles:
 class TestGiteaBackendPushFiles:
     def setup_method(self):
     def setup_method(self):
@@ -1358,6 +1378,16 @@ class TestForgejoBackendApiBase:
         assert owner == "owner"
         assert owner == "owner"
         assert repo == "repo"
         assert repo == "repo"
 
 
+    def test_parse_url_subpath_hosted(self):
+        # Forgejo inherits GiteaBackend's subpath handling (#2642)
+        owner, repo = self.backend.parse_repo_url("https://DOMAIN/forgejo/user/repo")
+        assert owner == "user"
+        assert repo == "repo"
+
+    def test_derives_api_base_subpath_hosted(self):
+        result = self.backend.get_api_base("https://DOMAIN/forgejo/user/repo")
+        assert result == "https://DOMAIN/forgejo/api/v1"
+
 
 
 class TestForgejoTestConnection:
 class TestForgejoTestConnection:
     """ForgejoBackend overrides test_connection to handle Forgejo v15+ 404-not-403 behaviour."""
     """ForgejoBackend overrides test_connection to handle Forgejo v15+ 404-not-403 behaviour."""

+ 13 - 0
backend/tests/unit/test_launcher_shutdown_config.py

@@ -34,6 +34,19 @@ REPO = Path(__file__).resolve().parents[3]
 
 
 FLAG = "--timeout-graceful-shutdown"
 FLAG = "--timeout-graceful-shutdown"
 
 
+# These pin repo-root launcher files (Dockerfile, compose, service units,
+# install scripts) that the Docker test image deliberately does not ship —
+# Dockerfile.test copies only backend/, pyproject.toml, gcode_viewer/ and
+# requirements. In a source checkout the files are always present and the
+# guard below is live (a moved/deleted launcher still fails loudly on every
+# `test_backend.sh` run); inside the stripped test image there is nothing to
+# check, so skip rather than fail. `frontend/package.json` is present in every
+# checkout but never in the test image, so it distinguishes the two.
+pytestmark = pytest.mark.skipif(
+    not (REPO / "frontend" / "package.json").is_file(),
+    reason="launcher config files aren't shipped in the Docker test image; verified in native runs",
+)
+
 
 
 def _read(rel: str) -> str:
 def _read(rel: str) -> str:
     path = REPO / rel
     path = REPO / rel

+ 3 - 3
backend/tests/unit/test_scheduler_cancel_race.py

@@ -83,13 +83,13 @@ async def queue_factory(tmp_path):
                 printer_id=printer.id,
                 printer_id=printer.id,
                 archive_id=archive.id,
                 archive_id=archive.id,
                 status=status,
                 status=status,
-                bed_levelling=True,
-                flow_cali=False,
+                bed_levelling="on",
+                flow_cali="off",
                 vibration_cali=True,
                 vibration_cali=True,
                 layer_inspect=False,
                 layer_inspect=False,
                 timelapse=False,
                 timelapse=False,
                 use_ams=True,
                 use_ams=True,
-                nozzle_offset_cali=True,
+                nozzle_offset_cali="on",
             )
             )
             db.add(item)
             db.add(item)
             await db.commit()
             await db.commit()

+ 3 - 3
backend/tests/unit/test_scheduler_cleanup_library.py

@@ -77,13 +77,13 @@ async def queue_factory(tmp_path):
                 library_file_id=library_file.id,
                 library_file_id=library_file.id,
                 status="pending",
                 status="pending",
                 cleanup_library_after_dispatch=cleanup,
                 cleanup_library_after_dispatch=cleanup,
-                bed_levelling=True,
-                flow_cali=False,
+                bed_levelling="on",
+                flow_cali="off",
                 vibration_cali=True,
                 vibration_cali=True,
                 layer_inspect=False,
                 layer_inspect=False,
                 timelapse=False,
                 timelapse=False,
                 use_ams=True,
                 use_ams=True,
-                nozzle_offset_cali=True,
+                nozzle_offset_cali="on",
             )
             )
             db.add(item)
             db.add(item)
             await db.commit()
             await db.commit()

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

@@ -124,6 +124,27 @@ class TestBuildOverrideDirectMapping:
         # Should match by colour (#CBC6B8 ≈ CBC6B8FF after strip), not by tray_info_idx.
         # Should match by colour (#CBC6B8 ≈ CBC6B8FF after strip), not by tray_info_idx.
         assert result == [0]
         assert result == [0]
 
 
+    def test_direct_mapping_pins_variant_when_override_carries_idx(self, scheduler):
+        """#2650: the no-3MF fallback honours a force override's own tray_info_idx,
+        so two same-colour PLA variants map to the intended slot rather than the
+        first same-colour tray."""
+        status = self._status(
+            ams=[
+                {
+                    "id": 0,
+                    "tray": [
+                        {"id": 0, "tray_type": "PLA", "tray_color": "FFFFFFFF", "tray_info_idx": "GFA00"},
+                        {"id": 1, "tray_type": "PLA", "tray_color": "FFFFFFFF", "tray_info_idx": "GFA01"},
+                    ],
+                }
+            ]
+        )
+        overrides = [
+            {"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "tray_info_idx": "GFA01", "force_color_match": True}
+        ]
+        result = scheduler._build_override_direct_mapping(overrides, status)
+        assert result == [1]  # global_tray_id 1 = GFA01 (Matte), not GFA00 (Basic) at 0
+
 
 
 class TestComputeAmsMappingFallback:
 class TestComputeAmsMappingFallback:
     """Integration tests for the force-color fallback inside
     """Integration tests for the force-color fallback inside
@@ -241,3 +262,132 @@ class TestComputeAmsMappingFallback:
             result = await scheduler._compute_ams_mapping_for_printer(db, 5, item)
             result = await scheduler._compute_ams_mapping_for_printer(db, 5, item)
 
 
         assert result is None
         assert result is None
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_force_color_override_pins_the_matching_variant_slot(self, mock_pm, scheduler):
+        """#2650 slot selection: with two same-colour PLA spools of different
+        variants loaded, applying a force_color_match override must map to the
+        tray whose tray_info_idx matches the 3MF (Matte GFA01), not the first
+        same-colour tray (Basic GFA00)."""
+        mock_pm.get_status.return_value = MagicMock(
+            raw_data={
+                "ams": [
+                    {
+                        "id": 0,
+                        "tray": [
+                            {"id": 0, "tray_type": "PLA", "tray_color": "FFFFFFFF", "tray_info_idx": "GFA00"},
+                            {"id": 1, "tray_type": "PLA", "tray_color": "FFFFFFFF", "tray_info_idx": "GFA01"},
+                        ],
+                    }
+                ]
+            },
+            ams_filament_backup=None,
+        )
+        item = self._make_item(
+            filament_overrides_json=(
+                '[{"slot_id": 1, "type": "PLA", "color": "#FFFFFF", '
+                '"tray_info_idx": "GFA01", "force_color_match": true}]'
+            )
+        )
+        filament_reqs = [{"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "tray_info_idx": "GFA01"}]
+        db = AsyncMock()
+
+        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)
+
+        assert result == [1]  # global_tray_id 1 = GFA01 (Matte), not GFA00 (Basic) at 0
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_preference_override_still_clears_idx_so_a_swap_matches_by_colour(self, mock_pm, scheduler):
+        """A non-force (preference) override is a filament SWAP: its slot must
+        match by the new type+colour, never by a stale 3MF variant that would pin
+        the old spool. Only force_color_match overrides keep their idx — anything
+        else is cleared, exactly as before #2650."""
+        mock_pm.get_status.return_value = MagicMock(
+            raw_data={
+                "ams": [
+                    {
+                        "id": 0,
+                        "tray": [
+                            # The 3MF's original variant (blue GFA01) and the swapped-to red.
+                            {"id": 0, "tray_type": "PLA", "tray_color": "0000FFFF", "tray_info_idx": "GFA01"},
+                            {"id": 1, "tray_type": "PLA", "tray_color": "FF0000FF", "tray_info_idx": "GFA00"},
+                        ],
+                    }
+                ]
+            },
+            ams_filament_backup=None,
+        )
+        # 3MF wants blue GFA01; the user swaps this slot to red via a preference
+        # override that (defensively) also carries the stale GFA01 idx.
+        item = self._make_item(
+            filament_overrides_json='[{"slot_id": 1, "type": "PLA", "color": "#FF0000", "tray_info_idx": "GFA01"}]'
+        )
+        filament_reqs = [{"slot_id": 1, "type": "PLA", "color": "#0000FF", "tray_info_idx": "GFA01"}]
+        db = AsyncMock()
+
+        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)
+
+        # idx cleared → matches the swapped-to red spool (global 1), not the stale
+        # GFA01 blue (global 0) that a preserved idx would have pinned.
+        assert result == [1]
+
+
+class TestGetMissingForceColorSlotsVariant:
+    """force_color_match must distinguish Bambu PLA variants that share a base
+    type+colour but differ in tray_info_idx (Basic GFA00 / Matte GFA01 /
+    Silk GFA06), while still accepting spools that report no idx (#2650)."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    def _status(self, trays: list[dict]) -> MagicMock:
+        """One AMS unit whose trays are the given dicts (white PLA of assorted variants)."""
+        return MagicMock(raw_data={"ams": [{"id": 0, "tray": trays}]})
+
+    @staticmethod
+    def _white(idx: str) -> dict:
+        return {"id": 0, "tray_type": "PLA", "tray_color": "FFFFFFFF", "tray_info_idx": idx}
+
+    def _override(self, idx: str | None) -> list[dict]:
+        o = {"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "force_color_match": True}
+        if idx is not None:
+            o["tray_info_idx"] = idx
+        return [o]
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_matte_requirement_rejects_basic_and_silk(self, mock_pm, scheduler):
+        """A GFA01 (Matte) job is unsatisfied by a printer loaded with only
+        Basic/Silk white PLA — the core #2650 regression."""
+        mock_pm.get_status.return_value = self._status([self._white("GFA00"), self._white("GFA06")])
+        assert scheduler._get_missing_force_color_slots(5, self._override("GFA01")) == ["PLA (#FFFFFF)"]
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_matte_requirement_accepts_matte(self, mock_pm, scheduler):
+        """The correct variant being loaded satisfies the override."""
+        mock_pm.get_status.return_value = self._status([self._white("GFA00"), self._white("GFA01")])
+        assert scheduler._get_missing_force_color_slots(5, self._override("GFA01")) == []
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_blank_loaded_idx_falls_back_to_type_and_colour(self, mock_pm, scheduler):
+        """A custom/third-party spool reports a blank tray_info_idx, so it must
+        still satisfy a variant-specific requirement (type+colour fallback)."""
+        mock_pm.get_status.return_value = self._status([self._white("")])
+        assert scheduler._get_missing_force_color_slots(5, self._override("GFA01")) == []
+
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    def test_requirement_without_idx_unchanged(self, mock_pm, scheduler):
+        """An older 3MF whose override carries no idx keeps the historical
+        type+colour behaviour and matches any white PLA."""
+        mock_pm.get_status.return_value = self._status([self._white("GFA06")])
+        assert scheduler._get_missing_force_color_slots(5, self._override(None)) == []

+ 3 - 3
backend/tests/unit/test_scheduler_nozzle_mismatch.py

@@ -158,13 +158,13 @@ async def archive_case(tmp_path):
                 printer_id=printer.id,
                 printer_id=printer.id,
                 archive_id=archive.id,
                 archive_id=archive.id,
                 status="pending",
                 status="pending",
-                bed_levelling=True,
-                flow_cali=False,
+                bed_levelling="on",
+                flow_cali="off",
                 vibration_cali=True,
                 vibration_cali=True,
                 layer_inspect=False,
                 layer_inspect=False,
                 timelapse=False,
                 timelapse=False,
                 use_ams=True,
                 use_ams=True,
-                nozzle_offset_cali=True,
+                nozzle_offset_cali="on",
             )
             )
             db.add(item)
             db.add(item)
             await db.commit()
             await db.commit()

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

@@ -0,0 +1,42 @@
+"""Tests for _pick_power_plug() in the print scheduler (#2629).
+
+A printer can have several plugs linked to it: the one feeding the printer and
+accessories that merely follow the print cycle (filter fan, chamber light). Only
+the former can bring an offline printer back, so the queue's power-on step must
+pick it rather than whichever row came back first.
+"""
+
+from types import SimpleNamespace
+
+from backend.app.services.print_scheduler import PrintScheduler
+
+
+def _plug(plug_id: int, name: str, controls_printer_power: bool) -> SimpleNamespace:
+    return SimpleNamespace(id=plug_id, name=name, controls_printer_power=controls_printer_power)
+
+
+class TestPickPowerPlug:
+    def test_prefers_power_plug_over_earlier_accessory(self):
+        fan = _plug(1, "BentoBox Filter", False)
+        printer_plug = _plug(2, "P1S Power", True)
+
+        assert PrintScheduler._pick_power_plug([fan, printer_plug]) is printer_plug
+
+    def test_keeps_first_power_plug_when_several_qualify(self):
+        first = _plug(1, "P1S Power", True)
+        second = _plug(2, "Bench Power", True)
+
+        assert PrintScheduler._pick_power_plug([first, second]) is first
+
+    def test_falls_back_to_first_when_none_flagged(self):
+        """Pre-#2629 behaviour for setups where no plug is marked as the power
+        source — powering on may not work, but nothing gets worse."""
+        fan = _plug(1, "BentoBox Filter", False)
+        light = _plug(2, "Chamber Light", False)
+
+        assert PrintScheduler._pick_power_plug([fan, light]) is fan
+
+    def test_single_plug_is_returned_regardless(self):
+        only = _plug(1, "P1S Power", True)
+
+        assert PrintScheduler._pick_power_plug([only]) is only

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

@@ -155,6 +155,83 @@ class TestEnrichCloudMetadata:
         assert c["filament"][0].filament_colour == "#FFFFFF"
         assert c["filament"][0].filament_colour == "#FFFFFF"
 
 
 
 
+class TestEnrichCompatiblePrinters:
+    """#2628: the same name bridge carries ``compatible_printers`` onto the
+    tiers that don't ship one. Bambu Cloud never does — so a profile whose
+    name carries no printer model reads as "compatibility unknown", which the
+    SliceModal treats as usable and auto-picks for the wrong printer."""
+
+    COMPAT = ["Bambu Lab X1 Carbon 0.2 nozzle", "Bambu Lab P1S 0.2 nozzle"]
+
+    def _tier(self, source: str, slot: str, compat: list[str] | None) -> dict[str, list[UnifiedPreset]]:
+        empty: dict[str, list[UnifiedPreset]] = {"printer": [], "process": [], "filament": []}
+        empty[slot] = [
+            UnifiedPreset(id=f"{source}1", name="Overture PLA Matte @0.2", source=source, compatible_printers=compat)
+        ]
+        return empty
+
+    def test_bambu_cloud_borrows_the_list_from_a_same_named_local_import(self):
+        local = self._tier("local", "filament", self.COMPAT)
+        cloud = self._tier("cloud", "filament", None)
+
+        _oc, c, _l, _s = sp._enrich_cloud_metadata(_slot([]), cloud, local, _slot([]))
+
+        assert c["filament"][0].compatible_printers == self.COMPAT
+
+    def test_bambu_cloud_borrows_from_orca_cloud_too(self):
+        orca = self._tier("orca_cloud", "filament", self.COMPAT)
+        cloud = self._tier("cloud", "filament", None)
+
+        _oc, c, _l, _s = sp._enrich_cloud_metadata(orca, cloud, _slot([]), _slot([]))
+
+        assert c["filament"][0].compatible_printers == self.COMPAT
+
+    def test_orca_cloud_borrows_when_its_own_content_had_no_list(self):
+        orca = self._tier("orca_cloud", "filament", None)
+        local = self._tier("local", "filament", self.COMPAT)
+
+        oc, _c, _l, _s = sp._enrich_cloud_metadata(orca, _slot([]), local, _slot([]))
+
+        assert oc["filament"][0].compatible_printers == self.COMPAT
+
+    def test_process_slot_is_bridged_as_well(self):
+        local = self._tier("local", "process", self.COMPAT)
+        cloud = self._tier("cloud", "process", None)
+
+        _oc, c, _l, _s = sp._enrich_cloud_metadata(_slot([]), cloud, local, _slot([]))
+
+        assert c["process"][0].compatible_printers == self.COMPAT
+
+    def test_never_overwrites_a_list_the_entry_already_has(self):
+        own = ["Bambu Lab P2S 0.4 nozzle"]
+        local = self._tier("local", "filament", self.COMPAT)
+        cloud = self._tier("cloud", "filament", own)
+
+        _oc, c, _l, _s = sp._enrich_cloud_metadata(_slot([]), cloud, local, _slot([]))
+
+        assert c["filament"][0].compatible_printers == own
+
+    def test_no_donor_leaves_the_entry_unclassified(self):
+        """Absent evidence the entry must stay None — the SliceModal then
+        falls back to the name matcher instead of hiding the profile."""
+        cloud = self._tier("cloud", "filament", None)
+
+        _oc, c, _l, _s = sp._enrich_cloud_metadata(_slot([]), cloud, _slot([]), _slot([]))
+
+        assert c["filament"][0].compatible_printers is None
+
+    def test_borrowed_list_is_copied_not_shared(self):
+        """A later mutation of one tier's list must not reach through to the
+        other — these objects are cached per user between requests."""
+        local = self._tier("local", "filament", list(self.COMPAT))
+        cloud = self._tier("cloud", "filament", None)
+
+        _oc, c, l_, _s = sp._enrich_cloud_metadata(_slot([]), cloud, local, _slot([]))
+        l_["filament"][0].compatible_printers.append("Bambu Lab H2D 0.4 nozzle")
+
+        assert c["filament"][0].compatible_printers == self.COMPAT
+
+
 def _user_with_cloud_auth(user_id: int = 1) -> MagicMock:
 def _user_with_cloud_auth(user_id: int = 1) -> MagicMock:
     """Construct a mock User that passes the CLOUD_AUTH permission check.
     """Construct a mock User that passes the CLOUD_AUTH permission check.
 
 
@@ -273,6 +350,76 @@ class TestFetchOrcaCloudPresets:
         assert filament[0].filament_type == "PLA"
         assert filament[0].filament_type == "PLA"
         assert filament[0].filament_colour == "#000000"
         assert filament[0].filament_colour == "#000000"
 
 
+    @pytest.mark.asyncio
+    async def test_extracts_compatible_printers_from_content(self):
+        """#2628: Orca's sync_pull already carries the profile's own
+        compatible-printer list. Surfacing it lets the SliceModal reject a
+        profile built for another printer instead of falling back to reading
+        the model out of the NAME — which fails outright for names that carry
+        no model ("Overture PLA Matte @0.2")."""
+        sp._orca_cloud_cache.clear()
+        compat = ["Bambu Lab X1 Carbon 0.2 nozzle", "Bambu Lab P1S 0.2 nozzle"]
+        svc_mock = MagicMock()
+        svc_mock.list_profiles = AsyncMock(
+            return_value=[
+                {
+                    "id": "f1",
+                    "name": "Overture PLA Matte @0.2",
+                    "content": {
+                        "type": "filament",
+                        "filament_type": ["PLA"],
+                        "compatible_printers": compat,
+                    },
+                },
+                {
+                    "id": "p1",
+                    "name": "Orca 0.20mm",
+                    "content": {"type": "print", "compatible_printers": "Bambu Lab P2S 0.4 nozzle"},
+                },
+                {"id": "m1", "name": "Orca X1C", "content": {"type": "printer"}},
+            ]
+        )
+        svc_mock.close = AsyncMock()
+        user = MagicMock(id=1)
+        user.has_permission = MagicMock(return_value=True)
+        with (
+            patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
+            patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)),
+        ):
+            slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
+
+        assert status == "ok"
+        assert slots["filament"][0].compatible_printers == compat
+        # A single-printer profile may store a bare string — normalised to a list.
+        assert slots["process"][0].compatible_printers == ["Bambu Lab P2S 0.4 nozzle"]
+        # Printer presets have nothing to be compatible with.
+        assert slots["printer"][0].compatible_printers is None
+
+    @pytest.mark.asyncio
+    async def test_missing_or_malformed_compatible_printers_stays_none(self):
+        """No data must read as "unknown", never as "compatible with nothing" —
+        the SliceModal falls back to the name matcher for those."""
+        sp._orca_cloud_cache.clear()
+        svc_mock = MagicMock()
+        svc_mock.list_profiles = AsyncMock(
+            return_value=[
+                {"id": "f1", "name": "No list", "content": {"type": "filament"}},
+                {"id": "f2", "name": "Empty list", "content": {"type": "filament", "compatible_printers": []}},
+                {"id": "f3", "name": "Blanks", "content": {"type": "filament", "compatible_printers": ["", "  "]}},
+                {"id": "f4", "name": "Wrong type", "content": {"type": "filament", "compatible_printers": {"a": 1}}},
+            ]
+        )
+        svc_mock.close = AsyncMock()
+        user = MagicMock(id=1)
+        user.has_permission = MagicMock(return_value=True)
+        with (
+            patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
+            patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)),
+        ):
+            slots, _status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
+
+        assert [p.compatible_printers for p in slots["filament"]] == [None, None, None, None]
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_cache_hit_skips_orca_call(self):
     async def test_cache_hit_skips_orca_call(self):
         """A second call within TTL must reuse the cached slots and NOT
         """A second call within TTL must reuse the cached slots and NOT

+ 184 - 0
backend/tests/unit/test_smart_plug_power_flag_migration_2629.py

@@ -0,0 +1,184 @@
+"""Migration test for #2629 — smart_plugs.controls_printer_power.
+
+Existing installs have plugs that were assumed to power their linked printer, so
+the new column must be added *and backfilled to true*: a NULL or false backfill
+would silently stop marking a real printer plug's power-off, which is the
+behaviour users have today.
+"""
+
+from __future__ import annotations
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from backend.app.core.database import run_migrations
+
+LEGACY_SMART_PLUGS = """
+CREATE TABLE smart_plugs (
+    id INTEGER PRIMARY KEY,
+    name VARCHAR(100) NOT NULL,
+    ip_address VARCHAR(45),
+    plug_type VARCHAR(20) DEFAULT 'tasmota',
+    ha_entity_id VARCHAR(100),
+    printer_id INTEGER,
+    enabled BOOLEAN DEFAULT 1,
+    auto_on BOOLEAN DEFAULT 1,
+    auto_off BOOLEAN DEFAULT 1,
+    auto_off_persistent BOOLEAN DEFAULT 0,
+    off_delay_mode VARCHAR(20) DEFAULT 'time',
+    off_delay_minutes INTEGER DEFAULT 5,
+    off_temp_threshold INTEGER DEFAULT 70,
+    show_in_switchbar BOOLEAN DEFAULT 0,
+    show_on_printer_card BOOLEAN DEFAULT 1,
+    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+)
+"""
+
+
+@pytest.fixture(autouse=True)
+def force_sqlite_dialect(monkeypatch):
+    """settings.database_url may point at Postgres in dev configs; the test engine
+    is SQLite, so force the dialect both places run_migrations reads it from."""
+    from backend.app.core import database as database_module, db_dialect
+
+    monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
+    monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
+    monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
+
+
+@pytest.fixture
+async def legacy_engine():
+    """A modern schema with a pre-#2629 smart_plugs table holding one plug."""
+    from backend.app.core.database import Base
+    from backend.app.models import (  # noqa: F401
+        ams_history,
+        ams_label,
+        api_key,
+        archive,
+        color_catalog,
+        external_link,
+        filament,
+        group,
+        kprofile_note,
+        maintenance,
+        notification,
+        notification_template,
+        print_log,
+        print_queue,
+        printer,
+        project,
+        project_bom,
+        settings,
+        slot_preset,
+        smart_plug,
+        smart_plug_energy_snapshot,
+        spool,
+        spool_assignment,
+        spool_catalog,
+        spool_k_profile,
+        spool_usage_history,
+        spoolbuddy_device,
+        user,
+        user_email_pref,
+        virtual_printer,
+    )
+
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+        await conn.execute(text("DROP TABLE smart_plugs"))
+        await conn.execute(text(LEGACY_SMART_PLUGS))
+        await conn.execute(
+            text("INSERT INTO smart_plugs (id, name, plug_type, printer_id) VALUES (1, 'P1S Power', 'tasmota', 1)")
+        )
+    yield engine
+    await engine.dispose()
+
+
+async def test_column_missing_before_migration(legacy_engine):
+    """Sanity check so the assertion below can't pass by accident."""
+    async with legacy_engine.begin() as conn:
+        columns = {row[1] for row in await conn.execute(text("PRAGMA table_info(smart_plugs)"))}
+    assert "controls_printer_power" not in columns
+
+
+async def test_existing_plugs_backfill_to_true(legacy_engine):
+    """An upgraded install must keep marking its printer offline on power-off."""
+    async with legacy_engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with legacy_engine.begin() as conn:
+        result = await conn.execute(text("SELECT controls_printer_power FROM smart_plugs WHERE id = 1"))
+        assert bool(result.scalar_one()) is True
+
+
+async def test_migration_is_idempotent(legacy_engine):
+    """Second boot must not fail on the already-present column."""
+    async with legacy_engine.begin() as conn:
+        await run_migrations(conn)
+    async with legacy_engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with legacy_engine.begin() as conn:
+        result = await conn.execute(text("SELECT controls_printer_power FROM smart_plugs WHERE id = 1"))
+        assert bool(result.scalar_one()) is True
+
+
+class TestPostgresBranch:
+    """CI runs on SQLite, so the Postgres branch of the dialect switch would be
+    dead code without this. Captures the SQL ``run_migrations`` would emit,
+    mirroring ``test_oidc_icon_migration_pg.py``.
+    """
+
+    @staticmethod
+    async def _capture_sql(is_sqlite_value: bool) -> list[str]:
+        from unittest.mock import AsyncMock, MagicMock, patch
+
+        from backend.app.core import database as db_module
+
+        class _AsyncCtxStub:
+            async def __aenter__(self):
+                return self
+
+            async def __aexit__(self, *_exc):
+                return False
+
+        executed_sql: list[str] = []
+
+        async def fake_safe_execute(_conn, sql: str) -> None:
+            executed_sql.append(sql)
+
+        fake_conn = MagicMock()
+        fake_conn.begin_nested = lambda: _AsyncCtxStub()
+        fake_conn.execute = AsyncMock(return_value=MagicMock(fetchone=MagicMock(return_value=None)))
+
+        with (
+            patch("backend.app.core.database.is_sqlite", return_value=is_sqlite_value),
+            patch("backend.app.core.database._safe_execute", side_effect=fake_safe_execute),
+            patch("backend.app.core.database._migrate_update_auto_link_constraint", AsyncMock()),
+            patch("backend.app.core.database._migrate_widen_spoolman_slot_ams_id_range", AsyncMock()),
+        ):
+            await db_module.run_migrations(fake_conn)
+
+        return executed_sql
+
+    @pytest.mark.asyncio
+    async def test_pg_branch_uses_true_and_if_not_exists(self):
+        executed = await self._capture_sql(is_sqlite_value=False)
+        stmts = [s for s in executed if "controls_printer_power" in s]
+
+        assert len(stmts) == 1, f"expected exactly one statement, got: {stmts!r}"
+        assert "IF NOT EXISTS" in stmts[0]  # idempotent on PG, which has no _safe_execute retry semantics
+        assert "DEFAULT true" in stmts[0]
+
+    @pytest.mark.asyncio
+    async def test_sqlite_branch_uses_numeric_default(self):
+        """SQLite has no true/false literal — the switch must not be inverted."""
+        executed = await self._capture_sql(is_sqlite_value=True)
+        stmts = [s for s in executed if "controls_printer_power" in s]
+
+        assert len(stmts) == 1
+        assert "DEFAULT 1" in stmts[0]
+        assert "true" not in stmts[0]

+ 49 - 0
backend/tests/unit/test_support_helpers.py

@@ -330,6 +330,55 @@ class TestSanitizeLogContent:
         assert "/home/[user]/" in result
         assert "/home/[user]/" in result
         assert "[IP]" in result
         assert "[IP]" in result
 
 
+    def test_ldap_dn_redacted_reporter_line(self):
+        """#2681: the exact reporter line — the CN (real name) must not survive."""
+        from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
+
+        content = (
+            "LDAP authentication successful for user: jschmoe "
+            "(DN: CN=Joe Schmoe,CN=Users,DC=ad,DC=example,DC=com, groups: 4)"
+        )
+        result = _sanitize_log_content(content)
+        assert "Joe Schmoe" not in result
+        assert "DC=example" not in result
+        assert result == "LDAP authentication successful for user: jschmoe (DN: [DN], groups: 4)"
+
+    def test_ldap_dn_redacted_in_exception_string(self):
+        """DNs that leak indirectly via ldap3 exception text are caught too."""
+        from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
+
+        content = "LDAP bind failed for user jschmoe: invalidCredentials at uid=jschmoe,ou=people,dc=example,dc=org"
+        result = _sanitize_log_content(content)
+        assert "uid=jschmoe" not in result
+        assert "[DN]" in result
+
+    def test_ldap_group_dn_redacted(self):
+        """Group DNs (from group-mapping logs) are PII-bearing and redacted."""
+        from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
+
+        content = "Mapped CN=Admins,OU=Groups,DC=corp,DC=local -> Administrators"
+        result = _sanitize_log_content(content)
+        assert "CN=Admins" not in result
+        assert "DC=corp" not in result
+        assert "[DN]" in result
+        assert "Administrators" in result  # the non-PII target group name survives
+
+    def test_non_dn_key_value_line_not_clobbered(self):
+        """An ordinary key=value log line must not be mistaken for a DN."""
+        from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
+
+        content = "Dispatch decision: mode=queue, state=FINISH, printer=1"
+        result = _sanitize_log_content(content)
+        assert result == content
+
+    def test_single_rdn_not_redacted(self):
+        """A lone attr=value (not a multi-component DN) is left alone."""
+        from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
+
+        content = "Country C=US selected"
+        result = _sanitize_log_content(content)
+        assert result == content
+
 
 
 class TestCollectSupportInfo:
 class TestCollectSupportInfo:
     """Tests for _collect_support_info() new diagnostic sections."""
     """Tests for _collect_support_info() new diagnostic sections."""

+ 11 - 0
backend/tests/unit/test_systemd_backup_paths.py

@@ -20,6 +20,17 @@ REPO = Path(__file__).resolve().parents[3]
 
 
 INSTALLERS = ["install/install.sh", "spoolbuddy/install/install.sh"]
 INSTALLERS = ["install/install.sh", "spoolbuddy/install/install.sh"]
 
 
+# The service unit + install scripts these tests read live at the repo root and
+# are not copied into the Docker test image (Dockerfile.test ships only backend/,
+# pyproject.toml, gcode_viewer/ and requirements). In a source checkout they are
+# always present and the guard below is live; in the stripped test image there is
+# nothing to check, so skip rather than fail. `frontend/package.json` exists in
+# every checkout but never in the test image, so it distinguishes the two.
+pytestmark = pytest.mark.skipif(
+    not (REPO / "frontend" / "package.json").is_file(),
+    reason="launcher config files aren't shipped in the Docker test image; verified in native runs",
+)
+
 
 
 def _read(rel: str) -> str:
 def _read(rel: str) -> str:
     path = REPO / rel
     path = REPO / rel

+ 35 - 32
backend/tests/unit/test_vp_mqtt_bridge.py

@@ -845,46 +845,49 @@ class TestPushStatusCache:
         await bridge.stop()
         await bridge.stop()
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
-    async def test_tray_exist_bits_skips_ams_ht_units(self):
-        """AMS-HT units (id >= 128) use a separate addressing scheme and
-        must not be touched by the bitmask cleanup — bit math at
-        global_bit = ams_id * 4 + tray_id would overrun normal AMS bits.
-        Pin the skip so future AMS-HT support doesn't accidentally wipe
-        loaded HT slots.
+    async def test_tray_exist_bits_clears_empty_ams_ht_unit(self):
+        """AMS-HT (id 128-135) presence rides bit 16+(ams_id-128), so the bridge
+        cache clears an empty HT slot just like the internal AMS card — keeping
+        the slicer-facing view in sync (#1726, #2670). Loaded (bit 16 set) is
+        preserved; empty (bit 16 clear) is wiped.
         """
         """
         server = _make_server()
         server = _make_server()
         bridge = _make_bridge(server)
         bridge = _make_bridge(server)
         await bridge.start()
         await bridge.start()
 
 
-        bridge._on_printer_raw(
-            f"device/{H2D_SERIAL}/report",
-            json.dumps(
-                {
-                    "print": {
-                        "command": "push_status",
-                        "ams": {
-                            "ams": [
-                                {
-                                    "id": "128",
-                                    "tray": [
-                                        {"id": "0", "tray_type": "PLA", "tray_color": "FF0000FF"},
-                                    ],
-                                }
-                            ],
-                            "tray_exist_bits": "0",
-                            "power_on_flag": True,
-                        },
+        def _push(tray_exist_bits: str) -> dict:
+            bridge._on_printer_raw(
+                f"device/{H2D_SERIAL}/report",
+                json.dumps(
+                    {
+                        "print": {
+                            "command": "push_status",
+                            "ams": {
+                                "ams": [
+                                    {
+                                        "id": "128",
+                                        "tray": [
+                                            {"id": "0", "tray_type": "PLA", "tray_color": "FF0000FF"},
+                                        ],
+                                    }
+                                ],
+                                "tray_exist_bits": tray_exist_bits,
+                                "power_on_flag": True,
+                            },
+                        }
                     }
                     }
-                }
-            ).encode(),
-        )
+                ).encode(),
+            )
+
+        # Loaded: bit 16 set → HT slot preserved.
+        _push("10000")
         await asyncio.sleep(0.01)
         await asyncio.sleep(0.01)
+        assert bridge.get_latest_print_state()["ams"]["ams"][0]["tray"][0]["tray_type"] == "PLA"
 
 
-        cached = bridge.get_latest_print_state()
-        ht_slot = cached["ams"]["ams"][0]["tray"][0]
-        # tray_exist_bits="0" alone would normally wipe — but AMS-HT is
-        # skipped, so the HT slot keeps its loaded data.
-        assert ht_slot["tray_type"] == "PLA"
+        # Empty: bit 16 clear → HT slot wiped so the slicer sees no phantom spool.
+        _push("0")
+        await asyncio.sleep(0.01)
+        assert bridge.get_latest_print_state()["ams"]["ams"][0]["tray"][0]["tray_type"] == ""
 
 
         await bridge.stop()
         await bridge.stop()
 
 

+ 29 - 33
frontend/package-lock.json

@@ -33,7 +33,7 @@
         "react-dom": "^19.2.0",
         "react-dom": "^19.2.0",
         "react-i18next": "^16.3.5",
         "react-i18next": "^16.3.5",
         "react-markdown": "^9.1.0",
         "react-markdown": "^9.1.0",
-        "react-router-dom": "^7.16.0",
+        "react-router-dom": "7.18.1",
         "react-simple-keyboard": "^3.8.164",
         "react-simple-keyboard": "^3.8.164",
         "recharts": "^3.5.1",
         "recharts": "^3.5.1",
         "remark-gfm": "^4.0.1",
         "remark-gfm": "^4.0.1",
@@ -3615,16 +3615,15 @@
       }
       }
     },
     },
     "node_modules/brace-expansion": {
     "node_modules/brace-expansion": {
-      "version": "5.0.6",
-      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
-      "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
+      "version": "5.0.8",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
+      "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
       "dev": true,
       "dev": true,
-      "license": "MIT",
       "dependencies": {
       "dependencies": {
         "balanced-match": "^4.0.2"
         "balanced-match": "^4.0.2"
       },
       },
       "engines": {
       "engines": {
-        "node": "18 || 20 || >=22"
+        "node": "20 || >=22"
       }
       }
     },
     },
     "node_modules/browserslist": {
     "node_modules/browserslist": {
@@ -4221,9 +4220,9 @@
       "peer": true
       "peer": true
     },
     },
     "node_modules/dompurify": {
     "node_modules/dompurify": {
-      "version": "3.4.11",
-      "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
-      "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
+      "version": "3.4.12",
+      "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
+      "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
       "optionalDependencies": {
       "optionalDependencies": {
         "@types/trusted-types": "^2.0.7"
         "@types/trusted-types": "^2.0.7"
       }
       }
@@ -5399,9 +5398,9 @@
       "license": "MIT"
       "license": "MIT"
     },
     },
     "node_modules/js-yaml": {
     "node_modules/js-yaml": {
-      "version": "4.2.0",
-      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
-      "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
+      "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
       "dev": true,
       "dev": true,
       "funding": [
       "funding": [
         {
         {
@@ -5819,9 +5818,9 @@
       "license": "MIT"
       "license": "MIT"
     },
     },
     "node_modules/linkify-it": {
     "node_modules/linkify-it": {
-      "version": "5.0.1",
-      "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz",
-      "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==",
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz",
+      "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==",
       "funding": [
       "funding": [
         {
         {
           "type": "github",
           "type": "github",
@@ -6966,9 +6965,9 @@
       }
       }
     },
     },
     "node_modules/nanoid": {
     "node_modules/nanoid": {
-      "version": "3.3.12",
-      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
-      "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+      "version": "3.3.16",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+      "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
       "dev": true,
       "dev": true,
       "funding": [
       "funding": [
         {
         {
@@ -7186,9 +7185,9 @@
       }
       }
     },
     },
     "node_modules/postcss": {
     "node_modules/postcss": {
-      "version": "8.5.15",
-      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
-      "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+      "version": "8.5.23",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
+      "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
       "dev": true,
       "dev": true,
       "funding": [
       "funding": [
         {
         {
@@ -7205,7 +7204,7 @@
         }
         }
       ],
       ],
       "dependencies": {
       "dependencies": {
-        "nanoid": "^3.3.12",
+        "nanoid": "^3.3.16",
         "picocolors": "^1.1.1",
         "picocolors": "^1.1.1",
         "source-map-js": "^1.2.1"
         "source-map-js": "^1.2.1"
       },
       },
@@ -7620,10 +7619,9 @@
       }
       }
     },
     },
     "node_modules/react-router": {
     "node_modules/react-router": {
-      "version": "7.16.0",
-      "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.16.0.tgz",
-      "integrity": "sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==",
-      "license": "MIT",
+      "version": "7.18.1",
+      "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
+      "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
       "dependencies": {
       "dependencies": {
         "cookie": "^1.0.1",
         "cookie": "^1.0.1",
         "set-cookie-parser": "^2.6.0"
         "set-cookie-parser": "^2.6.0"
@@ -7642,12 +7640,11 @@
       }
       }
     },
     },
     "node_modules/react-router-dom": {
     "node_modules/react-router-dom": {
-      "version": "7.16.0",
-      "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.16.0.tgz",
-      "integrity": "sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA==",
-      "license": "MIT",
+      "version": "7.18.1",
+      "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
+      "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
       "dependencies": {
       "dependencies": {
-        "react-router": "7.16.0"
+        "react-router": "7.18.1"
       },
       },
       "engines": {
       "engines": {
         "node": ">=20.0.0"
         "node": ">=20.0.0"
@@ -7931,8 +7928,7 @@
     "node_modules/set-cookie-parser": {
     "node_modules/set-cookie-parser": {
       "version": "2.7.2",
       "version": "2.7.2",
       "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
       "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
-      "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
-      "license": "MIT"
+      "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
     },
     },
     "node_modules/setimmediate": {
     "node_modules/setimmediate": {
       "version": "1.0.5",
       "version": "1.0.5",

+ 5 - 2
frontend/package.json

@@ -40,14 +40,17 @@
     "react-dom": "^19.2.0",
     "react-dom": "^19.2.0",
     "react-i18next": "^16.3.5",
     "react-i18next": "^16.3.5",
     "react-markdown": "^9.1.0",
     "react-markdown": "^9.1.0",
-    "react-router-dom": "^7.16.0",
+    "react-router-dom": "7.18.1",
     "react-simple-keyboard": "^3.8.164",
     "react-simple-keyboard": "^3.8.164",
     "recharts": "^3.5.1",
     "recharts": "^3.5.1",
     "remark-gfm": "^4.0.1",
     "remark-gfm": "^4.0.1",
     "three": "^0.181.2"
     "three": "^0.181.2"
   },
   },
   "overrides": {
   "overrides": {
-    "minimatch": "^10.2.1"
+    "minimatch": "^10.2.1",
+    "brace-expansion": "^5.0.8",
+    "js-yaml": "^4.3.0",
+    "react-router": "7.18.1"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@eslint/js": "^9.39.1",
     "@eslint/js": "^9.39.1",

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

@@ -142,6 +142,7 @@ function isAlwaysAllowedIdentical(value) {
 // UI labels are identical in DE. List below curates the legitimate ones.
 // UI labels are identical in DE. List below curates the legitimate ones.
 const DE_COGNATES = [
 const DE_COGNATES = [
   '{{ams}} · Slot {{slot}}',  // #2587 runout slot label — "Slot" is the DE term too
   '{{ams}} · Slot {{slot}}',  // #2587 runout slot label — "Slot" is the DE term too
+  'Auto',  // calibrationMode_auto — German UI uses the loanword (matches BambuStudio DE)
   'Name', 'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Modus',
   'Name', 'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Modus',
   'Stop', 'Reset', 'Test', 'Code', 'Token', 'Server', 'Port', 'Bug', 'Job',
   'Stop', 'Reset', 'Test', 'Code', 'Token', 'Server', 'Port', 'Bug', 'Job',
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale

+ 124 - 0
frontend/src/__tests__/components/FilamentHoverCard.test.tsx

@@ -326,6 +326,93 @@ describe('FilamentHoverCard', () => {
       });
       });
     });
     });
   });
   });
+
+  // The card is portaled at z-[60] — above ConfigureAmsSlotModal and
+  // LinkSpoolModal at z-50 — so a card left standing draws OVER the dialog its
+  // own button just opened. Mouseleave is the only thing that used to hide it,
+  // and a touch device never sends one after the tap that opened the card, so on
+  // a tablet it hung there indefinitely: two overlapping layers, competing focus.
+  describe('dismissal when an action opens a dialog (#2631)', () => {
+    it('closes the card when Configure is pressed, and still configures', async () => {
+      const onConfigure = vi.fn();
+      renderWithHover(
+        <FilamentHoverCard
+          data={baseFilamentData}
+          configureSlot={{ enabled: true, onConfigure }}
+        >
+          <div>trigger</div>
+        </FilamentHoverCard>
+      );
+      vi.advanceTimersByTime(100);
+      await waitFor(() => expect(screen.getByText(/configure/i)).toBeInTheDocument());
+
+      fireEvent.click(screen.getByText(/configure/i));
+
+      expect(onConfigure).toHaveBeenCalledTimes(1);
+      await waitFor(() => expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument());
+    });
+
+    it('stays closed with no mouseleave, which is all a tablet ever gives us', async () => {
+      renderWithHover(
+        <FilamentHoverCard
+          data={baseFilamentData}
+          configureSlot={{ enabled: true, onConfigure: vi.fn() }}
+        >
+          <div>trigger</div>
+        </FilamentHoverCard>
+      );
+      vi.advanceTimersByTime(100);
+      await waitFor(() => expect(screen.getByText(/configure/i)).toBeInTheDocument());
+
+      fireEvent.click(screen.getByText(/configure/i));
+      await waitFor(() => expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument());
+
+      // A pending show timer would resurrect the card on top of the dialog.
+      vi.advanceTimersByTime(1000);
+      expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument();
+    });
+
+    it('closes the card when Assign Spool is pressed', async () => {
+      const onAssignSpool = vi.fn();
+      renderWithHover(
+        <FilamentHoverCard
+          data={baseFilamentData}
+          inventory={{ assignedSpool: null, onAssignSpool }}
+        >
+          <div>trigger</div>
+        </FilamentHoverCard>
+      );
+      vi.advanceTimersByTime(100);
+      await waitFor(() => expect(screen.getByText(/assign/i)).toBeInTheDocument());
+
+      fireEvent.click(screen.getByText(/assign/i));
+
+      expect(onAssignSpool).toHaveBeenCalledTimes(1);
+      await waitFor(() => expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument());
+    });
+
+    it('closes the card when Unassign Spool is pressed', async () => {
+      const onUnassignSpool = vi.fn();
+      renderWithHover(
+        <FilamentHoverCard
+          data={baseFilamentData}
+          inventory={{
+            assignedSpool: { id: 7, material: 'PLA', brand: 'eSun', color_name: 'Black' },
+            onUnassignSpool,
+          }}
+        >
+          <div>trigger</div>
+        </FilamentHoverCard>
+      );
+      vi.advanceTimersByTime(100);
+      await waitFor(() => expect(screen.getByText(/unassign/i)).toBeInTheDocument());
+
+      fireEvent.click(screen.getByText(/unassign/i));
+
+      expect(onUnassignSpool).toHaveBeenCalledTimes(1);
+      await waitFor(() => expect(screen.queryByText('PLA Basic')).not.toBeInTheDocument());
+    });
+  });
 });
 });
 
 
 // EmptySlotHoverCard is the hover wrapper rendered for a physically empty
 // EmptySlotHoverCard is the hover wrapper rendered for a physically empty
@@ -397,4 +484,41 @@ describe('EmptySlotHoverCard (#1133)', () => {
     fireEvent.click(screen.getByText(/assign spool/i));
     fireEvent.click(screen.getByText(/assign spool/i));
     expect(onAssign).toHaveBeenCalledTimes(1);
     expect(onAssign).toHaveBeenCalledTimes(1);
   });
   });
+
+  // Same z-[60]-over-a-z-50-dialog problem as FilamentHoverCard (#2631).
+  describe('dismissal when an action opens a dialog (#2631)', () => {
+    it('closes the card when Configure is pressed, and still configures', async () => {
+      const onConfigure = vi.fn();
+      const result = render(
+        <EmptySlotHoverCard configureSlot={{ enabled: true, onConfigure }}>
+          <div>trigger</div>
+        </EmptySlotHoverCard>
+      );
+      fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
+      vi.advanceTimersByTime(100);
+      await waitFor(() => expect(screen.getByText(/configure/i)).toBeInTheDocument());
+
+      fireEvent.click(screen.getByText(/configure/i));
+
+      expect(onConfigure).toHaveBeenCalledTimes(1);
+      await waitFor(() => expect(screen.queryByText(/empty/i)).not.toBeInTheDocument());
+    });
+
+    it('closes the card when Assign Spool is pressed', async () => {
+      const onAssign = vi.fn();
+      const result = render(
+        <EmptySlotHoverCard onAssignSpool={onAssign}>
+          <div>trigger</div>
+        </EmptySlotHoverCard>
+      );
+      fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
+      vi.advanceTimersByTime(100);
+      await waitFor(() => expect(screen.getByText(/assign spool/i)).toBeInTheDocument());
+
+      fireEvent.click(screen.getByText(/assign spool/i));
+
+      expect(onAssign).toHaveBeenCalledTimes(1);
+      await waitFor(() => expect(screen.queryByText(/empty/i)).not.toBeInTheDocument());
+    });
+  });
 });
 });

+ 43 - 0
frontend/src/__tests__/components/FilamentMapping.test.tsx

@@ -297,4 +297,47 @@ describe('FilamentMapping — FTS routing', () => {
       expect(swatch).toBeInTheDocument();
       expect(swatch).toBeInTheDocument();
     });
     });
   });
   });
+
+  it('pins the gram usage so a long name cannot clip it (#2669)', async () => {
+    // Long resolved name + gram usage. The name must be the truncating
+    // element; the "(25g)" must sit in its own non-truncating, shrink-0 span
+    // so it stays visible on narrow/mobile widths.
+    server.use(
+      http.get('/api/v1/printers/:id/status', () => HttpResponse.json(createStatus({}))),
+      http.get('/api/v1/cloud/builtin-filaments', () =>
+        HttpResponse.json([{ filament_id: 'GFA01', name: 'Polymaker PLA Matte' }]),
+      ),
+      http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
+      http.get('/api/v1/inventory/colors/by-material', () => HttpResponse.json({ color_name: null })),
+    );
+
+    render(
+      <FilamentMapping
+        printerId={1}
+        filamentReqs={{
+          filaments: [
+            { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 25, used_meters: 8.5, nozzle_id: 1, tray_info_idx: 'GFA01' },
+          ],
+        }}
+        manualMappings={{}}
+        onManualMappingChange={() => {}}
+        currencySymbol="$"
+        defaultCostPerKg={0}
+        defaultExpanded
+      />,
+    );
+
+    const grams = await screen.findByText('(25g)');
+    // The gram usage never truncates and never shrinks away.
+    expect(grams.className).toContain('shrink-0');
+    expect(grams.className).not.toContain('truncate');
+
+    // The name is the element that truncates instead.
+    const name = await screen.findByText('Polymaker PLA Matte');
+    expect(name.className).toContain('truncate');
+    // Name and grams are separate siblings, so the name shrinking can't take
+    // the grams with it.
+    expect(name).not.toBe(grams);
+    expect(grams.parentElement).toBe(name.parentElement);
+  });
 });
 });

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

@@ -7,6 +7,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
 import { screen, fireEvent, waitFor } from '@testing-library/react';
 import { screen, fireEvent, waitFor } from '@testing-library/react';
 import { render } from '../utils';
 import { render } from '../utils';
 import { ModelViewerModal } from '../../components/ModelViewerModal';
 import { ModelViewerModal } from '../../components/ModelViewerModal';
+import { setStreamToken } from '../../api/client';
 import { http, HttpResponse } from 'msw';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
 import { server } from '../mocks/server';
 
 
@@ -385,6 +386,33 @@ describe('ModelViewerModal', () => {
       });
       });
     });
     });
 
 
+    // #2661: plate-thumbnail endpoints are gated behind a camera stream token
+    // (an <img> can't send a Bearer header), so the src must carry ?token=.
+    // Without it the 3D Preview thumbnails 401 while the Slice dialog (which
+    // already appends the token) shows the same file's thumbnails fine.
+    it('appends the camera stream token to plate thumbnail URLs', async () => {
+      setStreamToken('tok-2661');
+      try {
+        render(
+          <ModelViewerModal
+            archiveId={1}
+            title="Test Model"
+            onClose={mockOnClose}
+          />
+        );
+
+        await waitFor(() => {
+          expect(screen.getByText('Plate 1')).toBeInTheDocument();
+        });
+
+        const thumb = screen.getByAltText('Plate 1') as HTMLImageElement;
+        expect(thumb.src).toContain('/api/v1/archives/1/plates/1/thumbnail');
+        expect(thumb.src).toContain('token=tok-2661');
+      } finally {
+        setStreamToken(null);
+      }
+    });
+
     it('allows plate selection via click', async () => {
     it('allows plate selection via click', async () => {
       render(
       render(
         <ModelViewerModal
         <ModelViewerModal

+ 2 - 2
frontend/src/__tests__/components/PrintModal.test.tsx

@@ -39,8 +39,8 @@ const createMockQueueItem = (overrides: Partial<PrintQueueItem> = {}): PrintQueu
   manual_start: false,
   manual_start: false,
   ams_mapping: null,
   ams_mapping: null,
   plate_id: null,
   plate_id: null,
-  bed_levelling: true,
-  flow_cali: false,
+  bed_levelling: 'on',
+  flow_cali: 'off',
   vibration_cali: true,
   vibration_cali: true,
   layer_inspect: false,
   layer_inspect: false,
   timelapse: false,
   timelapse: false,

+ 67 - 0
frontend/src/__tests__/components/SkipObjectsModal.test.ts

@@ -0,0 +1,67 @@
+import { describe, expect, it } from 'vitest';
+import { pickObjectIdAt, plateClickToMaskPoint } from '../../utils/skipObjects';
+
+function imageData(width: number, height: number, pixels: number[]): ImageData {
+  return {
+    width,
+    height,
+    data: new Uint8ClampedArray(pixels),
+    colorSpace: 'srgb',
+  } as ImageData;
+}
+
+describe('pickObjectIdAt', () => {
+  it('decodes the slicer object ID from RGB channels', () => {
+    const pick = imageData(2, 1, [
+      0, 0, 0, 0,
+      52, 18, 1, 255,
+    ]);
+
+    expect(pickObjectIdAt(pick, 1, 0)).toBe(1 * 65536 + 18 * 256 + 52);
+  });
+
+  it('treats transparent and black pixels as empty plate space', () => {
+    const pick = imageData(2, 1, [
+      8, 0, 0, 0,
+      0, 0, 0, 255,
+    ]);
+
+    expect(pickObjectIdAt(pick, 0, 0)).toBeNull();
+    expect(pickObjectIdAt(pick, 1, 0)).toBeNull();
+  });
+
+  it('clamps click coordinates to the image bounds', () => {
+    const pick = imageData(1, 1, [63, 0, 0, 255]);
+
+    expect(pickObjectIdAt(pick, 99, -4)).toBe(63);
+  });
+});
+
+describe('plateClickToMaskPoint', () => {
+  const square = { left: 100, top: 50, width: 400, height: 400 };
+
+  it('maps a click through the display scale when the mask fills the box', () => {
+    // 400px box, 200px mask: the centre of the box is the centre of the mask.
+    expect(plateClickToMaskPoint(square, 200, 200, 300, 250)).toEqual({ x: 100, y: 100 });
+  });
+
+  it('offsets by the letterbox bars when the mask is not square', () => {
+    // A 200x100 mask in a 400x400 box renders 400x200, leaving 100px bars top
+    // and bottom. Without that offset this click would read 100px too low.
+    expect(plateClickToMaskPoint(square, 200, 100, 300, 250)).toEqual({ x: 100, y: 50 });
+  });
+
+  it('rejects clicks on a letterbox bar rather than clamping onto an edge object', () => {
+    expect(plateClickToMaskPoint(square, 200, 100, 300, 100)).toBeNull();
+    expect(plateClickToMaskPoint(square, 200, 100, 300, 400)).toBeNull();
+  });
+
+  it('rejects clicks outside the plate box', () => {
+    expect(plateClickToMaskPoint(square, 200, 200, 90, 250)).toBeNull();
+    expect(plateClickToMaskPoint(square, 200, 200, 300, 460)).toBeNull();
+  });
+
+  it('returns null for a collapsed box instead of dividing by zero', () => {
+    expect(plateClickToMaskPoint({ left: 0, top: 0, width: 0, height: 0 }, 200, 200, 0, 0)).toBeNull();
+  });
+});

+ 80 - 0
frontend/src/__tests__/components/SliceModal.test.tsx

@@ -1348,3 +1348,83 @@ describe('pickFilamentForSlot — printer-compat contract (#1851)', () => {
     expect(pick).toEqual({ source: 'standard', id: 'Generic PLA @BBL H2C' });
     expect(pick).toEqual({ source: 'standard', id: 'Generic PLA @BBL H2C' });
   });
   });
 });
 });
+
+describe('pickFilamentForSlot — long-form printer tag (#2628)', () => {
+  const index = buildCompatibilityIndex({
+    'Bambu Lab A1': 'A1',
+    'Bambu Lab H2D': 'H2D',
+  });
+
+  it('never auto-picks a user-saved preset scoped to another printer', () => {
+    // michaelklos's registry: a cloud-tier user preset carrying the full
+    // "@Bambu Lab H2D 0.4 nozzle" tag outscores the A1 preset on tier bonus
+    // alone. Until the matcher learned the long form it classified 'unknown'
+    // — indistinguishable from compatible — so it won the slot, landed in a
+    // dropdown the modal disables (slot not used by the plate), and the CLI
+    // rejected the whole slice.
+    const presets = makeUnified({
+      cloud: {
+        printer: [],
+        process: [],
+        filament: [
+          {
+            id: 'sunlu-tpu-h2d',
+            name: 'SUNLU TPU 95A @Bambu Lab H2D 0.4 nozzle',
+            source: 'cloud',
+            filament_type: 'PLA',
+            filament_colour: '#FF0000',
+          },
+        ],
+      },
+      standard: {
+        printer: [],
+        process: [],
+        filament: [
+          {
+            id: 'Bambu PLA Basic @BBL A1',
+            name: 'Bambu PLA Basic @BBL A1',
+            source: 'standard',
+            filament_type: 'PLA',
+            filament_colour: '#FFFFFF',
+          },
+        ],
+      },
+    });
+
+    const pick = pickFilamentForSlot(
+      presets,
+      { type: 'PLA', color: '#FF0000' },
+      'Bambu Lab A1 0.4 nozzle',
+      index,
+    );
+
+    expect(pick).toEqual({ source: 'standard', id: 'Bambu PLA Basic @BBL A1' });
+  });
+
+  it('still picks a long-form preset for its own printer', () => {
+    const presets = makeUnified({
+      cloud: {
+        printer: [],
+        process: [],
+        filament: [
+          {
+            id: 'sunlu-tpu-h2d',
+            name: 'SUNLU TPU 95A @Bambu Lab H2D 0.4 nozzle',
+            source: 'cloud',
+            filament_type: 'TPU',
+            filament_colour: '#FF0000',
+          },
+        ],
+      },
+    });
+
+    const pick = pickFilamentForSlot(
+      presets,
+      { type: 'TPU', color: '#FF0000' },
+      'Bambu Lab H2D 0.4 nozzle',
+      index,
+    );
+
+    expect(pick).toEqual({ source: 'cloud', id: 'sunlu-tpu-h2d' });
+  });
+});

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

@@ -40,6 +40,7 @@ const createMockPlug = (overrides: Partial<SmartPlug> = {}): SmartPlug => ({
   mqtt_state_path: null,
   mqtt_state_path: null,
   mqtt_state_on_value: null,
   mqtt_state_on_value: null,
   printer_id: 1,
   printer_id: 1,
+  controls_printer_power: true,
   enabled: true,
   enabled: true,
   auto_on: true,
   auto_on: true,
   auto_off: true,
   auto_off: true,
@@ -287,6 +288,33 @@ describe('SmartPlugCard', () => {
     });
     });
   });
   });
 
 
+  describe('powers the printer toggle (#2629)', () => {
+    it('shows the toggle when a printer is linked', async () => {
+      const user = userEvent.setup();
+      const plug = createMockPlug({ printer_id: 1, controls_printer_power: false });
+      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
+
+      await user.click(screen.getByText('Automation Settings'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Powers the printer')).toBeInTheDocument();
+      });
+    });
+
+    it('hides the toggle when no printer is linked', async () => {
+      const user = userEvent.setup();
+      const plug = createMockPlug({ printer_id: null });
+      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
+
+      await user.click(screen.getByText('Automation Settings'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Auto On')).toBeInTheDocument();
+      });
+      expect(screen.queryByText('Powers the printer')).not.toBeInTheDocument();
+    });
+  });
+
   describe('disabled state', () => {
   describe('disabled state', () => {
     it('renders plug even when disabled', () => {
     it('renders plug even when disabled', () => {
       const plug = createMockPlug({ enabled: false });
       const plug = createMockPlug({ enabled: false });

+ 56 - 0
frontend/src/__tests__/components/spool-form/PAProfileSectionNozzle.test.tsx

@@ -0,0 +1,56 @@
+/**
+ * Regression test for the PA-Profil picker's nozzle blindness (#2618).
+ *
+ * When a printer has two K-profiles for the same filament that differ only in
+ * nozzle size (e.g. PAHT-CF at 0.4mm K=0.042 and 0.6mm K=0.028), the picker
+ * must offer BOTH and label each with its nozzle diameter — not collapse to a
+ * single entry. The underlying cause lived in the fetch (it defaulted to the
+ * 0.4mm nozzle and never retrieved the 0.6mm profile); this test guards the
+ * rendering half: given both calibrations, the section shows both with a
+ * nozzle badge so they are distinguishable.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { I18nextProvider } from 'react-i18next';
+import i18n from '../../../i18n';
+import { PAProfileSection } from '../../../components/spool-form/PAProfileSection';
+import { defaultFormData } from '../../../components/spool-form/types';
+import type { PrinterWithCalibrations } from '../../../components/spool-form/types';
+
+const printers = [
+  {
+    printer: { id: 1, name: 'H2D', connected: true },
+    calibrations: [
+      // Same filament (non-generic id → id-match), different nozzle + extruder.
+      { cali_idx: 10, filament_id: 'GFN05', setting_id: '', name: 'PAHT-CF', k_value: 0.042, n_coef: 0, extruder_id: 0, nozzle_diameter: '0.4' },
+      { cali_idx: 11, filament_id: 'GFN05', setting_id: '', name: 'PAHT-CF', k_value: 0.028, n_coef: 0, extruder_id: 1, nozzle_diameter: '0.6' },
+    ],
+  },
+] as unknown as PrinterWithCalibrations[];
+
+describe('PAProfileSection nozzle-specific profiles (#2618)', () => {
+  it('renders both nozzle profiles for one filament, each with a nozzle badge', () => {
+    render(
+      <I18nextProvider i18n={i18n}>
+        <PAProfileSection
+          formData={{ ...defaultFormData, material: 'PAHT-CF', slicer_filament: 'GFN05' }}
+          updateField={vi.fn()}
+          printersWithCalibrations={printers}
+          selectedProfiles={new Set()}
+          setSelectedProfiles={vi.fn()}
+          expandedPrinters={new Set(['1'])}
+          setExpandedPrinters={vi.fn()}
+        />
+      </I18nextProvider>,
+    );
+
+    // Both nozzle-specific K values are offered — not just the 0.4mm one.
+    expect(screen.getByText('K=0.042')).toBeInTheDocument();
+    expect(screen.getByText('K=0.028')).toBeInTheDocument();
+
+    // Each is labelled by its nozzle so identically-named profiles are distinct.
+    expect(screen.getByText('0.4mm')).toBeInTheDocument();
+    expect(screen.getByText('0.6mm')).toBeInTheDocument();
+  });
+});

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

@@ -510,6 +510,56 @@ describe('useWebSocket hook', () => {
       vi.unstubAllGlobals();
       vi.unstubAllGlobals();
     });
     });
 
 
+    it('handles spool_assignment_verified messages (success and failure) without error', async () => {
+      vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
+        cb(0);
+        return 0;
+      });
+      const { useWebSocket } = await import('../../hooks/useWebSocket');
+
+      renderHook(() => useWebSocket(), {
+        wrapper: createWrapper(queryClient),
+      });
+
+      const ws = await waitForWs();
+      act(() => {
+        ws.open();
+      });
+
+      // #2582: verified (loaded), loaded-but-no-K-profile, and not-confirmed
+      // all route to a toast — assert none of the branches throw.
+      expect(() => {
+        act(() => {
+          ws.simulateMessage({
+            type: 'spool_assignment_verified',
+            printer_id: 3,
+            printer_name: 'Printer A',
+            slot: 'A1',
+            verified: true,
+            kprofile_applied: true,
+          });
+          ws.simulateMessage({
+            type: 'spool_assignment_verified',
+            printer_id: 3,
+            printer_name: 'Printer A',
+            slot: 'A1',
+            verified: true,
+            kprofile_applied: false,
+          });
+          ws.simulateMessage({
+            type: 'spool_assignment_verified',
+            printer_id: 3,
+            printer_name: 'Printer A',
+            slot: 'A1',
+            verified: false,
+            saw_tray: true,
+          });
+        });
+      }).not.toThrow();
+
+      vi.unstubAllGlobals();
+    });
+
     it('ignores pong messages without error', async () => {
     it('ignores pong messages without error', async () => {
       const { useWebSocket } = await import('../../hooks/useWebSocket');
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
 

+ 32 - 0
frontend/src/__tests__/pages/FileManagerPage.test.tsx

@@ -62,6 +62,9 @@ const mockFiles = [
     print_count: 5,
     print_count: 5,
     duplicate_count: 0,
     duplicate_count: 0,
     created_at: '2024-01-01T00:00:00Z',
     created_at: '2024-01-01T00:00:00Z',
+    // #2680: real on-disk mtime in a distinctive year so the display test can
+    // prove fs_modified_at is preferred over created_at (2024).
+    fs_modified_at: '2030-06-15T12:00:00Z',
   },
   },
   {
   {
     id: 2,
     id: 2,
@@ -1080,4 +1083,33 @@ describe('FileManagerPage', () => {
       expect(includeRootValues).toContain('false');
       expect(includeRootValues).toContain('false');
     });
     });
   });
   });
+
+  describe('last-modified date display (#2680)', () => {
+    it('is hidden by default and revealed by the toolbar toggle', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Benchy')).toBeInTheDocument();
+      });
+
+      // Hidden by default.
+      expect(screen.queryByText(/2030/)).not.toBeInTheDocument();
+
+      // Toggle on via the toolbar button.
+      await user.click(screen.getByTitle('Show modified dates'));
+
+      // benchy carries fs_modified_at in 2030, which must be preferred over its
+      // created_at (2024) — proving the real on-disk mtime drives the display.
+      await waitFor(() => {
+        expect(screen.getByText(/2030/)).toBeInTheDocument();
+      });
+
+      // Toggling off hides it again.
+      await user.click(screen.getByTitle('Hide modified dates'));
+      await waitFor(() => {
+        expect(screen.queryByText(/2030/)).not.toBeInTheDocument();
+      });
+    });
+  });
 });
 });

+ 112 - 6
frontend/src/__tests__/pages/QueuePage.test.tsx

@@ -24,8 +24,8 @@ const mockQueueItems = [
     manual_start: false,
     manual_start: false,
     ams_mapping: null,
     ams_mapping: null,
     plate_id: null,
     plate_id: null,
-    bed_levelling: true,
-    flow_cali: false,
+    bed_levelling: 'on',
+    flow_cali: 'off',
     vibration_cali: true,
     vibration_cali: true,
     layer_inspect: false,
     layer_inspect: false,
     timelapse: false,
     timelapse: false,
@@ -51,8 +51,8 @@ const mockQueueItems = [
     manual_start: false,
     manual_start: false,
     ams_mapping: null,
     ams_mapping: null,
     plate_id: null,
     plate_id: null,
-    bed_levelling: true,
-    flow_cali: false,
+    bed_levelling: 'on',
+    flow_cali: 'off',
     vibration_cali: true,
     vibration_cali: true,
     layer_inspect: false,
     layer_inspect: false,
     timelapse: false,
     timelapse: false,
@@ -78,8 +78,8 @@ const mockQueueItems = [
     manual_start: false,
     manual_start: false,
     ams_mapping: null,
     ams_mapping: null,
     plate_id: null,
     plate_id: null,
-    bed_levelling: true,
-    flow_cali: false,
+    bed_levelling: 'on',
+    flow_cali: 'off',
     vibration_cali: true,
     vibration_cali: true,
     layer_inspect: false,
     layer_inspect: false,
     timelapse: false,
     timelapse: false,
@@ -249,6 +249,51 @@ describe('QueuePage', () => {
     });
     });
   });
   });
 
 
+  describe('history pagination', () => {
+    // #2682: History rendered the full count in the header but only ever drew
+    // the first 50 rows, with no way to reach the rest. It now paginates with
+    // a "Show more" control.
+    const manyHistory = Array.from({ length: 60 }, (_, i) => ({
+      ...mockQueueItems[2],
+      id: 100 + i,
+      batch_id: null,
+      archive_name: `History Item ${String(i).padStart(2, '0')}`,
+      // Descending completed_at so index 0 is newest and sorts first; the
+      // default History sort is by date, newest first.
+      completed_at: new Date(Date.UTC(2024, 0, 1, 0, 0, 0) - i * 60000).toISOString(),
+    }));
+
+    beforeEach(() => {
+      server.use(
+        http.get('/api/v1/queue/', () => {
+          return HttpResponse.json(manyHistory);
+        })
+      );
+    });
+
+    it('caps the History list at one page and reveals the rest on Show more', async () => {
+      const user = userEvent.setup();
+      render(<QueuePage />);
+
+      await user.click(await screen.findByRole('button', { name: /^History/ }));
+
+      // First page is drawn; an item past the 50-row cap is not.
+      await waitFor(() => {
+        expect(screen.getByText('History Item 00')).toBeInTheDocument();
+      });
+      expect(screen.queryByText('History Item 59')).not.toBeInTheDocument();
+      expect(screen.getByText('Showing 50 of 60')).toBeInTheDocument();
+
+      // Show more reveals the remainder and then disappears (nothing left).
+      await user.click(screen.getByRole('button', { name: /show more/i }));
+
+      await waitFor(() => {
+        expect(screen.getByText('History Item 59')).toBeInTheDocument();
+      });
+      expect(screen.queryByRole('button', { name: /show more/i })).not.toBeInTheDocument();
+    });
+  });
+
   describe('empty state', () => {
   describe('empty state', () => {
     it('shows empty state when no queue items', async () => {
     it('shows empty state when no queue items', async () => {
       server.use(
       server.use(
@@ -534,4 +579,65 @@ describe('QueuePage', () => {
       expect(attempts).toBe(2);
       expect(attempts).toBe(2);
     });
     });
   });
   });
+
+  // #2667: mobile can't drag-reorder the queue, so pending rows get up/down
+  // arrows. They persist via the same POST /queue/reorder as drag. The
+  // buttons live in the DOM at every width (Tailwind `sm:hidden` is CSS-only),
+  // so they're clickable in jsdom.
+  describe('mobile reorder arrows (#2667)', () => {
+    const threePending = [1, 2, 3].map((n) => ({
+      ...mockQueueItems[0],
+      id: n,
+      archive_id: n,
+      position: n,
+      status: 'pending',
+      archive_name: `Pending ${n}`,
+    }));
+
+    it('renders Move Up / Move Down controls for pending items', async () => {
+      server.use(http.get('/api/v1/queue/', () => HttpResponse.json(threePending)));
+      render(<QueuePage />);
+
+      await waitFor(() => expect(screen.getByText('Pending 1')).toBeInTheDocument());
+
+      // One pair per pending row.
+      expect(screen.getAllByTitle('Move Up')).toHaveLength(3);
+      expect(screen.getAllByTitle('Move Down')).toHaveLength(3);
+    });
+
+    it('moving the first item down persists the swapped order', async () => {
+      let reorderBody: { items: { id: number; position: number }[] } | null = null;
+      server.use(
+        http.get('/api/v1/queue/', () => HttpResponse.json(threePending)),
+        http.post('/api/v1/queue/reorder', async ({ request }) => {
+          reorderBody = (await request.json()) as typeof reorderBody;
+          return HttpResponse.json({ message: 'ok' });
+        }),
+      );
+      render(<QueuePage />);
+
+      await waitFor(() => expect(screen.getByText('Pending 1')).toBeInTheDocument());
+
+      // First row's "Move Down": item 1 drops below item 2 → [2, 1, 3].
+      await userEvent.click(screen.getAllByTitle('Move Down')[0]);
+
+      await waitFor(() => expect(reorderBody).not.toBeNull());
+      expect(reorderBody!.items).toEqual([
+        { id: 2, position: 1 },
+        { id: 1, position: 2 },
+        { id: 3, position: 3 },
+      ]);
+    });
+
+    it('disables Move Up on the first row and Move Down on the last', async () => {
+      server.use(http.get('/api/v1/queue/', () => HttpResponse.json(threePending)));
+      render(<QueuePage />);
+
+      await waitFor(() => expect(screen.getByText('Pending 1')).toBeInTheDocument());
+
+      // Rows render top-to-bottom in position order.
+      expect(screen.getAllByTitle('Move Up')[0]).toBeDisabled();
+      expect(screen.getAllByTitle('Move Down')[2]).toBeDisabled();
+    });
+  });
 });
 });

+ 25 - 0
frontend/src/__tests__/utils/getAmsLabel.test.ts

@@ -0,0 +1,25 @@
+import { describe, it, expect } from 'vitest';
+
+import { getAmsLabel } from '../../utils/amsHelpers';
+
+describe('getAmsLabel', () => {
+  it('labels regular AMS units A/B/C by id', () => {
+    expect(getAmsLabel(0, 4)).toBe('AMS-A');
+    expect(getAmsLabel(1, 4)).toBe('AMS-B');
+  });
+
+  it('labels AMS-HT units (single tray, id >= 128)', () => {
+    expect(getAmsLabel(128, 1)).toBe('HT-A');
+    expect(getAmsLabel(129, 1)).toBe('HT-B');
+  });
+
+  it('labels the external spool', () => {
+    expect(getAmsLabel(255, 1)).toBe('External');
+  });
+
+  it('labels the A2L AMS Lite (normalised unit id 6) distinctly', () => {
+    // The backend normalises the A2L Lite's physical unit 16 -> 6; no regular
+    // AMS uses id 6, so it never collides with the A/B/C range.
+    expect(getAmsLabel(6, 4)).toBe('AMS Lite');
+  });
+});

+ 54 - 0
frontend/src/__tests__/utils/installedNozzleDiameters.test.ts

@@ -0,0 +1,54 @@
+/**
+ * Tests for installedNozzleDiameters helper (#2618).
+ *
+ * The spool PA-Profil picker must fetch K-profiles across every nozzle the
+ * printer actually has installed, not just the hardcoded 0.4mm default — else
+ * a 0.6mm profile for the same filament is never surfaced. This helper lists
+ * the distinct reported diameters, skipping empty/non-positive defaults, and
+ * returns an empty array when the hardware hasn't been reported so the caller
+ * can keep its own fallback.
+ */
+
+import { describe, it, expect } from 'vitest';
+
+import { installedNozzleDiameters } from '../../utils/amsHelpers';
+
+describe('installedNozzleDiameters', () => {
+  it('returns an empty array when status is null or undefined', () => {
+    expect(installedNozzleDiameters(null)).toEqual([]);
+    expect(installedNozzleDiameters(undefined)).toEqual([]);
+  });
+
+  it('returns an empty array when no nozzles are reported', () => {
+    expect(installedNozzleDiameters({ nozzles: [] })).toEqual([]);
+    expect(installedNozzleDiameters({})).toEqual([]);
+  });
+
+  it('skips empty-string and non-positive nozzle defaults', () => {
+    expect(
+      installedNozzleDiameters({ nozzles: [{ nozzle_diameter: '' }, { nozzle_diameter: '0' }] }),
+    ).toEqual([]);
+  });
+
+  it('returns the single installed diameter', () => {
+    expect(installedNozzleDiameters({ nozzles: [{ nozzle_diameter: '0.4' }] })).toEqual(['0.4']);
+  });
+
+  it('returns both diameters on a dual-nozzle printer, in order', () => {
+    expect(
+      installedNozzleDiameters({ nozzles: [{ nozzle_diameter: '0.4' }, { nozzle_diameter: '0.6' }] }),
+    ).toEqual(['0.4', '0.6']);
+  });
+
+  it('dedupes repeated diameters (two 0.4 hotends report once)', () => {
+    expect(
+      installedNozzleDiameters({ nozzles: [{ nozzle_diameter: '0.4' }, { nozzle_diameter: '0.4' }] }),
+    ).toEqual(['0.4']);
+  });
+
+  it('keeps only the valid diameter when one hotend is still an empty default', () => {
+    expect(
+      installedNozzleDiameters({ nozzles: [{ nozzle_diameter: '0.6' }, { nozzle_diameter: '' }] }),
+    ).toEqual(['0.6']);
+  });
+});

+ 41 - 1
frontend/src/__tests__/utils/printer.test.ts

@@ -8,7 +8,8 @@
  */
  */
 
 
 import { describe, it, expect } from 'vitest';
 import { describe, it, expect } from 'vitest';
-import { getPrinterImage, isGcodeCompatible } from '../../utils/printer';
+import { getPrinterImage, isGcodeCompatible, filterCompatibleQueueItems } from '../../utils/printer';
+import type { PrintQueueItem } from '../../api/client';
 
 
 describe('getPrinterImage', () => {
 describe('getPrinterImage', () => {
   describe('X2D (#988)', () => {
   describe('X2D (#988)', () => {
@@ -136,3 +137,42 @@ describe('isGcodeCompatible', () => {
     expect(isGcodeCompatible('H2D Pro', 'H2DPRO')).toBe(true);
     expect(isGcodeCompatible('H2D Pro', 'H2DPRO')).toBe(true);
   });
   });
 });
 });
+
+describe('filterCompatibleQueueItems — force-color PLA variant (#2650)', () => {
+  const makeItem = (
+    overrides: Array<{ slot_id: number; type: string; color: string; tray_info_idx?: string; force_color_match?: boolean }>,
+  ): PrintQueueItem => ({ id: 1, filament_overrides: overrides } as unknown as PrintQueueItem);
+
+  // A job sliced for White PLA Matte (GFA01).
+  const matteJob = makeItem([
+    { slot_id: 1, type: 'PLA', color: '#FFFFFF', tray_info_idx: 'GFA01', force_color_match: true },
+  ]);
+  const loadedTypes = new Set(['PLA']);
+  const loaded = new Set(['PLA:ffffff']);
+
+  it('rejects a printer loaded only with other white PLA variants (Basic/Silk)', () => {
+    const variants = new Set(['PLA:ffffff:GFA00', 'PLA:ffffff:GFA06']);
+    expect(filterCompatibleQueueItems([matteJob], loadedTypes, loaded, variants)).toHaveLength(0);
+  });
+
+  it('accepts a printer loaded with the matching variant (Matte GFA01)', () => {
+    const variants = new Set(['PLA:ffffff:GFA00', 'PLA:ffffff:GFA01']);
+    expect(filterCompatibleQueueItems([matteJob], loadedTypes, loaded, variants)).toHaveLength(1);
+  });
+
+  it('accepts a same-colour spool that reports no tray_info_idx (custom/third-party)', () => {
+    const variants = new Set(['PLA:ffffff:']);
+    expect(filterCompatibleQueueItems([matteJob], loadedTypes, loaded, variants)).toHaveLength(1);
+  });
+
+  it('falls back to type+colour when no variant data is supplied', () => {
+    // loadedVariants omitted → the hint is never stricter than the data it has.
+    expect(filterCompatibleQueueItems([matteJob], loadedTypes, loaded)).toHaveLength(1);
+  });
+
+  it('an override without a tray_info_idx keeps the old type+colour behaviour', () => {
+    const noIdxJob = makeItem([{ slot_id: 1, type: 'PLA', color: '#FFFFFF', force_color_match: true }]);
+    const variants = new Set(['PLA:ffffff:GFA06']);
+    expect(filterCompatibleQueueItems([noIdxJob], loadedTypes, loaded, variants)).toHaveLength(1);
+  });
+});

+ 124 - 0
frontend/src/__tests__/utils/slicerPrinterMatch.test.ts

@@ -361,3 +361,127 @@ describe('presetCompatibility with Bambu cloud A1M rename (#1649)', () => {
     ).toBe('mismatch');
     ).toBe('mismatch');
   });
   });
 });
 });
+
+describe('presetCompatibility — long-form @Bambu Lab printer tag (#2628)', () => {
+  const A1 = 'Bambu Lab A1 0.4 nozzle';
+  const A1_MINI = 'Bambu Lab A1 mini 0.4 nozzle';
+  const H2D = 'Bambu Lab H2D 0.4 nozzle';
+  const idx = buildCompatibilityIndex(PRINTER_MODELS);
+
+  it('flags a user-saved H2D filament preset as a mismatch on an A1', () => {
+    // michaelklos's report: this exact preset sat in an unused filament slot
+    // of a multi-plate 3MF. Classified 'unknown' it was treated as usable,
+    // auto-picked, and the CLI rejected the slice with "filament preset
+    // (slot 1) is not compatible with printer Bambu Lab A1 0.4 nozzle".
+    expect(
+      presetCompatibility({ name: 'SUNLU TPU 95A @Bambu Lab H2D 0.4 nozzle' }, 'filament', A1, idx),
+    ).toBe('mismatch');
+  });
+
+  it('matches the same preset against its own printer', () => {
+    expect(
+      presetCompatibility({ name: 'SUNLU TPU 95A @Bambu Lab H2D 0.4 nozzle' }, 'filament', H2D, idx),
+    ).toBe('match');
+  });
+
+  it('resolves a long-form model whose display name differs from its short code', () => {
+    const name = 'My PETG @Bambu Lab X1 Carbon 0.4 nozzle';
+    expect(presetCompatibility({ name }, 'filament', X1C, idx)).toBe('match');
+    expect(presetCompatibility({ name }, 'filament', A1, idx)).toBe('mismatch');
+  });
+
+  it('applies the nozzle filter to the long form too', () => {
+    expect(
+      presetCompatibility({ name: 'My PETG @Bambu Lab A1 0.6 nozzle' }, 'filament', A1, idx),
+    ).toBe('mismatch');
+  });
+
+  it('ignores a trailing "(Custom)" suffix rather than mangling the model token', () => {
+    // The slicer appends this to user-saved presets. Parsed naively the tag
+    // becomes "H2D 0.4 nozzle (Custom)" — which would brand the preset a
+    // mismatch against its own printer and hide it from the dropdown.
+    const name = 'Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle (Custom)';
+    expect(presetCompatibility({ name }, 'filament', H2D, idx)).toBe('match');
+    expect(presetCompatibility({ name }, 'filament', A1, idx)).toBe('mismatch');
+  });
+
+  it('keeps the A1M alias working through the long form', () => {
+    expect(
+      presetCompatibility({ name: 'My PLA @Bambu Lab A1 mini 0.4 nozzle' }, 'filament', A1_MINI, idx),
+    ).toBe('match');
+    expect(
+      presetCompatibility({ name: 'My PLA @Bambu Lab A1 mini 0.4 nozzle' }, 'filament', A1, idx),
+    ).toBe('mismatch');
+  });
+
+  it('stays unknown for an @-tag that names no recognisable printer', () => {
+    // "@Voron 0.4 nozzle" is not a Bambu printer preset — the matcher must
+    // not guess, so the preset keeps its place in the main dropdown list.
+    expect(
+      presetCompatibility({ name: 'My PLA @Voron 0.4 nozzle' }, 'filament', A1, idx),
+    ).toBe('unknown');
+  });
+
+  it('reads the tag from the last @ so a stray earlier one cannot swallow it', () => {
+    expect(
+      presetCompatibility({ name: 'My @work PLA @Bambu Lab H2D 0.4 nozzle' }, 'filament', A1, idx),
+    ).toBe('mismatch');
+    expect(
+      presetCompatibility({ name: 'My @work PLA @Bambu Lab H2D 0.4 nozzle' }, 'filament', H2D, idx),
+    ).toBe('match');
+  });
+});
+
+describe('presetCompatibility — nozzle-only @<size> tag (#2628 follow-up)', () => {
+  const P2S_04 = 'Bambu Lab P2S 0.4 nozzle';
+  const X1C_02 = 'Bambu Lab X1 Carbon 0.2 nozzle';
+  const idx = buildCompatibilityIndex(PRINTER_MODELS);
+
+  it('rules out a 0.2-nozzle profile on a 0.4-nozzle printer', () => {
+    // The live case: "Overture PLA Matte @0.2" inherits from the X1C 0.2
+    // nozzle system profile, so the slicer rejected a P2S 0.4 slice with
+    // "not compatible with printer Bambu Lab P2S 0.4 nozzle". The name
+    // carries no model, so this size is the only signal available.
+    expect(
+      presetCompatibility({ name: 'Overture PLA Matte @0.2' }, 'filament', P2S_04, idx),
+    ).toBe('mismatch');
+  });
+
+  it('stays unknown when the size agrees — a size is not a model', () => {
+    // The profile might belong to a different printer with the same nozzle;
+    // promoting this to 'match' would claim knowledge we don't have.
+    expect(
+      presetCompatibility({ name: 'Overture PLA Matte @0.2' }, 'filament', X1C_02, idx),
+    ).toBe('unknown');
+  });
+
+  it('accepts the "0.2 nozzle" and "0.2mm" spellings of the same tag', () => {
+    for (const name of ['My PLA @0.2 nozzle', 'My PLA @0.2mm']) {
+      expect(presetCompatibility({ name }, 'filament', P2S_04, idx)).toBe('mismatch');
+    }
+  });
+
+  it('compares sizes numerically so 0.20 and 0.2 are one size', () => {
+    expect(
+      presetCompatibility({ name: 'My PLA @0.20' }, 'filament', X1C_02, idx),
+    ).toBe('unknown');
+  });
+
+  it('ignores a numeric tag that cannot be a nozzle', () => {
+    // "@2026" is a year, not a 2026 mm nozzle — guessing here would brand
+    // the profile incompatible with every printer that exists.
+    expect(presetCompatibility({ name: 'My PLA @2026' }, 'filament', P2S_04, idx)).toBe('unknown');
+    expect(presetCompatibility({ name: 'My PLA @0.05' }, 'filament', P2S_04, idx)).toBe('unknown');
+  });
+
+  it('leaves a model-bearing tag on the model path', () => {
+    // Regression guard: the nozzle-only branch must not swallow the forms
+    // that carry a model — those still resolve to match/mismatch.
+    expect(
+      presetCompatibility({ name: 'Bambu PLA Basic @BBL P2S' }, 'filament', P2S_04, idx),
+    ).toBe('match');
+    expect(
+      presetCompatibility({ name: 'My PLA @Bambu Lab X1 Carbon 0.4 nozzle' }, 'filament', P2S_04, idx),
+    ).toBe('mismatch');
+  });
+});

+ 33 - 16
frontend/src/api/client.ts

@@ -1154,6 +1154,13 @@ export interface APIKeyUpdate {
   expires_at?: string | null;
   expires_at?: string | null;
 }
 }
 
 
+/**
+ * Tri-state calibration option (BambuStudio parity): "off" never runs it,
+ * "on" forces it every print, "auto" lets the printer skip it if it was done
+ * recently. Used by bed_levelling, flow_cali, and nozzle_offset_cali.
+ */
+export type CalibrationMode = 'off' | 'on' | 'auto';
+
 // Settings types
 // Settings types
 export interface AppSettings {
 export interface AppSettings {
   auto_archive: boolean;
   auto_archive: boolean;
@@ -1263,12 +1270,12 @@ export interface AppSettings {
   // User email notifications toggle
   // User email notifications toggle
   user_notifications_enabled: boolean;
   user_notifications_enabled: boolean;
   // Default print options
   // Default print options
-  default_bed_levelling: boolean;
-  default_flow_cali: boolean;
+  default_bed_levelling: CalibrationMode;
+  default_flow_cali: CalibrationMode;
   default_vibration_cali: boolean;
   default_vibration_cali: boolean;
   default_layer_inspect: boolean;
   default_layer_inspect: boolean;
   default_timelapse: boolean;
   default_timelapse: boolean;
-  default_nozzle_offset_cali: boolean;
+  default_nozzle_offset_cali: CalibrationMode;
   // Staggered batch start defaults
   // Staggered batch start defaults
   stagger_group_size: number;
   stagger_group_size: number;
   stagger_interval_minutes: number;
   stagger_interval_minutes: number;
@@ -1921,6 +1928,9 @@ export interface SmartPlug {
   rest_energy_total_path: string | null;
   rest_energy_total_path: string | null;
   rest_energy_total_multiplier: number;
   rest_energy_total_multiplier: number;
   printer_id: number | null;
   printer_id: number | null;
+  // #2629: only a plug that really feeds the printer may mark it offline when
+  // switched off. Accessory plugs follow the print cycle without powering it.
+  controls_printer_power: boolean;
   enabled: boolean;
   enabled: boolean;
   auto_on: boolean;
   auto_on: boolean;
   auto_off: boolean;
   auto_off: boolean;
@@ -1997,6 +2007,8 @@ export interface SmartPlugCreate {
   rest_energy_total_path?: string | null;
   rest_energy_total_path?: string | null;
   rest_energy_total_multiplier?: number;
   rest_energy_total_multiplier?: number;
   printer_id?: number | null;
   printer_id?: number | null;
+  // #2629
+  controls_printer_power?: boolean;
   enabled?: boolean;
   enabled?: boolean;
   auto_on?: boolean;
   auto_on?: boolean;
   auto_off?: boolean;
   auto_off?: boolean;
@@ -2065,6 +2077,8 @@ export interface SmartPlugUpdate {
   rest_energy_total_path?: string | null;
   rest_energy_total_path?: string | null;
   rest_energy_total_multiplier?: number;
   rest_energy_total_multiplier?: number;
   printer_id?: number | null;
   printer_id?: number | null;
+  // #2629
+  controls_printer_power?: boolean;
   enabled?: boolean;
   enabled?: boolean;
   auto_on?: boolean;
   auto_on?: boolean;
   auto_off?: boolean;
   auto_off?: boolean;
@@ -2178,16 +2192,16 @@ export interface PrintQueueItem {
   // PrintModal's deficit warning was acknowledged.
   // PrintModal's deficit warning was acknowledged.
   skip_filament_check: boolean;
   skip_filament_check: boolean;
   ams_mapping: number[] | null;  // AMS slot mapping for multi-color prints
   ams_mapping: number[] | null;  // AMS slot mapping for multi-color prints
-  filament_overrides: Array<{ slot_id: number; type: string; color: string; color_name?: string; force_color_match?: boolean }> | null;  // Filament overrides for model-based assignment
+  filament_overrides: Array<{ slot_id: number; type: string; color: string; color_name?: string; tray_info_idx?: string; force_color_match?: boolean }> | null;  // Filament overrides for model-based assignment
   plate_id: number | null;  // Plate ID for multi-plate 3MF files
   plate_id: number | null;  // Plate ID for multi-plate 3MF files
   // Print options
   // Print options
-  bed_levelling: boolean;
-  flow_cali: boolean;
+  bed_levelling: CalibrationMode;
+  flow_cali: CalibrationMode;
   vibration_cali: boolean;
   vibration_cali: boolean;
   layer_inspect: boolean;
   layer_inspect: boolean;
   timelapse: boolean;
   timelapse: boolean;
   use_ams: boolean;
   use_ams: boolean;
-  nozzle_offset_cali: boolean;
+  nozzle_offset_cali: CalibrationMode;
   preheat_override: 'inherit' | 'on' | 'off';
   preheat_override: 'inherit' | 'on' | 'off';
   preheat_chamber_target_override: number | null;
   preheat_chamber_target_override: number | null;
   status: 'pending' | 'printing' | 'completed' | 'failed' | 'skipped' | 'cancelled';
   status: 'pending' | 'printing' | 'completed' | 'failed' | 'skipped' | 'cancelled';
@@ -2258,13 +2272,13 @@ export interface PrintQueueItemCreate {
   ams_mapping?: number[] | null;  // AMS slot mapping for multi-color prints
   ams_mapping?: number[] | null;  // AMS slot mapping for multi-color prints
   plate_id?: number | null;  // Plate ID for multi-plate 3MF files
   plate_id?: number | null;  // Plate ID for multi-plate 3MF files
   // Print options
   // Print options
-  bed_levelling?: boolean;
-  flow_cali?: boolean;
+  bed_levelling?: CalibrationMode;
+  flow_cali?: CalibrationMode;
   vibration_cali?: boolean;
   vibration_cali?: boolean;
   layer_inspect?: boolean;
   layer_inspect?: boolean;
   timelapse?: boolean;
   timelapse?: boolean;
   use_ams?: boolean;
   use_ams?: boolean;
-  nozzle_offset_cali?: boolean;
+  nozzle_offset_cali?: CalibrationMode;
   preheat_override?: 'inherit' | 'on' | 'off';
   preheat_override?: 'inherit' | 'on' | 'off';
   preheat_chamber_target_override?: number | null;
   preheat_chamber_target_override?: number | null;
   // Auto-print G-code injection
   // Auto-print G-code injection
@@ -2302,13 +2316,13 @@ export interface PrintQueueItemUpdate {
   ams_mapping?: number[];
   ams_mapping?: number[];
   plate_id?: number | null;  // Plate ID for multi-plate 3MF files
   plate_id?: number | null;  // Plate ID for multi-plate 3MF files
   // Print options
   // Print options
-  bed_levelling?: boolean;
-  flow_cali?: boolean;
+  bed_levelling?: CalibrationMode;
+  flow_cali?: CalibrationMode;
   vibration_cali?: boolean;
   vibration_cali?: boolean;
   layer_inspect?: boolean;
   layer_inspect?: boolean;
   timelapse?: boolean;
   timelapse?: boolean;
   use_ams?: boolean;
   use_ams?: boolean;
-  nozzle_offset_cali?: boolean;
+  nozzle_offset_cali?: CalibrationMode;
   preheat_override?: 'inherit' | 'on' | 'off';
   preheat_override?: 'inherit' | 'on' | 'off';
   preheat_chamber_target_override?: number | null;
   preheat_chamber_target_override?: number | null;
   // Auto-print G-code injection
   // Auto-print G-code injection
@@ -2323,13 +2337,13 @@ export interface PrintQueueBulkUpdate {
   auto_off_after?: boolean;
   auto_off_after?: boolean;
   manual_start?: boolean;
   manual_start?: boolean;
   // Print options
   // Print options
-  bed_levelling?: boolean;
-  flow_cali?: boolean;
+  bed_levelling?: CalibrationMode;
+  flow_cali?: CalibrationMode;
   vibration_cali?: boolean;
   vibration_cali?: boolean;
   layer_inspect?: boolean;
   layer_inspect?: boolean;
   timelapse?: boolean;
   timelapse?: boolean;
   use_ams?: boolean;
   use_ams?: boolean;
-  nozzle_offset_cali?: boolean;
+  nozzle_offset_cali?: CalibrationMode;
   preheat_override?: 'inherit' | 'on' | 'off';
   preheat_override?: 'inherit' | 'on' | 'off';
   preheat_chamber_target_override?: number | null;
   preheat_chamber_target_override?: number | null;
   // Auto-print G-code injection
   // Auto-print G-code injection
@@ -6871,6 +6885,9 @@ export interface LibraryFileListItem {
   created_by_id: number | null;
   created_by_id: number | null;
   created_by_username: string | null;
   created_by_username: string | null;
   created_at: string;
   created_at: string;
+  // Real on-disk modification time (#2680). Null for managed uploads; the date
+  // sort and "Modified" column use `fs_modified_at ?? created_at`.
+  fs_modified_at: string | null;
   print_name: string | null;
   print_name: string | null;
   print_time_seconds: number | null;
   print_time_seconds: number | null;
   filament_used_grams: number | null;
   filament_used_grams: number | null;

+ 24 - 0
frontend/src/components/AddSmartPlugModal.tsx

@@ -84,6 +84,9 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
   const energyTotalDropdownRef = useRef<HTMLDivElement>(null);
   const energyTotalDropdownRef = useRef<HTMLDivElement>(null);
 
 
   const [printerId, setPrinterId] = useState<number | null>(plug?.printer_id || null);
   const [printerId, setPrinterId] = useState<number | null>(plug?.printer_id || null);
+  // #2629: defaults to true (a plug linked to a printer usually powers it);
+  // users turn it off for accessories like a filter fan or chamber light.
+  const [controlsPrinterPower, setControlsPrinterPower] = useState(plug?.controls_printer_power ?? true);
   const [testResult, setTestResult] = useState<{ success: boolean; state?: string | null; device_name?: string | null } | null>(null);
   const [testResult, setTestResult] = useState<{ success: boolean; state?: string | null; device_name?: string | null } | null>(null);
   const [error, setError] = useState<string | null>(null);
   const [error, setError] = useState<string | null>(null);
 
 
@@ -388,6 +391,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
       username: plugType === 'tasmota' ? (username.trim() || null) : null,
       username: plugType === 'tasmota' ? (username.trim() || null) : null,
       password: plugType === 'tasmota' ? (password.trim() || null) : null,
       password: plugType === 'tasmota' ? (password.trim() || null) : null,
       printer_id: printerId,
       printer_id: printerId,
+      controls_printer_power: controlsPrinterPower,
       // Power alerts
       // Power alerts
       power_alert_enabled: powerAlertEnabled,
       power_alert_enabled: powerAlertEnabled,
       power_alert_high: powerAlertHigh ? parseFloat(powerAlertHigh) : null,
       power_alert_high: powerAlertHigh ? parseFloat(powerAlertHigh) : null,
@@ -1505,6 +1509,26 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
               <p className="text-xs text-bambu-gray mt-1">
               <p className="text-xs text-bambu-gray mt-1">
                 {t('smartPlugs.linkingDescription')}
                 {t('smartPlugs.linkingDescription')}
               </p>
               </p>
+
+              {/* Whether the plug feeds the printer itself, or is an accessory
+                  that merely follows the print cycle (#2629). */}
+              {printerId !== null && (
+                <div className="flex items-center justify-between mt-3">
+                  <div className="pr-3">
+                    <p className="text-sm text-white">{t('smartPlugs.controlsPrinterPower')}</p>
+                    <p className="text-xs text-bambu-gray">{t('smartPlugs.controlsPrinterPowerDescription')}</p>
+                  </div>
+                  <label className="relative inline-flex items-center cursor-pointer shrink-0">
+                    <input
+                      type="checkbox"
+                      checked={controlsPrinterPower}
+                      onChange={(e) => setControlsPrinterPower(e.target.checked)}
+                      className="sr-only peer"
+                    />
+                    <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                  </label>
+                </div>
+              )}
             </div>
             </div>
           )}
           )}
 
 

+ 27 - 1
frontend/src/components/FilamentHoverCard.tsx

@@ -156,6 +156,19 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
     timeoutRef.current = setTimeout(() => setIsVisible(false), 100);
     timeoutRef.current = setTimeout(() => setIsVisible(false), 100);
   };
   };
 
 
+  // Dismiss the card immediately, for actions that open a dialog or navigate away.
+  //
+  // The card is portaled at z-[60] so it can escape sibling printer cards' stacking
+  // contexts, which puts it ABOVE ConfigureAmsSlotModal and LinkSpoolModal at z-50 —
+  // so a card left standing draws over the very dialog it just opened. Mouseleave is
+  // the only thing that normally hides it, and a touch device never sends one after
+  // the tap that opened the card, so on a tablet it stays up indefinitely (#2631).
+  // Clearing the timeout is not optional: a pending show timer would re-open it.
+  const dismiss = () => {
+    if (timeoutRef.current) clearTimeout(timeoutRef.current);
+    setIsVisible(false);
+  };
+
   // Cleanup timeout on unmount
   // Cleanup timeout on unmount
   useEffect(() => {
   useEffect(() => {
     return () => {
     return () => {
@@ -328,6 +341,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                       <button
                       <button
                         onClick={(e) => {
                         onClick={(e) => {
                           e.stopPropagation();
                           e.stopPropagation();
+                          dismiss();
                           navigate(`/inventory?spool=${spoolman.linkedSpoolId}`);
                           navigate(`/inventory?spool=${spoolman.linkedSpoolId}`);
                         }}
                         }}
                         className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green"
                         className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green"
@@ -380,6 +394,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                         <button
                         <button
                           onClick={(e) => {
                           onClick={(e) => {
                             e.stopPropagation();
                             e.stopPropagation();
+                            dismiss();
                             navigate(`/inventory?spool=${inventory.assignedSpool!.id}`);
                             navigate(`/inventory?spool=${inventory.assignedSpool!.id}`);
                           }}
                           }}
                           className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green"
                           className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green"
@@ -393,6 +408,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                         <button
                         <button
                           onClick={(e) => {
                           onClick={(e) => {
                             e.stopPropagation();
                             e.stopPropagation();
+                            dismiss();
                             inventory.onUnassignSpool?.();
                             inventory.onUnassignSpool?.();
                           }}
                           }}
                           className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-red-100 dark:bg-red-500/20 hover:bg-red-200 dark:hover:bg-red-500/30 text-red-700 dark:text-red-400"
                           className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-red-100 dark:bg-red-500/20 hover:bg-red-200 dark:hover:bg-red-500/30 text-red-700 dark:text-red-400"
@@ -406,6 +422,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                     <button
                     <button
                       onClick={inventory.isAssigned ? undefined : (e) => {
                       onClick={inventory.isAssigned ? undefined : (e) => {
                         e.stopPropagation();
                         e.stopPropagation();
+                        dismiss();
                         inventory.onAssignSpool?.();
                         inventory.onAssignSpool?.();
                       }}
                       }}
                       disabled={!!inventory.isAssigned}
                       disabled={!!inventory.isAssigned}
@@ -426,6 +443,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                   <button
                   <button
                     onClick={(e) => {
                     onClick={(e) => {
                       e.stopPropagation();
                       e.stopPropagation();
+                      dismiss();
                       configureSlot.onConfigure?.();
                       configureSlot.onConfigure?.();
                     }}
                     }}
                     className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
                     className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
@@ -534,6 +552,13 @@ export function EmptySlotHoverCard({ children, className = '', configureSlot, on
     timeoutRef.current = setTimeout(() => setIsVisible(false), 100);
     timeoutRef.current = setTimeout(() => setIsVisible(false), 100);
   };
   };
 
 
+  // See FilamentHoverCard.dismiss — same z-[60]-over-a-z-50-dialog problem, and the
+  // same missing mouseleave on touch (#2631).
+  const dismiss = () => {
+    if (timeoutRef.current) clearTimeout(timeoutRef.current);
+    setIsVisible(false);
+  };
+
   useEffect(() => {
   useEffect(() => {
     return () => {
     return () => {
       if (timeoutRef.current) clearTimeout(timeoutRef.current);
       if (timeoutRef.current) clearTimeout(timeoutRef.current);
@@ -601,6 +626,7 @@ export function EmptySlotHoverCard({ children, className = '', configureSlot, on
                   <button
                   <button
                     onClick={(e) => {
                     onClick={(e) => {
                       e.stopPropagation();
                       e.stopPropagation();
+                      dismiss();
                       configureSlot.onConfigure?.();
                       configureSlot.onConfigure?.();
                     }}
                     }}
                     className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
                     className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
@@ -612,7 +638,7 @@ export function EmptySlotHoverCard({ children, className = '', configureSlot, on
                 )}
                 )}
                 {onAssignSpool && (
                 {onAssignSpool && (
                   <button
                   <button
-                    onClick={(e) => { e.stopPropagation(); onAssignSpool(); }}
+                    onClick={(e) => { e.stopPropagation(); dismiss(); onAssignSpool(); }}
                     className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
                     className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
                   >
                   >
                     <Package className="w-3.5 h-3.5" />
                     <Package className="w-3.5 h-3.5" />

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

@@ -5,7 +5,7 @@ import { X, ExternalLink, Box, Code2, Cog, Loader2, Layers, Check, Maximize2, Mi
 import { ModelViewer } from './ModelViewer';
 import { ModelViewer } from './ModelViewer';
 import { GcodeViewer } from './GcodeViewer';
 import { GcodeViewer } from './GcodeViewer';
 import { Button } from './Button';
 import { Button } from './Button';
-import { api } from '../api/client';
+import { api, withStreamToken } from '../api/client';
 import { openInSlicer, type SlicerType } from '../utils/slicer';
 import { openInSlicer, type SlicerType } from '../utils/slicer';
 import type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates';
 import type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates';
 
 
@@ -477,7 +477,7 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
                           >
                           >
                             {plate.has_thumbnail && plate.thumbnail_url ? (
                             {plate.has_thumbnail && plate.thumbnail_url ? (
                               <img
                               <img
-                                src={plate.thumbnail_url}
+                                src={withStreamToken(plate.thumbnail_url)}
                                 alt={`Plate ${plate.index}`}
                                 alt={`Plate ${plate.index}`}
                                 className={`${splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'} rounded object-cover bg-bambu-dark-tertiary`}
                                 className={`${splitFullscreen ? 'w-8 h-8' : 'w-10 h-10'} rounded object-cover bg-bambu-dark-tertiary`}
                               />
                               />

+ 6 - 3
frontend/src/components/PrintModal/FilamentMapping.tsx

@@ -243,8 +243,10 @@ export function FilamentMapping({
                 <span title={`Required: ${resolvedName} - ${colorLabel}`}>
                 <span title={`Required: ${resolvedName} - ${colorLabel}`}>
                   <Circle className="w-3 h-3" fill={item.color} stroke={item.color} />
                   <Circle className="w-3 h-3" fill={item.color} stroke={item.color} />
                 </span>
                 </span>
-                {/* Required type + grams + nozzle badge */}
-                <span className="text-white truncate flex items-center gap-1">
+                {/* Required type + grams + nozzle badge. Only the name
+                    truncates; the gram usage is pinned (shrink-0) so it never
+                    clips on narrow/mobile widths (#2669). */}
+                <span className="text-white flex items-center gap-1 min-w-0">
                   {isDualNozzle && item.nozzle_id != null && (
                   {isDualNozzle && item.nozzle_id != null && (
                     <span
                     <span
                       className="inline-flex items-center justify-center w-3.5 h-3.5 rounded text-[9px] font-bold leading-none bg-bambu-gray/20 text-bambu-gray shrink-0"
                       className="inline-flex items-center justify-center w-3.5 h-3.5 rounded text-[9px] font-bold leading-none bg-bambu-gray/20 text-bambu-gray shrink-0"
@@ -253,7 +255,8 @@ export function FilamentMapping({
                       {item.nozzle_id === 1 ? t('printModal.leftNozzle') : t('printModal.rightNozzle')}
                       {item.nozzle_id === 1 ? t('printModal.leftNozzle') : t('printModal.rightNozzle')}
                     </span>
                     </span>
                   )}
                   )}
-                  {resolvedName} <span className="text-bambu-gray">({item.used_grams}g)</span>
+                  <span className="truncate min-w-0" title={resolvedName}>{resolvedName}</span>
+                  <span className="text-bambu-gray shrink-0 whitespace-nowrap">({item.used_grams}g)</span>
                 </span>
                 </span>
                 {/* Arrow */}
                 {/* Arrow */}
                 <span className="text-bambu-gray">→</span>
                 <span className="text-bambu-gray">→</span>

+ 73 - 24
frontend/src/components/PrintModal/PrintOptions.tsx

@@ -1,15 +1,30 @@
 import { useState } from 'react';
 import { useState } from 'react';
 import { Settings, ChevronDown, ChevronUp, Flame } from 'lucide-react';
 import { Settings, ChevronDown, ChevronUp, Flame } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import type { PrintOptionsProps, PrintOptions as PrintOptionsType, PreheatOverride } from './types';
+import type {
+  PrintOptionsProps,
+  PrintOptions as PrintOptionsType,
+  PreheatOverride,
+  CalibrationMode,
+} from './types';
+import {
+  CALIBRATION_MODES,
+  CALIBRATION_MODE_ACTIVE,
+  CALIBRATION_MODE_INACTIVE,
+} from '../../utils/calibrationMode';
 
 
 type OptionConfig = {
 type OptionConfig = {
   key: keyof PrintOptionsType;
   key: keyof PrintOptionsType;
   label: string;
   label: string;
   desc: string;
   desc: string;
   dualNozzleOnly?: boolean;
   dualNozzleOnly?: boolean;
+  /** Tri-state (off/on/auto) rather than a plain on/off pair. */
+  tristate?: boolean;
 };
 };
 
 
+// On/off options render as the same button pair, minus the "auto" choice.
+const BOOLEAN_MODES = ['off', 'on'] as const;
+
 /**
 /**
  * Print options toggle panel with collapsible UI.
  * Print options toggle panel with collapsible UI.
  * Shows bed levelling, flow/vibration calibration, layer inspection, timelapse,
  * Shows bed levelling, flow/vibration calibration, layer inspection, timelapse,
@@ -27,18 +42,22 @@ export function PrintOptionsPanel({
   // Labels/descriptions reuse the settings.default* namespace — identical strings,
   // Labels/descriptions reuse the settings.default* namespace — identical strings,
   // already translated across all locales. Only nozzle_offset_cali is new (#1682).
   // already translated across all locales. Only nozzle_offset_cali is new (#1682).
   const printOptionsConfig: OptionConfig[] = [
   const printOptionsConfig: OptionConfig[] = [
-    { key: 'bed_levelling', label: t('settings.defaultBedLevelling'), desc: t('settings.defaultBedLevellingDesc') },
-    { key: 'flow_cali', label: t('settings.defaultFlowCali'), desc: t('settings.defaultFlowCaliDesc') },
+    { key: 'bed_levelling', label: t('settings.defaultBedLevelling'), desc: t('settings.defaultBedLevellingDesc'), tristate: true },
+    { key: 'flow_cali', label: t('settings.defaultFlowCali'), desc: t('settings.defaultFlowCaliDesc'), tristate: true },
     { key: 'vibration_cali', label: t('settings.defaultVibrationCali'), desc: t('settings.defaultVibrationCaliDesc') },
     { key: 'vibration_cali', label: t('settings.defaultVibrationCali'), desc: t('settings.defaultVibrationCaliDesc') },
     { key: 'layer_inspect', label: t('settings.defaultLayerInspect'), desc: t('settings.defaultLayerInspectDesc') },
     { key: 'layer_inspect', label: t('settings.defaultLayerInspect'), desc: t('settings.defaultLayerInspectDesc') },
     { key: 'timelapse', label: t('settings.defaultTimelapse'), desc: t('settings.defaultTimelapseDesc') },
     { key: 'timelapse', label: t('settings.defaultTimelapse'), desc: t('settings.defaultTimelapseDesc') },
-    { key: 'nozzle_offset_cali', label: t('settings.defaultNozzleOffsetCali'), desc: t('settings.defaultNozzleOffsetCaliDesc'), dualNozzleOnly: true },
+    { key: 'nozzle_offset_cali', label: t('settings.defaultNozzleOffsetCali'), desc: t('settings.defaultNozzleOffsetCaliDesc'), dualNozzleOnly: true, tristate: true },
   ];
   ];
 
 
   const visibleOptions = printOptionsConfig.filter(o => !o.dualNozzleOnly || showDualNozzleOptions);
   const visibleOptions = printOptionsConfig.filter(o => !o.dualNozzleOnly || showDualNozzleOptions);
 
 
-  const handleToggle = (key: keyof PrintOptionsType) => {
-    onChange({ ...options, [key]: !options[key] });
+  const handleToggle = (key: keyof PrintOptionsType, value: boolean) => {
+    onChange({ ...options, [key]: value });
+  };
+
+  const handleCalibrationMode = (key: keyof PrintOptionsType, mode: CalibrationMode) => {
+    onChange({ ...options, [key]: mode });
   };
   };
 
 
   const handlePreheatOverride = (next: PreheatOverride) => {
   const handlePreheatOverride = (next: PreheatOverride) => {
@@ -81,26 +100,56 @@ export function PrintOptionsPanel({
       </button>
       </button>
       {isExpanded && (
       {isExpanded && (
         <div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
         <div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
-          {visibleOptions.map(({ key, label, desc }) => (
-            <label key={key} className="flex items-center justify-between cursor-pointer group">
-              <div>
-                <span className="text-sm text-white">{label}</span>
-                <p className="text-xs text-bambu-gray">{desc}</p>
+          {visibleOptions.map(({ key, label, desc, tristate }) =>
+            tristate ? (
+              <div key={key} className="flex items-center justify-between gap-3">
+                <div>
+                  <span className="text-sm text-white">{label}</span>
+                  <p className="text-xs text-bambu-gray">{desc}</p>
+                </div>
+                <div className="flex gap-1 shrink-0">
+                  {CALIBRATION_MODES.map((mode) => (
+                    <button
+                      key={mode}
+                      type="button"
+                      onClick={() => handleCalibrationMode(key, mode)}
+                      className={`px-2.5 py-1 text-xs rounded transition-colors ${
+                        options[key as 'bed_levelling'] === mode
+                          ? CALIBRATION_MODE_ACTIVE[mode]
+                          : CALIBRATION_MODE_INACTIVE
+                      }`}
+                    >
+                      {t(`settings.calibrationMode_${mode}`)}
+                    </button>
+                  ))}
+                </div>
               </div>
               </div>
-              <div
-                className={`relative w-10 h-5 rounded-full transition-colors ${
-                  options[key as 'bed_levelling'] ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
-                }`}
-                onClick={() => handleToggle(key)}
-              >
-                <div
-                  className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
-                    options[key as 'bed_levelling'] ? 'translate-x-5' : 'translate-x-0.5'
-                  }`}
-                />
+            ) : (
+              <div key={key} className="flex items-center justify-between gap-3">
+                <div>
+                  <span className="text-sm text-white">{label}</span>
+                  <p className="text-xs text-bambu-gray">{desc}</p>
+                </div>
+                <div className="flex gap-1 shrink-0">
+                  {BOOLEAN_MODES.map((mode) => {
+                    const active = (options[key as 'vibration_cali'] ? 'on' : 'off') === mode;
+                    return (
+                      <button
+                        key={mode}
+                        type="button"
+                        onClick={() => handleToggle(key, mode === 'on')}
+                        className={`px-2.5 py-1 text-xs rounded transition-colors ${
+                          active ? CALIBRATION_MODE_ACTIVE[mode] : CALIBRATION_MODE_INACTIVE
+                        }`}
+                      >
+                        {t(`settings.calibrationMode_${mode}`)}
+                      </button>
+                    );
+                  })}
+                </div>
               </div>
               </div>
-            </label>
-          ))}
+            ),
+          )}
 
 
           {/* Preheat / heat-soak per-item override (#1468). Defaults to
           {/* Preheat / heat-soak per-item override (#1468). Defaults to
               'inherit' which means the global Settings → Workflow toggle
               'inherit' which means the global Settings → Workflow toggle

+ 5 - 2
frontend/src/components/PrintModal/PrinterSelector.tsx

@@ -161,8 +161,11 @@ function InlineMappingEditor({
           <span title={`Required: ${req.type} - ${getColorName(req.color)}`}>
           <span title={`Required: ${req.type} - ${getColorName(req.color)}`}>
             <Circle className="w-3 h-3" fill={req.color} stroke={req.color} />
             <Circle className="w-3 h-3" fill={req.color} stroke={req.color} />
           </span>
           </span>
-          <span className="text-white truncate">
-            {req.type} <span className="text-bambu-gray">({req.used_grams}g)</span>
+          {/* Only the name truncates; the gram usage is pinned (shrink-0) so
+              it never clips on narrow/mobile widths (#2669). */}
+          <span className="text-white flex items-center gap-1 min-w-0">
+            <span className="truncate min-w-0" title={req.type}>{req.type}</span>
+            <span className="text-bambu-gray shrink-0 whitespace-nowrap">({req.used_grams}g)</span>
           </span>
           </span>
           <span className="text-bambu-gray">→</span>
           <span className="text-bambu-gray">→</span>
           <select
           <select

+ 9 - 7
frontend/src/components/PrintModal/types.ts

@@ -1,4 +1,6 @@
-import type { PrintQueueItem, Printer } from '../../api/client';
+import type { PrintQueueItem, Printer, CalibrationMode } from '../../api/client';
+
+export type { CalibrationMode };
 
 
 /**
 /**
  * Mode of operation for the PrintModal.
  * Mode of operation for the PrintModal.
@@ -44,12 +46,12 @@ export interface PrintModalProps {
 export type PreheatOverride = 'inherit' | 'on' | 'off';
 export type PreheatOverride = 'inherit' | 'on' | 'off';
 
 
 export interface PrintOptions {
 export interface PrintOptions {
-  bed_levelling: boolean;
-  flow_cali: boolean;
+  bed_levelling: CalibrationMode;
+  flow_cali: CalibrationMode;
   vibration_cali: boolean;
   vibration_cali: boolean;
   layer_inspect: boolean;
   layer_inspect: boolean;
   timelapse: boolean;
   timelapse: boolean;
-  nozzle_offset_cali: boolean;
+  nozzle_offset_cali: CalibrationMode;
   // Per-item preheat / heat-soak override (#1468). 'inherit' uses the global
   // Per-item preheat / heat-soak override (#1468). 'inherit' uses the global
   // Settings → Workflow toggle; 'on' / 'off' force the per-print decision.
   // Settings → Workflow toggle; 'on' / 'off' force the per-print decision.
   // chamber_target_override is non-null to bypass the per-filament-type
   // chamber_target_override is non-null to bypass the per-filament-type
@@ -62,12 +64,12 @@ export interface PrintOptions {
  * Default print options values.
  * Default print options values.
  */
  */
 export const DEFAULT_PRINT_OPTIONS: PrintOptions = {
 export const DEFAULT_PRINT_OPTIONS: PrintOptions = {
-  bed_levelling: true,
-  flow_cali: false,
+  bed_levelling: 'auto',
+  flow_cali: 'auto',
   vibration_cali: true,
   vibration_cali: true,
   layer_inspect: false,
   layer_inspect: false,
   timelapse: false,
   timelapse: false,
-  nozzle_offset_cali: true,
+  nozzle_offset_cali: 'auto',
   preheat_override: 'inherit',
   preheat_override: 'inherit',
   preheat_chamber_target_override: null,
   preheat_chamber_target_override: null,
 };
 };

+ 3 - 2
frontend/src/components/PrinterQueueWidget.tsx

@@ -11,10 +11,11 @@ interface PrinterQueueWidgetProps {
   printerModel?: string | null;
   printerModel?: string | null;
   loadedFilamentTypes?: Set<string>;
   loadedFilamentTypes?: Set<string>;
   loadedFilaments?: Set<string>;  // "TYPE:rrggbb" pairs for filament override color matching
   loadedFilaments?: Set<string>;  // "TYPE:rrggbb" pairs for filament override color matching
+  loadedVariants?: Set<string>;  // "TYPE:rrggbb:idx" triples for PLA sub-variant matching (#2650)
   variant?: 'card' | 'panelExtension';
   variant?: 'card' | 'panelExtension';
 }
 }
 
 
-export function PrinterQueueWidget({ printerId, printerModel, loadedFilamentTypes, loadedFilaments, variant = 'card' }: PrinterQueueWidgetProps) {
+export function PrinterQueueWidget({ printerId, printerModel, loadedFilamentTypes, loadedFilaments, loadedVariants, variant = 'card' }: PrinterQueueWidgetProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { data: queue } = useQuery({
   const { data: queue } = useQuery({
     queryKey: ['queue', printerId, 'pending', printerModel],
     queryKey: ['queue', printerId, 'pending', printerModel],
@@ -23,7 +24,7 @@ export function PrinterQueueWidget({ printerId, printerModel, loadedFilamentType
   });
   });
 
 
   // Filter queue to items this printer can actually print (filament type + color check)
   // Filter queue to items this printer can actually print (filament type + color check)
-  const compatibleQueue = queue ? filterCompatibleQueueItems(queue, loadedFilamentTypes, loadedFilaments) : undefined;
+  const compatibleQueue = queue ? filterCompatibleQueueItems(queue, loadedFilamentTypes, loadedFilaments, loadedVariants) : undefined;
   const totalPending = compatibleQueue?.length || 0;
   const totalPending = compatibleQueue?.length || 0;
 
 
   if (totalPending === 0) {
   if (totalPending === 0) {

+ 308 - 312
frontend/src/components/SkipObjectsModal.tsx

@@ -1,20 +1,18 @@
-import { useState } from 'react';
-import { useQuery, useMutation } from '@tanstack/react-query';
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { useMutation, useQuery } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { X, Loader2, Monitor, AlertCircle, Box, Maximize2 } from 'lucide-react';
+import { AlertCircle, Box, CheckSquare, Loader2, Maximize2, Square, X } from 'lucide-react';
 import { api, withStreamToken } from '../api/client';
 import { api, withStreamToken } from '../api/client';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
+import { pickObjectIdAt, plateClickToMaskPoint } from '../utils/skipObjects';
 import { ConfirmModal } from './ConfirmModal';
 import { ConfirmModal } from './ConfirmModal';
 
 
-// Custom Skip Objects icon - arrow jumping over boxes
 export const SkipObjectsIcon = ({ className }: { className?: string }) => (
 export const SkipObjectsIcon = ({ className }: { className?: string }) => (
   <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
   <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
-    {/* Three boxes at the bottom */}
     <rect x="2" y="15" width="5" height="5" rx="0.5" />
     <rect x="2" y="15" width="5" height="5" rx="0.5" />
     <rect x="9.5" y="15" width="5" height="5" rx="0.5" fill="currentColor" opacity="0.3" />
     <rect x="9.5" y="15" width="5" height="5" rx="0.5" fill="currentColor" opacity="0.3" />
     <rect x="17" y="15" width="5" height="5" rx="0.5" />
     <rect x="17" y="15" width="5" height="5" rx="0.5" />
-    {/* Curved arrow jumping over first box */}
     <path d="M4 12 C4 6, 14 6, 14 12" />
     <path d="M4 12 C4 6, 14 6, 14 12" />
     <polyline points="12,10 14,12 12,14" />
     <polyline points="12,10 14,12 12,14" />
   </svg>
   </svg>
@@ -26,12 +24,25 @@ interface SkipObjectsModalProps {
   onClose: () => void;
   onClose: () => void;
 }
 }
 
 
+interface PrintableObject {
+  id: number;
+  name: string;
+  x: number | null;
+  y: number | null;
+  skipped: boolean;
+}
+
 export function SkipObjectsModal({ printerId, isOpen, onClose }: SkipObjectsModalProps) {
 export function SkipObjectsModal({ printerId, isOpen, onClose }: SkipObjectsModalProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { showToast } = useToast();
   const { showToast } = useToast();
   const { hasPermission } = useAuth();
   const { hasPermission } = useAuth();
-  const [pendingSkip, setPendingSkip] = useState<{ id: number; name: string } | null>(null);
+  const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
+  const [confirming, setConfirming] = useState(false);
   const [enlarged, setEnlarged] = useState(false);
   const [enlarged, setEnlarged] = useState(false);
+  const [pickReady, setPickReady] = useState(false);
+  const pickDataRef = useRef<ImageData | null>(null);
+  const overlayRef = useRef<HTMLCanvasElement | null>(null);
+  const enlargedOverlayRef = useRef<HTMLCanvasElement | null>(null);
 
 
   const { data: status } = useQuery({
   const { data: status } = useQuery({
     queryKey: ['printerStatus', printerId],
     queryKey: ['printerStatus', printerId],
@@ -47,338 +58,323 @@ export function SkipObjectsModal({ printerId, isOpen, onClose }: SkipObjectsModa
     refetchInterval: isOpen ? 5000 : false,
     refetchInterval: isOpen ? 5000 : false,
   });
   });
 
 
+  const hasObjects = (objectsData?.objects.length ?? 0) > 0;
+  const topViewUrl = hasObjects && status?.cover_url
+    ? withStreamToken(`${status.cover_url}?view=top`)
+    : null;
+  const pickViewUrl = hasObjects && status?.cover_url
+    ? withStreamToken(`${status.cover_url}?view=pick`)
+    : null;
+
+  const activeObjects = useMemo(
+    () => (objectsData?.objects ?? []).filter((object) => !object.skipped),
+    [objectsData],
+  );
+  const selectedObjects = useMemo(
+    () => activeObjects.filter((object) => selectedIds.has(object.id)),
+    [activeObjects, selectedIds],
+  );
+  const allSelected = activeObjects.length > 0 && selectedObjects.length === activeObjects.length;
+  const skippingAllRemaining = allSelected && selectedIds.size > 0;
+  const canSubmit = selectedIds.size > 0
+    && (status?.layer_num ?? 0) > 1
+    && hasPermission('printers:control');
+
   const skipObjectsMutation = useMutation({
   const skipObjectsMutation = useMutation({
     mutationFn: (objectIds: number[]) => api.skipObjects(printerId, objectIds),
     mutationFn: (objectIds: number[]) => api.skipObjects(printerId, objectIds),
-    onSuccess: (data) => {
+    onSuccess: async (data) => {
       showToast(data.message || t('printers.skipObjects.objectsSkipped'));
       showToast(data.message || t('printers.skipObjects.objectsSkipped'));
-      setPendingSkip(null);
-      refetchObjects();
+      // Refresh before closing: this modal is the only on-demand refetch of the
+      // shared printableObjects query, so the printer card behind it would keep
+      // showing the pre-skip count otherwise.
+      await refetchObjects();
+      setConfirming(false);
+      setSelectedIds(new Set());
+      onClose();
+    },
+    onError: (error: Error) => {
+      setConfirming(false);
+      showToast(error.message || t('printers.toast.failedToSkipObjects'), 'error');
     },
     },
-    onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSkipObjects'), 'error'),
   });
   });
 
 
-  if (!isOpen) return null;
+  useEffect(() => {
+    if (isOpen) return;
+    setSelectedIds(new Set());
+    setConfirming(false);
+    setEnlarged(false);
+  }, [isOpen]);
 
 
-  return (
-    <>
-    <div
-      className="fixed inset-0 z-50 flex items-center justify-center"
-      onClick={onClose}
-      onKeyDown={(e) => {
-        if (e.key === 'Escape') {
-          if (enlarged) setEnlarged(false);
-          else onClose();
-        }
-      }}
-      tabIndex={-1}
-      ref={(el) => el?.focus()}
-    >
-      {/* Backdrop */}
-      <div className="absolute inset-0 bg-black/50 z-0" />
-      {/* Modal */}
-      <div
-        className="relative z-10 bg-white dark:bg-bambu-dark border border-gray-200 dark:border-bambu-dark-tertiary rounded-xl shadow-2xl w-[560px] max-h-[85vh] flex flex-col overflow-hidden"
-        onClick={(e) => e.stopPropagation()}
-      >
-        {/* Header */}
-        <div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-bambu-dark-tertiary bg-gray-50 dark:bg-bambu-dark">
-          <div className="flex items-center gap-2">
-            <SkipObjectsIcon className="w-4 h-4 text-bambu-green" />
-            <span className="text-sm font-medium text-gray-900 dark:text-white">{t('printers.skipObjects.title')}</span>
-          </div>
-          <button
-            onClick={onClose}
-            className="p-1 text-gray-500 dark:text-bambu-gray hover:text-gray-900 dark:hover:text-white rounded transition-colors"
-          >
-            <X className="w-4 h-4" />
-          </button>
-        </div>
+  useEffect(() => {
+    pickDataRef.current = null;
+    setPickReady(false);
+    if (!isOpen || !pickViewUrl) return;
 
 
-        {!objectsData ? (
-          <div className="flex items-center justify-center py-12">
-            <Loader2 className="w-5 h-5 animate-spin text-bambu-gray" />
-          </div>
-        ) : objectsData.objects.length === 0 ? (
-          <div className="text-center py-8 px-4 text-bambu-gray">
-            <p className="text-sm">{t('printers.noObjectsFound')}</p>
-            <p className="text-xs mt-1 opacity-70">{t('printers.objectsLoadedOnPrintStart')}</p>
-          </div>
-        ) : (
-          <div className="flex flex-col overflow-hidden">
-            {/* Info Banner */}
-            <div className="flex items-center gap-3 px-4 py-2.5 bg-blue-50 dark:bg-blue-500/10 border-b border-gray-200 dark:border-bambu-dark-tertiary">
-              <div className="flex-shrink-0 w-8 h-8 rounded-lg bg-blue-100 dark:bg-blue-500/20 flex items-center justify-center">
-                <Monitor className="w-4 h-4 text-blue-500 dark:text-blue-400" />
-              </div>
-              <div className="flex-1 min-w-0">
-                <p className="text-xs text-blue-600 dark:text-blue-300">{t('printers.skipObjects.matchIdsInfo')}</p>
-                <p className="text-[10px] text-blue-500/70 dark:text-blue-300/60">{t('printers.skipObjects.printerShowsIds')}</p>
-              </div>
-              <div className="flex-shrink-0 text-xs text-gray-500 dark:text-bambu-gray">
-                {objectsData.skipped_count}/{objectsData.total} {t('printers.skipObjects.skipped')}
-              </div>
-            </div>
-
-            {/* Layer Warning */}
-            {(status?.layer_num ?? 0) <= 1 && (
-              <div className="flex items-center gap-2 px-4 py-2 bg-amber-50 dark:bg-amber-500/10 border-b border-gray-200 dark:border-bambu-dark-tertiary">
-                <AlertCircle className="w-4 h-4 text-amber-500 dark:text-amber-400 flex-shrink-0" />
-                <p className="text-xs text-amber-600 dark:text-amber-400">
-                  {t('printers.skipObjects.waitForLayer', { layer: status?.layer_num ?? 0 })}
-                </p>
-              </div>
-            )}
-
-            {/* Content: Image + List side by side */}
-            <div className="flex flex-1 overflow-hidden">
-              {/* Left: Preview Image with object markers */}
-              <div className="w-52 flex-shrink-0 p-4 border-r border-gray-200 dark:border-bambu-dark-tertiary bg-gray-50 dark:bg-bambu-dark-secondary overflow-y-auto">
-                <div className="relative cursor-pointer group" onClick={() => setEnlarged(true)}>
-                  {status?.cover_url ? (
-                    <img
-                      src={withStreamToken(`${status.cover_url}?view=top`)}
-                      alt={t('printers.printPreview')}
-                      className="w-full aspect-square object-contain rounded-lg bg-gray-900 dark:bg-gray-900 border border-gray-300 dark:border-gray-600"
-                    />
-                  ) : (
-                    <div className="w-full aspect-square rounded-lg bg-gray-100 dark:bg-bambu-dark flex items-center justify-center">
-                      <Box className="w-8 h-8 text-gray-300 dark:text-bambu-gray/30" />
-                    </div>
-                  )}
-                  {/* Enlarge hint */}
-                  <div className="absolute top-2 right-2 p-1 bg-black/60 rounded opacity-0 group-hover:opacity-100 transition-opacity">
-                    <Maximize2 className="w-3.5 h-3.5 text-white" />
-                  </div>
-                  {/* Object ID markers overlay - positioned based on object data */}
-                  {objectsData.objects.length > 0 && (
-                    <div className="absolute inset-0 pointer-events-none">
-                      {objectsData.objects.map((obj, idx) => {
-                        let x: number, y: number;
+    let cancelled = false;
+    const image = new Image();
+    image.onload = () => {
+      if (cancelled) return;
+      const canvas = document.createElement('canvas');
+      canvas.width = image.naturalWidth || 512;
+      canvas.height = image.naturalHeight || 512;
+      const context = canvas.getContext('2d', { willReadFrequently: true });
+      if (!context) return;
+      context.drawImage(image, 0, 0);
+      pickDataRef.current = context.getImageData(0, 0, canvas.width, canvas.height);
+      setPickReady(true);
+    };
+    image.onerror = () => {
+      if (!cancelled) setPickReady(false);
+    };
+    image.src = pickViewUrl;
 
 
-                        // Use position data if available, otherwise fall back to grid
-                        if (obj.x != null && obj.y != null && objectsData.bbox_all) {
-                          // bbox_all defines the visible area in the top_N.png image
-                          // Format: [x_min, y_min, x_max, y_max] in mm
-                          const [xMin, yMin, xMax, yMax] = objectsData.bbox_all;
-                          const bboxWidth = xMax - xMin;
-                          const bboxHeight = yMax - yMin;
+    return () => {
+      cancelled = true;
+    };
+  }, [isOpen, pickViewUrl]);
 
 
-                          // The image shows bbox_all area with some padding (~5-10%)
-                          const padding = 8;
-                          const contentArea = 100 - (padding * 2);
+  useEffect(() => {
+    const pickData = pickDataRef.current;
+    if (!pickData || !objectsData) return;
 
 
-                          // Map object position to image percentage
-                          x = padding + ((obj.x - xMin) / bboxWidth) * contentArea;
-                          // Y axis: image Y increases downward, but 3D Y increases toward back
-                          y = padding + ((yMax - obj.y) / bboxHeight) * contentArea;
+    const skippedIds = new Set(objectsData.objects.filter((object) => object.skipped).map((object) => object.id));
+    for (const canvas of [overlayRef.current, enlargedOverlayRef.current]) {
+      if (!canvas) continue;
+      canvas.width = pickData.width;
+      canvas.height = pickData.height;
+      const context = canvas.getContext('2d');
+      if (!context) continue;
+      const overlay = context.createImageData(pickData.width, pickData.height);
 
 
-                          // Clamp to valid range
-                          x = Math.max(5, Math.min(95, x));
-                          y = Math.max(5, Math.min(95, y));
-                        } else if (obj.x != null && obj.y != null) {
-                          // Fallback: use full build plate (256mm)
-                          const buildPlate = 256;
-                          x = (obj.x / buildPlate) * 100;
-                          y = 100 - (obj.y / buildPlate) * 100;
-                          x = Math.max(5, Math.min(95, x));
-                          y = Math.max(5, Math.min(95, y));
-                        } else {
-                          // Fallback: arrange in a grid pattern over the build plate area
-                          const cols = Math.ceil(Math.sqrt(objectsData.objects.length));
-                          const row = Math.floor(idx / cols);
-                          const col = idx % cols;
-                          const rows = Math.ceil(objectsData.objects.length / cols);
-                          x = 15 + (col * (70 / cols)) + (35 / cols);
-                          y = 15 + (row * (70 / rows)) + (35 / rows);
-                        }
+      for (let offset = 0; offset < pickData.data.length; offset += 4) {
+        const objectId = pickData.data[offset] + (pickData.data[offset + 1] << 8) + (pickData.data[offset + 2] << 16);
+        const selected = selectedIds.has(objectId);
+        const skipped = skippedIds.has(objectId);
+        if (!selected && !skipped) continue;
+        const pixel = offset / 4;
+        const x = pixel % pickData.width;
+        const y = Math.floor(pixel / pickData.width);
+        const stripe = ((x + y) % 14) < 7;
+        overlay.data[offset] = selected ? (stripe ? 37 : 74) : 148;
+        overlay.data[offset + 1] = selected ? (stripe ? 199 : 222) : 163;
+        overlay.data[offset + 2] = selected ? (stripe ? 91 : 128) : 184;
+        overlay.data[offset + 3] = selected ? (stripe ? 205 : 145) : 175;
+      }
+      context.putImageData(overlay, 0, 0);
+    }
+  }, [enlarged, objectsData, pickReady, selectedIds]);
 
 
-                        return (
-                          <div
-                            key={obj.id}
-                            className={`absolute flex items-center justify-center w-6 h-6 rounded-full text-[10px] font-bold shadow-lg ${
-                              obj.skipped
-                                ? 'bg-red-500 text-white line-through'
-                                : 'bg-bambu-green text-black'
-                            }`}
-                            style={{
-                              left: `${x}%`,
-                              top: `${y}%`,
-                              transform: 'translate(-50%, -50%)'
-                            }}
-                            title={obj.name}
-                          >
-                            {obj.id}
-                          </div>
-                        );
-                      })}
-                    </div>
-                  )}
-                  {/* Object count overlay */}
-                  <div className="absolute bottom-2 right-2 px-2 py-1 bg-white/90 dark:bg-black/80 rounded text-[10px] text-gray-700 dark:text-white shadow-sm">
-                    {t('printers.skipObjects.activeCount', { count: objectsData.objects.filter(o => !o.skipped).length })}
-                  </div>
-                </div>
-              </div>
-
-              {/* Right: Object List with prominent IDs */}
-              <div className="flex-1 min-w-0 overflow-y-auto">
-                {objectsData.objects.map((obj) => (
-                  <div
-                    key={obj.id}
-                    className={`
-                      flex items-center gap-3 px-4 py-3 border-b border-gray-200 dark:border-bambu-dark-tertiary/50 last:border-0
-                      ${obj.skipped ? 'bg-red-50 dark:bg-red-500/10' : 'hover:bg-gray-50 dark:hover:bg-bambu-dark/50'}
-                    `}
-                  >
-                    {/* Large prominent ID badge */}
-                    <div className={`
-                      w-12 h-12 flex-shrink-0 rounded-lg flex flex-col items-center justify-center
-                      ${obj.skipped
-                        ? 'bg-red-100 dark:bg-red-500/20 border border-red-300 dark:border-red-500/40'
-                        : 'bg-green-100 dark:bg-bambu-green/20 border border-green-300 dark:border-bambu-green/40'}
-                    `}>
-                      <span className={`text-lg font-mono font-bold ${obj.skipped ? 'text-red-500 dark:text-red-400' : 'text-green-600 dark:text-bambu-green'}`}>
-                        {obj.id}
-                      </span>
-                      <span className={`text-[8px] uppercase tracking-wider ${obj.skipped ? 'text-red-700/70 dark:text-red-400/60' : 'text-green-500/60 dark:text-bambu-green/60'}`}>
-                        ID
-                      </span>
-                    </div>
+  const toggleObject = (object: PrintableObject) => {
+    if (object.skipped) return;
+    setSelectedIds((current) => {
+      const next = new Set(current);
+      if (next.has(object.id)) next.delete(object.id);
+      else next.add(object.id);
+      return next;
+    });
+  };
 
 
-                    {/* Object name and status */}
-                    <div className="flex-1 min-w-0">
-                      <span className={`block text-sm truncate ${obj.skipped ? 'text-red-500 dark:text-red-400 line-through' : 'text-gray-900 dark:text-white'}`}>
-                        {obj.name}
-                      </span>
-                      {obj.skipped && (
-                        <span className="text-[10px] text-red-700/70 dark:text-red-400/60">{t('printers.willBeSkipped')}</span>
-                      )}
-                    </div>
+  const toggleFromPlate = (event: React.MouseEvent<HTMLCanvasElement>) => {
+    const pickData = pickDataRef.current;
+    if (!pickData) return;
+    const point = plateClickToMaskPoint(
+      event.currentTarget.getBoundingClientRect(),
+      pickData.width,
+      pickData.height,
+      event.clientX,
+      event.clientY,
+    );
+    if (!point) return;
+    const objectId = pickObjectIdAt(pickData, point.x, point.y);
+    const object = objectsData?.objects.find((candidate) => candidate.id === objectId);
+    if (object) toggleObject(object);
+  };
 
 
-                    {/* Skip button */}
-                    {!obj.skipped ? (
-                      <button
-                        onClick={() => setPendingSkip({ id: obj.id, name: obj.name })}
-                        disabled={skipObjectsMutation.isPending || (status?.layer_num ?? 0) <= 1 || !hasPermission('printers:control')}
-                        className={`px-4 py-2 text-xs font-medium rounded-lg transition-colors ${
-                          (status?.layer_num ?? 0) <= 1 || !hasPermission('printers:control')
-                            ? 'bg-gray-100 dark:bg-bambu-dark text-gray-400 dark:text-bambu-gray/50 cursor-not-allowed'
-                            : 'bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-500/30 border border-red-300 dark:border-red-500/30'
-                        }`}
-                        title={!hasPermission('printers:control') ? t('printers.permission.noControl') : ((status?.layer_num ?? 0) <= 1 ? t('printers.skipObjects.waitForLayer', { layer: status?.layer_num ?? 0 }) : t('printers.skipObjects.skip'))}
-                      >
-                        {t('printers.skipObjects.skip')}
-                      </button>
-                    ) : (
-                      <span className="px-4 py-2 text-xs text-red-500 dark:text-red-400/70 bg-red-100 dark:bg-red-500/10 rounded-lg">
-                        {t('printers.skipObjects.skipped')}
-                      </span>
-                    )}
-                  </div>
-                ))}
-              </div>
-            </div>
-          </div>
-        )}
-      </div>
-    </div>
-    {pendingSkip && (
-      <ConfirmModal
-        variant="warning"
-        title={t('printers.skipObjects.confirmTitle')}
-        message={t('printers.skipObjects.confirmMessage', { name: pendingSkip.name })}
-        confirmText={t('printers.skipObjects.skip')}
-        isLoading={skipObjectsMutation.isPending}
-        onConfirm={() => skipObjectsMutation.mutate([pendingSkip.id])}
-        onCancel={() => setPendingSkip(null)}
+  const renderPlate = (large = false) => (
+    <div className={`relative aspect-square overflow-hidden rounded-lg border border-gray-300 bg-gray-900 dark:border-gray-600 ${pickReady ? 'cursor-crosshair' : ''}`}>
+      {topViewUrl ? (
+        <img src={topViewUrl} alt={t('printers.printPreview')} className="absolute inset-0 h-full w-full object-contain" />
+      ) : (
+        <div className="absolute inset-0 flex items-center justify-center">
+          <Box className="h-10 w-10 text-gray-500" />
+        </div>
+      )}
+      <canvas
+        ref={large ? enlargedOverlayRef : overlayRef}
+        onClick={toggleFromPlate}
+        className="absolute inset-0 h-full w-full object-contain"
+        aria-label={t('printers.skipObjects.selectObjectsToSkip')}
       />
       />
-    )}
-    {/* Enlarged lightbox overlay */}
-    {enlarged && objectsData && (
-      <div
-        className="fixed inset-0 bg-black/90 flex items-center justify-center z-60"
-        onClick={() => setEnlarged(false)}
-      >
+      {!large && topViewUrl && (
         <button
         <button
-          onClick={() => setEnlarged(false)}
-          className="absolute top-4 right-4 p-2 text-white/70 hover:text-white transition-colors"
+          type="button"
+          onClick={(event) => {
+            event.stopPropagation();
+            setEnlarged(true);
+          }}
+          className="absolute right-2 top-2 rounded bg-black/65 p-1.5 text-white/80 hover:text-white"
+          title={t('common.expand')}
         >
         >
-          <X className="w-6 h-6" />
+          <Maximize2 className="h-4 w-4" />
         </button>
         </button>
-        <div
-          className="relative max-w-[600px] max-h-[80vh] aspect-square"
-          onClick={(e) => e.stopPropagation()}
+      )}
+      {!pickReady && topViewUrl && (
+        <div className="absolute bottom-2 left-2 rounded bg-black/70 px-2 py-1 text-[10px] text-white/80">
+          {t('common.unavailable')}
+        </div>
+      )}
+    </div>
+  );
+
+  if (!isOpen) return null;
+
+  return (
+    <>
+      <div
+        className="fixed inset-0 z-50 flex items-center justify-center p-4"
+        onClick={onClose}
+        onKeyDown={(event) => {
+          if (event.key !== 'Escape') return;
+          if (enlarged) setEnlarged(false);
+          else onClose();
+        }}
+        tabIndex={-1}
+        ref={(element) => element?.focus()}
+      >
+        <div className="absolute inset-0 bg-black/55" />
+        <section
+          className="relative z-10 flex max-h-[88vh] w-full max-w-[980px] flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-2xl dark:border-bambu-dark-tertiary dark:bg-bambu-dark"
+          onClick={(event) => event.stopPropagation()}
+          aria-label={t('printers.skipObjects.title')}
         >
         >
-          {status?.cover_url ? (
-            <img
-              src={withStreamToken(`${status.cover_url}?view=top`)}
-              alt={t('printers.printPreview')}
-              className="w-full h-full object-contain rounded-lg bg-gray-900"
-            />
-          ) : (
-            <div className="w-full h-full rounded-lg bg-gray-800 flex items-center justify-center">
-              <Box className="w-16 h-16 text-gray-500" />
+          <header className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-3 dark:border-bambu-dark-tertiary dark:bg-bambu-dark">
+            <div className="flex items-center gap-2">
+              <SkipObjectsIcon className="h-5 w-5 text-bambu-green" />
+              <div>
+                <h2 className="text-sm font-semibold text-gray-900 dark:text-white">{t('printers.skipObjects.title')}</h2>
+                <p className="text-xs text-gray-500 dark:text-bambu-gray">{t('printers.skipObjects.selectObjectsToSkip')}</p>
+              </div>
             </div>
             </div>
-          )}
-          {/* Object ID markers overlay */}
-          {objectsData.objects.length > 0 && (
-            <div className="absolute inset-0 pointer-events-none">
-              {objectsData.objects.map((obj, idx) => {
-                let x: number, y: number;
+            <button type="button" onClick={onClose} className="rounded p-1 text-gray-500 hover:text-gray-900 dark:text-bambu-gray dark:hover:text-white">
+              <X className="h-5 w-5" />
+            </button>
+          </header>
 
 
-                if (obj.x != null && obj.y != null && objectsData.bbox_all) {
-                  const [xMin, yMin, xMax, yMax] = objectsData.bbox_all;
-                  const bboxWidth = xMax - xMin;
-                  const bboxHeight = yMax - yMin;
-                  const padding = 8;
-                  const contentArea = 100 - (padding * 2);
-                  x = padding + ((obj.x - xMin) / bboxWidth) * contentArea;
-                  y = padding + ((yMax - obj.y) / bboxHeight) * contentArea;
-                  x = Math.max(5, Math.min(95, x));
-                  y = Math.max(5, Math.min(95, y));
-                } else if (obj.x != null && obj.y != null) {
-                  const buildPlate = 256;
-                  x = (obj.x / buildPlate) * 100;
-                  y = 100 - (obj.y / buildPlate) * 100;
-                  x = Math.max(5, Math.min(95, x));
-                  y = Math.max(5, Math.min(95, y));
-                } else {
-                  const cols = Math.ceil(Math.sqrt(objectsData.objects.length));
-                  const row = Math.floor(idx / cols);
-                  const col = idx % cols;
-                  const rows = Math.ceil(objectsData.objects.length / cols);
-                  x = 15 + (col * (70 / cols)) + (35 / cols);
-                  y = 15 + (row * (70 / rows)) + (35 / rows);
-                }
+          {!objectsData ? (
+            <div className="flex items-center justify-center py-16">
+              <Loader2 className="h-6 w-6 animate-spin text-bambu-gray" />
+            </div>
+          ) : objectsData.objects.length === 0 ? (
+            <div className="px-4 py-12 text-center text-bambu-gray">
+              <p className="text-sm">{t('printers.noObjectsFound')}</p>
+              <p className="mt-1 text-xs opacity-70">{t('printers.objectsLoadedOnPrintStart')}</p>
+            </div>
+          ) : (
+            <>
+              {(status?.layer_num ?? 0) <= 1 && (
+                <div className="flex items-center gap-2 border-b border-amber-400/20 bg-amber-500/10 px-4 py-2 text-xs text-amber-400">
+                  <AlertCircle className="h-4 w-4 flex-shrink-0" />
+                  {t('printers.skipObjects.waitForLayer', { layer: status?.layer_num ?? 0 })}
+                </div>
+              )}
+              <div className="grid min-h-0 flex-1 grid-cols-[minmax(320px,1fr)_minmax(340px,0.9fr)] overflow-hidden max-md:grid-cols-1 max-md:overflow-y-auto">
+                <div className="border-r border-gray-200 bg-gray-50 p-4 dark:border-bambu-dark-tertiary dark:bg-bambu-dark-secondary max-md:border-b-0 max-md:border-r-0">
+                  {renderPlate()}
+                  <div className="mt-3 flex items-center justify-between text-xs text-gray-500 dark:text-bambu-gray">
+                    <span>{selectedIds.size}/{activeObjects.length}</span>
+                    <span>{objectsData.skipped_count} {t('printers.skipObjects.skipped')}</span>
+                  </div>
+                </div>
 
 
-                return (
-                  <div
-                    key={obj.id}
-                    className={`absolute flex items-center justify-center w-6 h-6 rounded-full text-[10px] font-bold shadow-lg ${
-                      obj.skipped
-                        ? 'bg-red-500 text-white line-through'
-                        : 'bg-bambu-green text-black'
-                    }`}
-                    style={{
-                      left: `${x}%`,
-                      top: `${y}%`,
-                      transform: 'translate(-50%, -50%)'
-                    }}
-                    title={obj.name}
+                <div className="flex min-h-0 flex-col">
+                  <button
+                    type="button"
+                    onClick={() => setSelectedIds(allSelected ? new Set() : new Set(activeObjects.map((object) => object.id)))}
+                    className="flex items-center gap-3 border-b border-gray-200 px-4 py-3 text-left text-sm font-semibold text-gray-900 hover:bg-gray-50 dark:border-bambu-dark-tertiary dark:text-white dark:hover:bg-white/5"
                   >
                   >
-                    {obj.id}
+                    {allSelected ? <CheckSquare className="h-5 w-5 text-bambu-green" /> : <Square className="h-5 w-5 text-bambu-gray" />}
+                    <span className="flex-1">{allSelected ? t('common.deselectAll') : t('common.selectAll')}</span>
+                    <span className="text-xs font-normal text-bambu-gray">{activeObjects.length}</span>
+                  </button>
+                  <div className="min-h-0 flex-1 overflow-y-auto">
+                    {objectsData.objects.map((object, index) => {
+                      const selected = selectedIds.has(object.id);
+                      return (
+                        <button
+                          type="button"
+                          key={object.id}
+                          onClick={() => toggleObject(object)}
+                          disabled={object.skipped}
+                          className={`flex w-full items-center gap-3 border-b border-gray-200 px-4 py-2.5 text-left transition-colors dark:border-bambu-dark-tertiary/60 ${
+                            object.skipped
+                              ? 'cursor-not-allowed bg-red-500/5 opacity-55'
+                              : selected
+                                ? 'bg-bambu-green/15 hover:bg-bambu-green/20'
+                                : 'hover:bg-gray-50 dark:hover:bg-white/5'
+                          }`}
+                        >
+                          {object.skipped || selected
+                            ? <CheckSquare className={`h-5 w-5 flex-shrink-0 ${object.skipped ? 'text-red-400' : 'text-bambu-green'}`} />
+                            : <Square className="h-5 w-5 flex-shrink-0 text-bambu-gray" />}
+                          <span className="w-8 flex-shrink-0 text-xs font-bold text-bambu-gray">{index + 1}</span>
+                          <span className={`min-w-0 flex-1 truncate text-sm ${object.skipped ? 'text-red-400 line-through' : 'text-gray-900 dark:text-white'}`}>{object.name}</span>
+                          <span className="text-[10px] text-bambu-gray">ID {object.id}</span>
+                        </button>
+                      );
+                    })}
                   </div>
                   </div>
-                );
-              })}
-            </div>
+                </div>
+              </div>
+
+              <footer className="flex items-center gap-3 border-t border-gray-200 bg-gray-50 px-4 py-3 dark:border-bambu-dark-tertiary dark:bg-bambu-dark">
+                <span className="mr-auto text-sm text-gray-600 dark:text-bambu-gray">
+                  {selectedIds.size === 0 ? t('printers.skipObjects.noObjectsSelected') : `${selectedIds.size}/${activeObjects.length}`}
+                </span>
+                <button type="button" onClick={onClose} className="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:border-bambu-dark-tertiary dark:text-white dark:hover:bg-white/5">
+                  {t('common.cancel')}
+                </button>
+                <button
+                  type="button"
+                  onClick={() => setConfirming(true)}
+                  disabled={!canSubmit || skipObjectsMutation.isPending}
+                  className="rounded-md bg-red-500 px-4 py-2 text-sm font-semibold text-white hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-35"
+                >
+                  {skipObjectsMutation.isPending ? t('printers.skipObjects.skipping') : t('printers.skipObjects.skipSelected')}
+                </button>
+              </footer>
+            </>
           )}
           )}
-          {/* Active count badge */}
-          <div className="absolute bottom-2 right-2 px-2 py-1 bg-white/90 dark:bg-black/80 rounded text-[10px] text-gray-700 dark:text-white shadow-sm">
-            {t('printers.skipObjects.activeCount', { count: objectsData.objects.filter(o => !o.skipped).length })}
+        </section>
+      </div>
+
+      {confirming && (
+        <ConfirmModal
+          variant="warning"
+          title={t('printers.skipObjects.confirmTitle')}
+          message={skippingAllRemaining
+            ? t('printers.skipObjects.confirmAllMessage')
+            : selectedObjects.length === 1
+              // Naming one object is useful; joining 30 is a wall of text, and
+              // plates of clones share a name, so the list identifies nothing.
+              ? t('printers.skipObjects.confirmMessage', { name: selectedObjects[0].name })
+              : t('printers.skipObjects.confirmMultipleMessage', { count: selectedObjects.length })}
+          confirmText={t('printers.skipObjects.skipSelected')}
+          isLoading={skipObjectsMutation.isPending}
+          onConfirm={() => skipObjectsMutation.mutate([...selectedIds])}
+          onCancel={() => setConfirming(false)}
+        />
+      )}
+
+      {enlarged && (
+        <div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/90 p-8" onClick={() => setEnlarged(false)}>
+          <button type="button" onClick={() => setEnlarged(false)} className="absolute right-4 top-4 rounded p-2 text-white/70 hover:text-white">
+            <X className="h-6 w-6" />
+          </button>
+          <div className="aspect-square max-h-[86vh] w-full max-w-[86vh]" onClick={(event) => event.stopPropagation()}>
+            {renderPlate(true)}
           </div>
           </div>
         </div>
         </div>
-      </div>
-    )}
-  </>
+      )}
+    </>
   );
   );
 }
 }

+ 23 - 0
frontend/src/components/SmartPlugCard.tsx

@@ -317,6 +317,29 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
                 </label>
                 </label>
               </div>
               </div>
 
 
+              {/* Powers the printer (#2629) - only meaningful with a linked printer.
+                  When off, switching this plug off no longer marks the printer offline. */}
+              {plug.printer_id != null && (
+                <div className="flex items-center justify-between">
+                  <div className="flex items-center gap-2">
+                    <Power className="w-4 h-4 text-bambu-green" />
+                    <div>
+                      <p className="text-sm text-white">{t('smartPlugs.controlsPrinterPower')}</p>
+                      <p className="text-xs text-bambu-gray">{t('smartPlugs.controlsPrinterPowerDescription')}</p>
+                    </div>
+                  </div>
+                  <label className="relative inline-flex items-center cursor-pointer">
+                    <input
+                      type="checkbox"
+                      checked={plug.controls_printer_power}
+                      onChange={(e) => updateMutation.mutate({ controls_printer_power: e.target.checked })}
+                      className="sr-only peer"
+                    />
+                    <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
+                  </label>
+                </div>
+              )}
+
               {/* Automation controls - only for controllable plugs (not MQTT) */}
               {/* Automation controls - only for controllable plugs (not MQTT) */}
               {plug.plug_type !== 'mqtt' && (
               {plug.plug_type !== 'mqtt' && (
                 <>
                 <>

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