Explorar o código

Merge pull request #1741 from maziggy/0.2.4.7

**Bambuddy 0.2.4.7**

**⚠ Upgrade Notes — Read Before Updating**

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

Four behavior-change callouts to know about before you upgrade:

- Capture-Finish-Photo on the printer side is no longer force-toggled at dispatch (#1721, reported by @agrisci). The earlier workaround force-enabled the printer-side setting on every print to drive the finish-photo capture; the side effect was that intermediary progress notifications fell silent on A1, and the slicer's own Capture Finish Photo checkbox was silently overridden. Dispatch no longer flips the printer-side setting. Finish-photo capture is now driven by Bambuddy's own stage-22 pre-capture path with a FINISH-state fallback. If you previously unchecked Capture Finish Photo in the slicer to dodge the side effect, you can re-enable it. If you relied on the force-on to get finish photos without slicer changes, the FINISH-state fallback covers it — no UI change required.

- Slicer Bundle (.bbscfg) import removed (#1712, reported by @IndividualGhost1905). The legacy .bbscfg import path on the Slicer page is gone — it silently underdelivered on multi-tier preset matching and conflicted with the Orca Cloud / Bambu Cloud precedence work. Use the cloud sync (Orca Cloud or Bambu Cloud) or local imported presets instead. The SliceModal preset picker now reaches across Imported → Orca Cloud → Bambu Cloud → Standard with proper precedence and cross-tier dedup.

- Bambu Lab A2L support added (#1684). New "A2 Series" optgroup in the Add-Printer / Edit-Printer dropdowns and across the SpoolBuddy / inventory surface. The connection diagnostic, AMS slot routing, and camera path all auto-shape themselves to A2L's hardware (Wi-Fi-only, chamber-image protocol on port 6000 instead of RTSPS:322, single-extruder + cutter/plotter head with no deputy-slot routing). Existing X1 / H2 / P1 / P2 / A1 surfaces are unchanged. If you're on an A2L: re-test Configure AMS Slot — the picker now filters profiles to A2L-compatible filaments (#1623 fix below).

- Windows installer pipeline overhauled. The installer is self-versioning (filename matches the release tag, plus an unversioned alias on stable / beta), ships its own NSSM binary instead of fetching at build time, stops the Bambuddy service before file copy on upgrade, and bootstraps setuptools + wheel into its embedded Python so the post-install dependency install doesn't fail on a clean machine. Existing Windows installs upgrade in place via the Service / Update entries on the new installer; first-time installs no longer depend on a network trip to a flaky NSSM mirror.

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

**Docker**

docker compose pull
docker compose up -d

docker-compose.yml doesn't need refreshing for 0.2.4.7. (If you map the VP passive FTP port range or run the slicer-API sidecar, the 0.2.4.6 upgrade notes still apply —
nothing new in 0.2.4.7.)

**Native install — recommended path**

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

Snapshots the database first and rolls back on failure.

**Native install — manual path**

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

requirements.txt bumps the aiohttp floor to >=3.14.0 this release to clear two upstream advisories. No code change inside Bambuddy — only the floor moves so fresh installs and CI pick up the fix.

**Windows install**

0.2.4.7 ships a rebuilt Windows installer with the pipeline improvements from the Upgrade Notes. Download bambuddy-0.2.4.7-windows-x64-setup.exe from this release page (or the unversioned bambuddy-windows-x64-setup.exe alias for an always-latest link). Existing 0.2.4.5 / 0.2.4.6 installs upgrade in place — the installer stops the Bambuddy service, swaps files, restarts the service, and preserves your data directory.

---
**Highlights**

0.2.4.7 is a heavy fix-cycle release with one big add — Bambu Lab A2L support (#1684) — and a long tail of contributor-credited fixes across the Virtual Printer, Slicer, Print Queue, and connection-diagnostic surfaces.

The Virtual Printer surface got another full sweep driven by #1622 telemetry. The bridge cache now accumulates push_status per-field instead of replacing fields on each push (round 4), overlays incoming dict-shaped fields onto the cache instead of wholesale replacement (round 5, @shaddowlink), and applies the tray_exist_bits empty-slot cleanup to the slicer-facing cache (#1726, @needo37 with full code-level analysis). Net effect: BambuStudio's Device tab no longer greys out between pushalls, and Sync no longer sees phantom-loaded filaments in empty AMS slots. Three env-flagged debug paths landed in the same cycle (wire-payload dump, command-flow trace, bridge-synthesised-reply trace) and stay silent in normal operation.

Slicer (SliceModal) is the second-largest theme: full preset-lookup precedence rework + cross-tier dedup + signed-out banner + AMS slot badges (#1712, @IndividualGhost1905). Follow-ups in the same train fix the Orca Cloud / Bambu Cloud preset resolver to pin type and from to CLI-accepted values for the headless slicer-api sidecar, and the Library G-code preview now correctly renders sidecar-sliced .gcode.3mf rows as G-code instead of returning raw ZIP bytes as text/plain
(#1709, root cause + fix from @yanglei1980).

Print Queue + Archive polish: multi-plate Send All now enqueues one queue item per plate instead of one item per click, archive delete cascades to remove related queue items instead of leaving "cancelled" rows behind, the force-color-match checkbox is no longer missing when scheduling against a specific printer (#1717, @SamNuttall), and the filament-override panel surfaces Bambu Studio's sub-brand colour name instead of the raw 3MF base material (#1718, @SamNuttall). Multi-color filament rows in the Print Log render one swatch per colour instead of a single barely-visible gray dot (#1731 part 1, @IndividualGhost1905). Telegram (and other image-bearing) finish notifications on a reprint-from-archive now ship the new run's finish photo instead of the original print's (#1707, @kycrna).

Windows + restore reliability. Beyond the installer pipeline overhaul: /api/local-backup/status no longer 500s on ZoneInfoNotFoundError: 'No time zone found with key UTC' from a missing zoneinfo DB on the Windows installer (a stdlib UTC fallback covers it). Network-interface enumeration on Windows now uses psutil instead of the Linux-only path. Restore pauses timer-based DB writers before the swap, fixing a Postgres deadlock cascade observed during multi-printer restores.

---
**New Features**

- Bambu Lab A2L support (#1684). New "A2 Series" optgroup in Add-Printer / Edit-Printer dropdowns, with full capability resolution from BambuStudio's machine profile cross-checked against Bambu's official A2L specs page: linear rail, single FDM extruder + integrated cutter/plotter head, Wi-Fi-only (no Ethernet), Low-Rate-Kamera on the chamber-image protocol (port 6000, not RTSPS:322), no heated chamber. The dual-tool-head capability is correctly distinguished from dual-filament extrusion — A2L is not in DUAL_NOZZLE_MODELS, so AMS does not route to a deputy slot (firmware would reject with 07FF_8012). Registry updates touch utils/printer_models.py, firmware_check.py (wiki path follows the established /en/a2l/manual/a2l-firmware-release-history pattern; existing 404 handling makes this safe to ship before Bambu publishes the page), virtual_printer/manager.py (serial prefix 26A19), virtual_printer/mqtt_server.py, PrintersPage.tsx, and  SpoolBuddyAmsPage.tsx. Camera and dual-nozzle code paths need no edits — supports_rtsp() correctly falls through to chamber-image and is_dual_nozzle_model() correctly returns False.

- Re-print / Schedule modal allows cross-extruder AMS slot picks on dual-nozzle (#1722, reported by @privatsturm). The picker previously gated AMS slot choices to the same extruder as the source plate's filament group. On dual-nozzle hardware that's wrong — operators routinely re-route a left-side filament to a right-side AMS slot when the left bank is empty or busy. Fix removes the gate; the MQTT layer's existing dual-nozzle routing handles the rest.

- Support bundle now includes redacted cached push_status per connected printer. The raw push_status payload is the single highest-signal artefact for diagnosing slicer-facing VP issues. The new bundle entry walks the cached payload and scrubs net.info[*].ip plus the documented privacy-sensitive fields before serialising — the live state.raw_data is never mutated. Drops directly into the existing bundle archive alongside the existing log + config dumps.

- One-shot device identification probe for unknown printer models. When Bambuddy sees a serial prefix it doesn't recognise on the MQTT bus, it now fires a single push_all-only probe to surface the model code, instead of either silently dropping the connection or polluting the log with repeated unknown-model warnings. Aids future model rollouts (next H2 / A2 / X2 family variant).

---
**Changes**

- VP MQTT bridge cache shape rework (#1622 rounds 4 + 5). The cache now accumulates push_status per-field instead of replacing fields on each incoming push (round 4, fixes Device-tab greying), and overlays incoming dict-shaped fields onto the cache instead of replacing the whole sub-object (round 5, @shaddowlink, fixes vt_tray going "invalid" right after a slicer filament pick). Field accumulation is bounded by the field allowlist documented in the VP regression matrix.

- SliceModal preset-lookup precedence + cross-tier dedup + signed-out banner + AMS slot badges (#1712, reported by @IndividualGhost1905). The 4-tier picker (Imported → Orca Cloud → Bambu Cloud → Standard) now enforces the documented precedence end-to-end with explicit dedup on key collisions, surfaces a signed-out banner per cloud
tier instead of silently empty lists, and renders AMS slot badges next to the preset row so operators can see at a glance which slot the preset would land in. The legacy .bbscfg import path was dropped in the same train — see Upgrade Notes.

- Print-modal "off" toggles for flow_cali and nozzle_offset_cali now actually suppress the calibration stage. Live-tested on H2D 01.x. The previous behaviour wrote the toggle value into the project_file payload but the firmware still ran the stage; fix routes through the existing dual-nozzle gate and the calibration-suppress field at the MQTT layer.

- Support-bundle log noise demoted (#1721 adjacent, observed on the reporter's A1). Benign "not connected" and "may linger" warnings are now INFO-level so the bundle log dumps surface real problems instead of background heartbeat noise.

- aiohttp pinned to >=3.14.0 in requirements.txt for two upstream advisories. Bambuddy's aiohttp usage was unaffected by the underlying issues, but the floor moves so
fresh installs and CI pick up the fixed runtime.

---
**Fixed**

**UI / rendering**

- Print Log multi-color filament rows now render one swatch per colour instead of a single barely-visible gray dot (#1731 part 1, reported by @IndividualGhost1905). The renderer was averaging the multi-colour RGBA into a single greyscale fallback; the fix splits on the slicer's pipe-separated colour list and renders a small swatch row.

- Stats page Failure Analysis widget now renders translated failure reasons instead of raw camelCase keys (#1687 follow-up, reported by @IndividualGhost1905). The GET serialiser was dropping the canonical failure_reason mapping; the fix re-applies the same vocabulary the Archive Edit modal uses.

- System page boot time no longer renders with a doubled timezone offset (#1690 follow-up, reported by @IndividualGhost1905). The recorder was emitting a tz-naive datetime that the frontend then localised on top; both boot_time and generated_at now ship as tz-aware UTC.

- AMS slot card stays in sync with the new spool's preset name after RFID auto-assigns a new spool. Reporter observed H2D-1 / AMS-B3 / PLA-CF rendering as "Bambu PLA Silk+" until manual refresh. The fix invalidates the AMS slot's cached preset-name lookup on the same event that updates the spool binding.

**Virtual printer**

- Empty AMS slots no longer forwarded as phantom loaded filaments to BambuStudio Sync (#1726, reported with full code-level analysis by @needo37). The tray_exist_bits empty-slot cleanup now applies to the slicer-facing cache in addition to the live state — Sync sees the same empty-slot picture the real printer reports.

- The vt_tray external-spool object no longer goes "invalid" immediately after a slicer filament pick (#1622 round 5, reported by @shaddowlink). The cache was replacing the whole vt_tray sub-object on each push, dropping the slicer's just-picked state; the overlay rework preserves field-level updates.

- VP cache no longer drains capability / lifecycle fields between pushalls, which had greyed out Device-tab UIs (#1622 round 4, reported by @shaddowlink). The per-field accumulation fix from the Changes section.

**Print queue + dispatch + archive**

- Multi-plate Send All now enqueues one queue item per plate instead of one item per click. The route was accepting the plate count from the UI but the queue-insert was firing once per request; fix iterates the plate set on the server side.

- Archive delete now removes related queue items instead of leaving "cancelled" rows behind. The cascade was already declared at the schema level but the DELETE route was using a soft-delete path that bypassed it; fix routes the archive delete through the same cascading path the bulk-delete already used.

- Finish-photo capture: dispatch no longer force-toggles the printer-side Capture Finish Photo setting (#1721, reported by @agrisci). See Upgrade Notes for the full behaviour change.

- Telegram (and other image-bearing) finish notification on a reprint-from-archive no longer ships the original print's finish photo instead of the new run's (#1707, reported by @kycrna). The notification was reading the finish-photo path from the source archive instead of the new archive row.

- Print Queue filament-override panel now shows Bambu Studio's sub-brand colour name instead of the raw 3MF base material (#1718, reported by @SamNuttall). The panel was reading directly from the 3MF metadata instead of the resolved Bambu profile.

- Force-color-match checkbox no longer missing when scheduling against a specific printer (#1717, reported by @SamNuttall). The Charcoal-style label fix from earlier in the queue cycle was missing on the Specific-Printer panel; this also extends to a related label-fix follow-up (#1718 round 3).

**Inventory / AMS / connection diagnostic**

- Configure AMS Slot picker now filters filament profiles to the printer model (#1623, reported by @shaddowlink). The list was showing every imported profile regardless of printer compatibility; the fix routes the picker through the same compatibility filter the SliceModal uses.

- Connection diagnostic no longer flags external_storage: fail on A1 / A1 Mini (#1703, reported by @MartinNYHC). A1 and A1 Mini have no MicroSD slot; the diagnostic now skips the check for those models with a clear "n/a — printer has no SD slot" surface.

- A1 / A1 Mini internal-code map was swapped in PRINTER_MODEL_ID_MAP (surfaced while scoping A2L support, #1684). The swap had no user-visible symptoms but corrupted the printer-model resolver for any code path that round-tripped through the ID map.

**Slicer / library**

- Library G-code preview returned raw ZIP bytes as text/plain for sidecar-sliced .gcode.3mf rows (#1709, root cause + fix from @yanglei1980). The preview route was branching on filename suffix but the sidecar produces .gcode.3mf — a ZIP wrapper around a .gcode payload. Fix extracts the inner .gcode before serving and pins the Content-Type to text/x-gcode.

- Cloud + Orca Cloud preset resolver now pins type and from to CLI-accepted values (#1712 follow-up, reported by maziggy on a Mecha Mewtwo slice). The resolver was passing the user-tier-as-displayed string ("orca_cloud") to the headless slicer-api CLI, which only accepts a fixed enum; fix maps display tiers to CLI tiers explicitly.

**Windows / install / restore**

- /api/local-backup/status no longer 500s on ZoneInfoNotFoundError: 'No time zone found with key UTC' (from a user's log on the Windows installer). Some Windows hosts ship without the IANA zoneinfo database; the fix falls back to the stdlib UTC implementation when the IANA lookup raises.

- Network-interface enumeration on Windows now uses psutil instead of the Linux-only socket path. Affected the Add-Printer custom-subnet picker and any path that listed local interfaces.

- In-app updater now routes every git step through app_dir for separate-mount installs (#1715, reported by @francescocozzi). On installs where /opt/bambuddy/data is a separate mount from /opt/bambuddy, the updater's git commands ran from the data dir and failed silently on the .git lookup; fix explicitly passes -C app_dir.

- Restore now pauses timer-based DB writers before swap, fixing a Postgres deadlock cascade. Multi-printer restores were triggering concurrent timer writers against the same connection pool as the restore transaction; fix gates the timer loop on a "restore in progress" flag.
MartinNYHC hai 2 meses
pai
achega
e2e66e14b7
Modificáronse 100 ficheiros con 7670 adicións e 4199 borrados
  1. 2 0
      .gitignore
  2. 3 0
      CHANGELOG.md
  3. 339 121
      backend/app/api/routes/archives.py
  4. 18 2
      backend/app/api/routes/groups.py
  5. 109 184
      backend/app/api/routes/inventory.py
  6. 191 202
      backend/app/api/routes/library.py
  7. 19 4
      backend/app/api/routes/pending_uploads.py
  8. 10 1
      backend/app/api/routes/print_log.py
  9. 63 4
      backend/app/api/routes/print_queue.py
  10. 6 2
      backend/app/api/routes/printers.py
  11. 13 5
      backend/app/api/routes/slice_jobs.py
  12. 33 203
      backend/app/api/routes/slicer_presets.py
  13. 39 7
      backend/app/api/routes/spoolman_inventory.py
  14. 87 1
      backend/app/api/routes/support.py
  15. 5 5
      backend/app/api/routes/system.py
  16. 27 16
      backend/app/api/routes/updates.py
  17. 14 3
      backend/app/api/routes/users.py
  18. 22 8
      backend/app/core/auth.py
  19. 1 1
      backend/app/core/config.py
  20. 101 1
      backend/app/core/database.py
  21. 30 9
      backend/app/core/permissions.py
  22. 240 98
      backend/app/main.py
  23. 0 46
      backend/app/schemas/slicer.py
  24. 8 7
      backend/app/schemas/slicer_presets.py
  25. 65 20
      backend/app/services/archive.py
  26. 2 50
      backend/app/services/background_dispatch.py
  27. 46 12
      backend/app/services/bambu_ftp.py
  28. 224 79
      backend/app/services/bambu_mqtt.py
  29. 5 0
      backend/app/services/export.py
  30. 7 0
      backend/app/services/firmware_check.py
  31. 17 7
      backend/app/services/local_backup.py
  32. 26 0
      backend/app/services/preset_resolver.py
  33. 7 15
      backend/app/services/print_scheduler.py
  34. 8 1
      backend/app/services/printer_diagnostic.py
  35. 19 0
      backend/app/services/printer_manager.py
  36. 23 88
      backend/app/services/slice_preview.py
  37. 0 240
      backend/app/services/slicer_api.py
  38. 205 0
      backend/app/services/slicer_filament_resolver.py
  39. 158 0
      backend/app/services/slot_preset_writer.py
  40. 17 0
      backend/app/services/spool_tag_matcher.py
  41. 146 0
      backend/app/services/virtual_printer/_debug.py
  42. 116 70
      backend/app/services/virtual_printer/manager.py
  43. 117 74
      backend/app/services/virtual_printer/mqtt_bridge.py
  44. 41 5
      backend/app/services/virtual_printer/mqtt_server.py
  45. 45 4
      backend/app/utils/printer_models.py
  46. 58 0
      backend/tests/integration/test_archives_api.py
  47. 127 0
      backend/tests/integration/test_color_catalog_extras.py
  48. 34 0
      backend/tests/integration/test_library_api.py
  49. 14 258
      backend/tests/integration/test_library_slice_api.py
  50. 280 0
      backend/tests/integration/test_ownership_permissions.py
  51. 29 11
      backend/tests/integration/test_print_queue_api.py
  52. 236 0
      backend/tests/integration/test_read_permission_backfill_migration.py
  53. 169 0
      backend/tests/integration/test_spoolman_slot_assignment_mqtt.py
  54. 33 0
      backend/tests/integration/test_system_api.py
  55. 68 0
      backend/tests/integration/test_updates_api.py
  56. 407 0
      backend/tests/integration/test_users_groups_privilege_escalation.py
  57. 5 5
      backend/tests/unit/services/test_background_dispatch.py
  58. 0 16
      backend/tests/unit/services/test_background_dispatch_watchdog.py
  59. 27 6
      backend/tests/unit/services/test_bambu_ftp.py
  60. 216 15
      backend/tests/unit/services/test_bambu_mqtt.py
  61. 0 156
      backend/tests/unit/services/test_dispatch_force_timelapse.py
  62. 128 2
      backend/tests/unit/services/test_preset_resolver.py
  63. 26 2
      backend/tests/unit/services/test_printer_diagnostic.py
  64. 0 182
      backend/tests/unit/services/test_slice_preview.py
  65. 0 273
      backend/tests/unit/services/test_slicer_api.py
  66. 201 0
      backend/tests/unit/services/test_slot_preset_writer.py
  67. 182 0
      backend/tests/unit/services/test_spool_tag_matcher.py
  68. 134 0
      backend/tests/unit/services/test_virtual_printer.py
  69. 0 205
      backend/tests/unit/test_cleanup_forced_timelapse.py
  70. 19 0
      backend/tests/unit/test_local_backup.py
  71. 112 0
      backend/tests/unit/test_printer_models.py
  72. 326 0
      backend/tests/unit/test_reprint_clears_stale_timelapse.py
  73. 0 71
      backend/tests/unit/test_scheduler_force_timelapse_wiring.py
  74. 6 74
      backend/tests/unit/test_slice_request_schema.py
  75. 104 260
      backend/tests/unit/test_slicer_presets.py
  76. 120 0
      backend/tests/unit/test_support_helpers.py
  77. 405 2
      backend/tests/unit/test_vp_mqtt_bridge.py
  78. 206 0
      backend/tests/unit/test_vp_wire_dump.py
  79. BIN=BIN
      frontend/public/img/printers/a2l.png
  80. 43 0
      frontend/src/__tests__/components/EditArchiveModal.test.tsx
  81. 159 7
      frontend/src/__tests__/components/FilamentMapping.test.tsx
  82. 240 1
      frontend/src/__tests__/components/FilamentOverride.test.tsx
  83. 17 0
      frontend/src/__tests__/components/PrintLogModal.test.tsx
  84. 11 179
      frontend/src/__tests__/components/SliceModal.test.tsx
  85. 0 214
      frontend/src/__tests__/components/SlicerBundlesPanel.test.tsx
  86. 230 0
      frontend/src/__tests__/hooks/useFilamentLabels.test.tsx
  87. 43 0
      frontend/src/__tests__/pages/StatsPage.test.tsx
  88. 24 0
      frontend/src/__tests__/utils/printer.test.ts
  89. 16 141
      frontend/src/__tests__/utils/slicerPrinterMatch.test.ts
  90. 27 89
      frontend/src/api/client.ts
  91. 162 24
      frontend/src/components/ConfigureAmsSlotModal.tsx
  92. 7 1
      frontend/src/components/ConfirmModal.tsx
  93. 16 3
      frontend/src/components/EditArchiveModal.tsx
  94. 1 1
      frontend/src/components/PrintLogTable.tsx
  95. 124 82
      frontend/src/components/PrintModal/FilamentMapping.tsx
  96. 18 4
      frontend/src/components/PrintModal/FilamentOverride.tsx
  97. 4 0
      frontend/src/components/PrintModal/index.tsx
  98. 10 0
      frontend/src/components/PrintModal/types.ts
  99. 118 0
      frontend/src/components/PrintModal/useFilamentLabels.ts
  100. 54 320
      frontend/src/components/SliceModal.tsx

+ 2 - 0
.gitignore

@@ -90,3 +90,5 @@ advertisements/
 gitleaks-report.json
 
 scripts/pip-audit.sh
+
+security/

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 3 - 0
CHANGELOG.md


+ 339 - 121
backend/app/api/routes/archives.py

@@ -119,6 +119,43 @@ def _match_timelapse_by_timestamp(
     return best_video, best_diff
 
 
+def _ensure_archive_visible(
+    archive: PrintArchive | None,
+    user: User | None,
+    can_read_all: bool,
+) -> PrintArchive:
+    """Per-archive visibility gate for ownership-scoped reads (#1726-adjacent).
+
+    Returns ``archive`` if the caller is allowed to see it; raises 404 otherwise.
+    Single enforcement point used by every detail / download / sub-resource
+    route so we can't accidentally leak a row through a less-guarded sibling.
+
+    Rules:
+      - Missing archive or soft-deleted (``deleted_at != None``) → 404.
+      - Caller with ARCHIVES_READ_ALL or auth disabled (``can_read_all=True``,
+        ``user`` may be None) → archive returned.
+      - Caller without ARCHIVES_READ_ALL and ``archive.created_by_id != user.id``
+        → 404, deliberately NOT 403. 403 leaks "this id exists but you can't
+        see it" — enumeration-friendly. 404 is indistinguishable from a
+        nonexistent id. Pre-GHSA fix the caller saw 200 here (the PoC vector).
+      - Ownerless rows (``created_by_id is None``) require ALL — fail-closed
+        per ``feedback_no_fail_open_in_auth``.
+    """
+    if not archive or archive.deleted_at is not None:
+        raise HTTPException(404, "Archive not found")
+    if can_read_all:
+        return archive
+    # Auth enabled, caller has _OWN only.
+    if user is None:
+        # Defensive: should be unreachable (RequirePermissionIfAuthEnabled
+        # would have 401'd already), but never trust user identity to be
+        # non-None when can_read_all is False.
+        raise HTTPException(404, "Archive not found")
+    if archive.created_by_id is None or archive.created_by_id != user.id:
+        raise HTTPException(404, "Archive not found")
+    return archive
+
+
 def _validate_user_filter_permission(current_user: User | None, created_by_id: int | None):
     """Raise 403 if created_by_id filter is used without stats:filter_by_user permission."""
     if created_by_id is None or current_user is None:
@@ -315,9 +352,16 @@ async def list_archives(
     limit: int = 50,
     offset: int = 0,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
     """List archived prints."""
+    user, can_read_all = auth_result
+    visible_to_user_id = user.id if (user is not None and not can_read_all) else None
     service = ArchiveService(db)
     archives = await service.list_archives(
         printer_id=printer_id,
@@ -326,6 +370,7 @@ async def list_archives(
         date_to=date_to,
         limit=limit,
         offset=offset,
+        visible_to_user_id=visible_to_user_id,
     )
 
     # Get sets of duplicate hashes and duplicate (name, hash) pairs (efficient single queries)
@@ -430,7 +475,12 @@ async def list_archives(
 @router.get("/no-3mf-warning")
 async def no_3mf_warning(
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
     """Whether to nudge the user about install step 4 ("Store sent files on
     external storage"). True iff any archive in the last 30 days was created
@@ -446,14 +496,16 @@ async def no_3mf_warning(
     user has been told, no further nudge until they clear browser storage.
     The backend stays stateless.
     """
+    user, can_read_all = auth_result
     cutoff = datetime.now(timezone.utc) - timedelta(days=30)
-    result = await db.execute(
-        select(PrintArchive.extra_data).where(
-            PrintArchive.created_at >= cutoff,
-            PrintArchive.deleted_at.is_(None),
-            PrintArchive.extra_data.isnot(None),
-        )
-    )
+    conditions = [
+        PrintArchive.created_at >= cutoff,
+        PrintArchive.deleted_at.is_(None),
+        PrintArchive.extra_data.isnot(None),
+    ]
+    if user is not None and not can_read_all:
+        conditions.append(PrintArchive.created_by_id == user.id)
+    result = await db.execute(select(PrintArchive.extra_data).where(*conditions))
     for (extra_data,) in result.all():
         if extra_data and extra_data.get("no_3mf_available"):
             return {"has_fallback": True}
@@ -468,7 +520,12 @@ async def list_archives_slim(
     limit: int = Query(default=10000, le=50000),
     offset: int = 0,
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
     """Per-event listing for stats/dashboard widgets.
 
@@ -481,7 +538,16 @@ async def list_archives_slim(
     """
     from backend.app.models.print_log import PrintLogEntry
 
+    current_user, can_read_all = auth_result
     _validate_user_filter_permission(current_user, created_by_id)
+    # Callers without ARCHIVES_READ_ALL can only see their own runs — pin
+    # the filter unconditionally so a caller-supplied ?created_by_id=
+    # can't widen the listing past their own scope. The existing
+    # _validate_user_filter_permission rejects ?created_by_id= without
+    # STATS_FILTER_BY_USER, so the only way to reach this is owner-self
+    # filtering anyway, but pinning here is the fail-closed default.
+    if current_user is not None and not can_read_all:
+        created_by_id = current_user.id
     filters = []
     if date_from:
         dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
@@ -558,7 +624,12 @@ async def search_archives(
     limit: int = 50,
     offset: int = 0,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
     """Full-text search across archives.
 
@@ -570,6 +641,8 @@ async def search_archives(
 
     from backend.app.core.db_dialect import is_sqlite
 
+    user, can_read_all = auth_result
+    own_only = user is not None and not can_read_all
     search_term = q.strip()
 
     # Build dialect-specific full-text search query
@@ -630,6 +703,8 @@ async def search_archives(
             query = query.where(PrintArchive.project_id == project_id)
         if status:
             query = query.where(PrintArchive.status == status)
+        if own_only:
+            query = query.where(PrintArchive.created_by_id == user.id)
 
         query = query.limit(limit).offset(offset)
         result = await db.execute(query)
@@ -648,6 +723,8 @@ async def search_archives(
         .options(selectinload(PrintArchive.project))
         .where(PrintArchive.id.in_(matched_ids), PrintArchive.deleted_at.is_(None))
     )
+    if own_only:
+        query = query.where(PrintArchive.created_by_id == user.id)
 
     # Apply additional filters
     if printer_id:
@@ -721,7 +798,12 @@ async def analyze_failures(
     project_id: int | None = None,
     created_by_id: int | None = Query(None, description="Filter by user who created the print (-1 for no user)"),
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
     """Analyze failure patterns across prints.
 
@@ -732,7 +814,11 @@ async def analyze_failures(
     - Recent failures
     - Weekly trend
     """
+    current_user, can_read_all = auth_result
     _validate_user_filter_permission(current_user, created_by_id)
+    # Callers without ARCHIVES_READ_ALL are scoped to their own runs (#2).
+    if current_user is not None and not can_read_all:
+        created_by_id = current_user.id
 
     from backend.app.services.failure_analysis import FailureAnalysisService
 
@@ -751,7 +837,12 @@ async def analyze_failures(
 async def compare_archives(
     archive_ids: str = Query(..., description="Comma-separated archive IDs (2-5)"),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
     """Compare multiple archives side by side.
 
@@ -763,6 +854,8 @@ async def compare_archives(
     """
     from backend.app.services.archive_comparison import ArchiveComparisonService
 
+    user, can_read_all = auth_result
+
     # Parse and validate archive IDs
     try:
         ids = [int(id.strip()) for id in archive_ids.split(",")]
@@ -774,6 +867,20 @@ async def compare_archives(
     if len(ids) > 5:
         raise HTTPException(400, "Maximum 5 archives can be compared at once")
 
+    # Verify the caller is allowed to see every archive in the comparison —
+    # one not-owned id in the list would otherwise leak its full detail block.
+    # _ensure_archive_visible raises 404 on the first miss (same 404 the
+    # single-archive endpoint would return).
+    if user is not None and not can_read_all:
+        existing = await db.execute(
+            select(PrintArchive.id, PrintArchive.created_by_id, PrintArchive.deleted_at).where(PrintArchive.id.in_(ids))
+        )
+        owners_by_id = {row.id: row for row in existing.all()}
+        for archive_id in ids:
+            row = owners_by_id.get(archive_id)
+            if row is None or row.deleted_at is not None or row.created_by_id != user.id:
+                raise HTTPException(404, "Archive not found")
+
     service = ArchiveComparisonService(db)
     try:
         return await service.compare_archives(ids)
@@ -792,7 +899,12 @@ async def export_archives(
     date_to: str | None = Query(None, description="End date (ISO format)"),
     search: str | None = None,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
     """Export archives to CSV or Excel format.
 
@@ -804,6 +916,9 @@ async def export_archives(
 
     from backend.app.services.export import ExportService
 
+    user, can_read_all = auth_result
+    visible_to_user_id = user.id if (user is not None and not can_read_all) else None
+
     if format not in ("csv", "xlsx"):
         raise HTTPException(400, "Format must be 'csv' or 'xlsx'")
 
@@ -837,6 +952,7 @@ async def export_archives(
             date_from=date_from_dt,
             date_to=date_to_dt,
             search=search,
+            visible_to_user_id=visible_to_user_id,
         )
     except ImportError as e:
         raise HTTPException(500, str(e))
@@ -1220,16 +1336,23 @@ async def _sum_snapshot_deltas(
 @router.get("/tags")
 async def get_all_tags(
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
     """List all unique tags with usage counts.
 
     Returns a list of tags sorted by count (descending), then by name.
     """
+    user, can_read_all = auth_result
     # Query all archives with non-null tags
-    result = await db.execute(
-        select(PrintArchive.tags).where(PrintArchive.tags.isnot(None), PrintArchive.deleted_at.is_(None))
-    )
+    tag_conditions = [PrintArchive.tags.isnot(None), PrintArchive.deleted_at.is_(None)]
+    if user is not None and not can_read_all:
+        tag_conditions.append(PrintArchive.created_by_id == user.id)
+    result = await db.execute(select(PrintArchive.tags).where(*tag_conditions))
     all_tags_rows = result.all()
 
     # Count occurrences of each tag
@@ -1332,17 +1455,17 @@ async def delete_tag(
 async def get_archive(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
     """Get a specific archive."""
+    user, can_read_all = auth_result
     service = ArchiveService(db)
-    archive = await service.get_archive(archive_id)
-    # Soft-deleted archives are hidden from the UI (#1343) — surface them as
-    # 404 here too so a stale bookmark / direct URL doesn't expose a row the
-    # user has already removed. The hard-delete (?purge_stats=true) path
-    # bypasses this check by querying PrintArchive directly.
-    if not archive or archive.deleted_at is not None:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
 
     # Find duplicates
     makerworld_id = archive.extra_data.get("makerworld_model_id") if archive.extra_data else None
@@ -1356,11 +1479,45 @@ async def get_archive(
     return archive_to_response(archive, duplicates, run_aggregate=run_aggregates.get(archive.id))
 
 
+@router.get("/{archive_id}/delete-impact")
+async def get_archive_delete_impact(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
+):
+    """Pre-flight for the delete-confirm modal (#1734).
+
+    Returns the number of related queue items the user is about to remove
+    AND whether any of them are currently printing (which would block the
+    delete with a 409 — surfaced to the modal so it can disable the
+    confirm button instead of failing on submit). Cheap, single endpoint —
+    not folded into the archive GET response so the much larger list
+    endpoint isn't forced to run the same query per row.
+    """
+    user, can_read_all = auth_result
+    service = ArchiveService(db)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    from backend.app.services.archive import _count_related_queue_items
+
+    total, printing = await _count_related_queue_items(db, archive.id)
+    return {"related_queue_items": total, "currently_printing": printing}
+
+
 @router.get("/{archive_id}/runs", response_model=PrintLogResponse)
 async def list_archive_runs(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
     """List PrintLogEntry rows for this archive — one per print event.
 
@@ -1369,9 +1526,8 @@ async def list_archive_runs(
     from backend.app.models.print_log import PrintLogEntry
     from backend.app.schemas.print_log import PrintLogEntrySchema
 
-    archive = await db.get(PrintArchive, archive_id)
-    if not archive or archive.deleted_at is not None:
-        raise HTTPException(404, "Archive not found")
+    user, can_read_all = auth_result
+    _ensure_archive_visible(await db.get(PrintArchive, archive_id), user, can_read_all)
 
     rows = await db.execute(
         select(PrintLogEntry)
@@ -1388,7 +1544,12 @@ async def find_similar_archives(
     archive_id: int,
     limit: int = 10,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
     """Find archives with similar settings for comparison.
 
@@ -1399,6 +1560,9 @@ async def find_similar_archives(
     """
     from backend.app.services.archive_comparison import ArchiveComparisonService
 
+    user, can_read_all = auth_result
+    _ensure_archive_visible(await db.get(PrintArchive, archive_id), user, can_read_all)
+
     service = ArchiveComparisonService(db)
     try:
         return await service.find_similar_archives(archive_id, limit=limit)
@@ -1720,13 +1884,17 @@ async def rescan_all_archives(
 async def get_archive_duplicates(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
     """Get duplicates for a specific archive."""
+    user, can_read_all = auth_result
     service = ArchiveService(db)
-    archive = await service.get_archive(archive_id)
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
 
     makerworld_id = archive.extra_data.get("makerworld_model_id") if archive.extra_data else None
     duplicates = await service.find_duplicates(
@@ -1787,7 +1955,14 @@ async def delete_archive(
         )
     ),
 ):
-    """Delete an archive (soft by default; ``?purge_stats=true`` to hard-delete)."""
+    """Delete an archive (soft by default; ``?purge_stats=true`` to hard-delete).
+
+    Both delete paths now cascade to related ``print_queue`` rows (#1734) —
+    hard delete via the ``ON DELETE CASCADE`` FK, soft delete via the
+    ``_delete_related_queue_items`` helper. A 409 guard blocks the delete
+    when any related queue item is currently mid-print so the dispatcher
+    doesn't lose its metadata trail under the running print.
+    """
     user, can_modify_all = auth_result
 
     # Get archive first to check ownership
@@ -1801,6 +1976,20 @@ async def delete_archive(
         if archive.created_by_id != user.id:
             raise HTTPException(403, "You can only delete your own archives")
 
+    # #1734: block delete when any related queue item is currently printing.
+    # Both soft and hard delete are gated — an in-flight print needs its
+    # backing archive to stay around for the metadata trail (filament,
+    # plate, ams_mapping). The user can stop the print first, then retry.
+    from backend.app.services.archive import _count_related_queue_items
+
+    _related_total, related_printing = await _count_related_queue_items(db, archive_id)
+    if related_printing > 0:
+        raise HTTPException(
+            409,
+            f"Cannot delete archive — {related_printing} related queue item(s) are "
+            f"currently printing. Stop the print first, then retry.",
+        )
+
     service = ArchiveService(db)
     if purge_stats:
         # Hard-delete the linked PrintLogEntry rows first so their filament /
@@ -1828,13 +2017,17 @@ async def download_archive(
     archive_id: int,
     inline: bool = False,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
     """Download the 3MF file."""
+    user, can_read_all = auth_result
     service = ArchiveService(db)
-    archive = await service.get_archive(archive_id)
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
 
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
@@ -1856,13 +2049,17 @@ async def download_archive_with_filename(
     archive_id: int,
     filename: str,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
     """Download the 3MF file with filename in URL."""
+    user, can_read_all = auth_result
     service = ArchiveService(db)
-    archive = await service.get_archive(archive_id)
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
 
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
@@ -1879,7 +2076,12 @@ async def download_archive_with_filename(
 async def create_archive_slicer_token(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
     """Create a short-lived download token for opening files in slicer applications.
 
@@ -1888,10 +2090,9 @@ async def create_archive_slicer_token(
     """
     from backend.app.core.auth import create_slicer_download_token
 
+    user, can_read_all = auth_result
     service = ArchiveService(db)
-    archive = await service.get_archive(archive_id)
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
 
     token = await create_slicer_download_token("archive", archive_id)
     return {"token": token}
@@ -2327,15 +2528,21 @@ async def upload_timelapse(
 async def get_timelapse_info(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
     """Get timelapse video metadata for editor."""
     from backend.app.schemas.timelapse import TimelapseInfoResponse
     from backend.app.services.timelapse_processor import TimelapseProcessor
 
+    user, can_read_all = auth_result
     service = ArchiveService(db)
-    archive = await service.get_archive(archive_id)
-    if not archive or not archive.timelapse_path:
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    if not archive.timelapse_path:
         raise HTTPException(404, "Timelapse not found")
 
     timelapse_path = settings.base_dir / archive.timelapse_path
@@ -2357,7 +2564,12 @@ async def get_timelapse_thumbnails(
     count: int = Query(10, ge=1, le=30),
     width: int = Query(160, ge=80, le=320),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
     """Generate timeline thumbnail frames for visual scrubbing."""
     import base64
@@ -2365,9 +2577,10 @@ async def get_timelapse_thumbnails(
     from backend.app.schemas.timelapse import ThumbnailResponse
     from backend.app.services.timelapse_processor import TimelapseProcessor
 
+    user, can_read_all = auth_result
     service = ArchiveService(db)
-    archive = await service.get_archive(archive_id)
-    if not archive or not archive.timelapse_path:
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    if not archive.timelapse_path:
         raise HTTPException(404, "Timelapse not found")
 
     timelapse_path = settings.base_dir / archive.timelapse_path
@@ -2692,15 +2905,19 @@ async def get_qrcode(
 async def get_archive_capabilities(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
     """Check what viewing capabilities are available for this 3MF file."""
     import defusedxml.ElementTree as ET
 
+    user, can_read_all = auth_result
     service = ArchiveService(db)
-    archive = await service.get_archive(archive_id)
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
 
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
@@ -2913,7 +3130,12 @@ async def get_gcode(
     archive_id: int,
     plate: int | None = None,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
     """Extract and return G-code from the 3MF file.
 
@@ -2922,10 +3144,9 @@ async def get_gcode(
     back to the first plate found in the archive (preserving the original
     behaviour for callers that predate the multi-plate viewer).
     """
+    user, can_read_all = auth_result
     service = ArchiveService(db)
-    archive = await service.get_archive(archive_id)
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
 
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
@@ -3161,7 +3382,12 @@ async def upload_archives_bulk(
 async def get_archive_plates(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
     """Get available plates from a multi-plate 3MF archive.
 
@@ -3172,10 +3398,9 @@ async def get_archive_plates(
 
     import defusedxml.ElementTree as ET
 
+    user, can_read_all = auth_result
     service = ArchiveService(db)
-    archive = await service.get_archive(archive_id)
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
 
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
@@ -3498,20 +3723,12 @@ async def _try_preview_slice_filaments(
     plate_id: int,
     file_path: Path,
     request_id: str | None = None,
-    bundle_id: str | None = None,
-    printer_name: str | None = None,
-    process_name: str | None = None,
-    filament_names: list[str] | None = None,
 ) -> list[dict] | None:
     """Run a preview slice via the user's configured sidecar so the filament
     list endpoint can return real per-plate filaments for unsliced project
     files. Returns ``None`` on any failure — the caller falls back to the
     painted-face heuristic. ``request_id`` flows through to the sidecar
     for live progress on the SliceModal's inline spinner + toast.
-
-    Bundle context (id + preset names) is forwarded to the preview helper
-    so the preview can mirror the real-print profile triplet when supplied
-    — see ``slice_preview.get_preview_filaments`` for the full contract.
     """
     from backend.app.api.routes.settings import get_setting
     from backend.app.services.slice_preview import get_preview_filaments
@@ -3540,10 +3757,6 @@ async def _try_preview_slice_filaments(
         file_name=file_path.name,
         api_url=api_url,
         request_id=request_id,
-        bundle_id=bundle_id,
-        printer_name=printer_name,
-        process_name=process_name,
-        filament_names=filament_names,
     )
 
 
@@ -3552,12 +3765,13 @@ async def get_filament_requirements(
     archive_id: int,
     plate_id: int | None = None,
     request_id: str | None = None,
-    bundle_id: str | None = None,
-    printer_name: str | None = None,
-    process_name: str | None = None,
-    filament_names: str | None = None,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
     """Get filament requirements from the archived 3MF file.
 
@@ -3567,19 +3781,12 @@ async def get_filament_requirements(
     Args:
         archive_id: The archive ID
         plate_id: Optional plate index to filter filaments for (for multi-plate files)
-        bundle_id / printer_name / process_name / filament_names: Optional
-            bundle context. When all four are supplied, the preview slice
-            (run for unsliced project files) uses ``slice_with_bundle``
-            against the named preset triplet instead of the embedded-
-            settings fallback. ``filament_names`` is comma- or semicolon-
-            separated.
     """
     import defusedxml.ElementTree as ET
 
+    user, can_read_all = auth_result
     service = ArchiveService(db)
-    archive = await service.get_archive(archive_id)
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
 
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
@@ -3676,11 +3883,6 @@ async def get_filament_requirements(
                 project_filaments = extract_project_filaments_from_3mf(zf)
                 used_slot_ids: set[int] = set()
                 if project_filaments and plate_id is not None:
-                    parsed_filament_names: list[str] | None = None
-                    if filament_names:
-                        parsed_filament_names = [
-                            n.strip() for n in filament_names.replace(";", ",").split(",") if n.strip()
-                        ] or None
                     preview = await _try_preview_slice_filaments(
                         db,
                         kind="archive",
@@ -3688,10 +3890,6 @@ async def get_filament_requirements(
                         plate_id=plate_id,
                         file_path=file_path,
                         request_id=request_id,
-                        bundle_id=bundle_id,
-                        printer_name=printer_name,
-                        process_name=process_name,
-                        filament_names=parsed_filament_names,
                     )
                     if preview is not None:
                         used_slot_ids = {f["slot_id"] for f in preview}
@@ -3928,16 +4126,20 @@ async def reprint_archive(
 async def get_project_page(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
     """Get the project page data from the 3MF file."""
     from backend.app.schemas.archive import ProjectPageResponse
     from backend.app.services.archive import ProjectPageParser
 
+    user, can_read_all = auth_result
     service = ArchiveService(db)
-    archive = await service.get_archive(archive_id)
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
 
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
@@ -4119,13 +4321,17 @@ async def upload_source_3mf(
 async def download_source_3mf(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
     """Download the source 3MF project file."""
+    user, can_read_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = result.scalar_one_or_none()
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all)
 
     if not archive.source_3mf_path:
         raise HTTPException(404, "No source 3MF attached to this archive")
@@ -4149,13 +4355,17 @@ async def download_source_3mf_for_slicer(
     archive_id: int,
     filename: str,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
     """Download source 3MF with filename in URL."""
+    user, can_read_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = result.scalar_one_or_none()
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all)
 
     if not archive.source_3mf_path:
         raise HTTPException(404, "No source 3MF attached to this archive")
@@ -4175,15 +4385,19 @@ async def download_source_3mf_for_slicer(
 async def create_source_slicer_token(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
     """Create a short-lived download token for opening source 3MF in slicer."""
     from backend.app.core.auth import create_slicer_download_token
 
+    user, can_read_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = result.scalar_one_or_none()
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all)
     if not archive.source_3mf_path:
         raise HTTPException(404, "No source 3MF attached to this archive")
 
@@ -4401,13 +4615,17 @@ async def upload_f3d(
 async def download_f3d(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
     """Download the Fusion 360 design file."""
+    user, can_read_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = result.scalar_one_or_none()
-    if not archive:
-        raise HTTPException(404, "Archive not found")
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all)
 
     if not archive.f3d_path:
         raise HTTPException(404, "No F3D file attached to this archive")

+ 18 - 2
backend/app/api/routes/groups.py

@@ -5,7 +5,7 @@ from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequireAdminIfAuthEnabled, RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.permissions import (
     ALL_PERMISSIONS,
@@ -87,6 +87,7 @@ async def list_groups(
 @router.post("/", response_model=GroupResponse, status_code=status.HTTP_201_CREATED)
 async def create_group(
     group_data: GroupCreate,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_CREATE),
     db: AsyncSession = Depends(get_db),
 ):
@@ -135,7 +136,8 @@ async def get_group(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_READ),
     db: AsyncSession = Depends(get_db),
 ):
-    """Get a group by ID with user list."""
+    """Get a group by ID with user list. Read-only — gated on
+    ``GROUPS_READ`` only."""
     result = await db.execute(select(Group).where(Group.id == group_id).options(selectinload(Group.users)))
     group = result.scalar_one_or_none()
     if not group:
@@ -161,6 +163,7 @@ async def get_group(
 async def update_group(
     group_id: int,
     group_data: GroupUpdate,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ):
@@ -193,6 +196,16 @@ async def update_group(
         group.description = group_data.description
 
     if group_data.permissions is not None:
+        # System groups (Administrators in particular) have fixed permission
+        # sets that the app depends on — stripping them is a denial-of-
+        # service vector that even admin callers shouldn't trigger by
+        # accident through the generic edit form. Mirrors the rename block
+        # immediately above.
+        if group.is_system:
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="Cannot modify permissions of system groups",
+            )
         # Validate permissions
         invalid_perms = [p for p in group_data.permissions if p not in ALL_PERMISSIONS]
         if invalid_perms:
@@ -220,6 +233,7 @@ async def update_group(
 @router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
 async def delete_group(
     group_id: int,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_DELETE),
     db: AsyncSession = Depends(get_db),
 ):
@@ -246,6 +260,7 @@ async def delete_group(
 async def add_user_to_group(
     group_id: int,
     user_id: int,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ):
@@ -283,6 +298,7 @@ async def add_user_to_group(
 async def remove_user_from_group(
     group_id: int,
     user_id: int,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ):

+ 109 - 184
backend/app/api/routes/inventory.py

@@ -38,6 +38,7 @@ from backend.app.schemas.spool import (
     normalize_extra_colors,
 )
 from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
+from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
 from backend.app.services.spool_csv import (
     MAX_CSV_IMPORT_BYTES,
     ImportPreview,
@@ -66,27 +67,6 @@ _CSV_UPLOAD_CHUNK_BYTES = 64 * 1024
 # FilamentColors.xyz API
 FILAMENT_COLORS_API = "https://filamentcolors.xyz/api"
 
-# Generic Bambu filament IDs by material — fallback when no specific
-# preset is resolvable. Keep aligned with the inline table in
-# apply_spool_to_slot_via_mqtt below; both paths must produce the same
-# value for a given material.
-_GENERIC_FILAMENT_IDS: dict[str, str] = {
-    "PLA": "GFL99",
-    "PETG": "GFG99",
-    "ABS": "GFB99",
-    "ASA": "GFB98",
-    "PC": "GFC99",
-    "PA": "GFN99",
-    "NYLON": "GFN99",
-    "TPU": "GFU99",
-    "PVA": "GFS99",
-    "HIPS": "GFS98",
-    "PLA-CF": "GFL98",
-    "PETG-CF": "GFG98",
-    "PA-CF": "GFN98",
-    "PETG HF": "GFG96",
-}
-
 
 async def apply_spool_to_slot_via_mqtt(
     *,
@@ -129,125 +109,24 @@ async def apply_spool_to_slot_via_mqtt(
     )
     tray_color = spool.rgba or "FFFFFFFF"
 
-    _generic_id_values = set(_GENERIC_FILAMENT_IDS.values())
-
-    tray_info_idx = ""
-    setting_id = ""
-    sf = spool.slicer_filament or ""
-
-    if sf:
-        base_sf = sf.split("_")[0] if "_" in sf else sf
-        # Cloud-side preset IDs in three known shapes:
-        #   GFS…   — Bambu official cloud preset
-        #   PFUS…  — cloud user-created preset
-        #   PFCN…  — cloud shared / partner preset (e.g. Polymaker's
-        #            "(Custom)" Bambu Lab H2D variant, #1648)
-        # All three need a cloud-detail lookup to extract the underlying
-        # filament_id; without it the raw cloud id ends up in tray_info_idx
-        # and the printer's calibration table can't resolve it.
-        if base_sf.startswith("GFS") or base_sf.startswith("PFUS") or base_sf.startswith("PFCN"):
-            setting_id = base_sf
-            try:
-                from backend.app.api.routes.cloud import build_authenticated_cloud
-
-                cloud = await build_authenticated_cloud(db, current_user)
-                if cloud is not None and cloud.is_authenticated:
-                    try:
-                        detail = await cloud.get_setting_detail(base_sf)
-                        if detail.get("filament_id"):
-                            tray_info_idx = detail["filament_id"]
-                            cloud_name = detail.get("name", "")
-                            if cloud_name:
-                                tray_sub_brands = cloud_name.replace(r"@.*$", "").split("@")[0].strip()
-                        elif detail.get("base_id"):
-                            bid = detail["base_id"].split("_")[0]
-                            if bid.startswith("GFS") and len(bid) >= 5:
-                                tray_info_idx = f"GF{bid[3:]}"
-                            else:
-                                tray_info_idx = bid
-                    finally:
-                        await cloud.close()
-                elif cloud is not None:
-                    await cloud.close()
-            except Exception as e:
-                logger.warning("Spool assign: cloud lookup failed for %r: %s", sf, e)
-
-            if not tray_info_idx:
-                tray_info_idx, setting_id = normalize_slicer_filament(sf)
-        elif base_sf.startswith("GF"):
-            tray_info_idx, setting_id = normalize_slicer_filament(sf)
-        else:
-            try:
-                local_id = int(sf)
-                from backend.app.models.local_preset import LocalPreset as LP
-
-                lp_result = await db.execute(select(LP).where(LP.id == local_id, LP.preset_type == "filament"))
-                lp = lp_result.scalar_one_or_none()
-                if lp:
-                    # Local preset's setting JSON carries the printer-recognized
-                    # filament_id (e.g. "P4d64437") — use that directly so the
-                    # slicer can resolve the specific preset. Falls through to
-                    # generic material id only when the JSON doesn't carry one.
-                    lp_filament_id = ""
-                    if lp.setting:
-                        try:
-                            setting_data = json.loads(lp.setting)
-                            raw_fid = setting_data.get("filament_id")
-                            if isinstance(raw_fid, str) and raw_fid:
-                                lp_filament_id = raw_fid
-                        except (json.JSONDecodeError, AttributeError):
-                            pass
-                    if lp_filament_id:
-                        tray_info_idx = lp_filament_id
-                        setting_id = filament_id_to_setting_id(lp_filament_id)
-                    else:
-                        mat = (spool.material or lp.filament_type or "").upper().strip()
-                        tray_info_idx = (
-                            _GENERIC_FILAMENT_IDS.get(mat)
-                            or _GENERIC_FILAMENT_IDS.get(mat.split("-")[0].split(" ")[0])
-                            or ""
-                        )
-                    if lp.name:
-                        tray_sub_brands = lp.name.split("@")[0].strip()
-            except (ValueError, TypeError):
-                tray_info_idx, setting_id = normalize_slicer_filament(sf)
-
-    if tray_info_idx and spool.slicer_filament_name:
-        from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES
-
-        expected_name = _BUILTIN_FILAMENT_NAMES.get(tray_info_idx, "")
-        if expected_name and expected_name != spool.slicer_filament_name:
-            for fid, fname in _BUILTIN_FILAMENT_NAMES.items():
-                if fname == spool.slicer_filament_name:
-                    tray_info_idx = fid
-                    setting_id = filament_id_to_setting_id(fid)
-                    break
-
-    # Defend against tray_info_idx values the slicer cannot resolve. Three
-    # shapes leak through and must be discarded so the generic-material
-    # fallback below can rescue the slot:
-    #   1. Literal material names ("PLA", "PETG-CF") that pass through
-    #      normalize_slicer_filament unchanged when the spool's slicer_filament
-    #      is free-text rather than a real preset ID.
-    #   2. PFUS-prefix cloud setting_ids — valid as setting_id but rejected
-    #      by the slicer as tray_info_idx (the printer's calibration table
-    #      indexes by filament_id, and a PFUS isn't one). This normally gets
-    #      realigned to a P-prefix local id via printer_kp lookup, but the
-    #      replay path in main.py.on_ams_change passes current_user=None,
-    #      which skips cloud auth and leaves the raw PFUS in tray_info_idx —
-    #      overwriting the correctly-configured slot from the original assign.
-    #   3. PFCN-prefix cloud shared / partner presets (e.g. Polymaker's
-    #      "(Custom)" H2D variants, #1648) — same shape problem as PFUS.
-    # Valid tray_info_idx values: "GF" + letter + digits (Bambu official) or
-    # "P" followed by hex (user/local presets, NOT "PFUS" or "PFCN").
-    _known_materials = set(MATERIAL_TEMPS.keys()) | set(_GENERIC_FILAMENT_IDS.keys())
-    if tray_info_idx and (
-        tray_info_idx.upper() in _known_materials
-        or tray_info_idx.startswith("PFUS")
-        or tray_info_idx.startswith("PFCN")
-    ):
-        tray_info_idx = ""
-        setting_id = ""
+    _generic_id_values = _GENERIC_ID_VALUES
+    _known_materials = set(MATERIAL_TEMPS.keys()) | set(GENERIC_FILAMENT_IDS.keys())
+
+    # slicer_filament → (tray_info_idx, setting_id) resolution is shared with
+    # the Spoolman-mode route via this helper (#1713). The helper handles
+    # GFS/PFUS/PFCN cloud lookup, GF normalize, integer LocalPreset id,
+    # the builtin-name realignment, AND the defensive PFUS/PFCN/material-name
+    # sanitization. When it returns an empty tray_info_idx the local
+    # current-tray-state + generic-material fallback below rescues the slot.
+    tray_info_idx, setting_id, sub_brand_override = await resolve_slicer_filament(
+        db=db,
+        current_user=current_user,
+        slicer_filament=spool.slicer_filament,
+        slicer_filament_name=spool.slicer_filament_name,
+        material=spool.material,
+    )
+    if sub_brand_override:
+        tray_sub_brands = sub_brand_override
 
     if not tray_info_idx:
         if (
@@ -263,8 +142,8 @@ async def apply_spool_to_slot_via_mqtt(
         elif tray_type:
             material = tray_type.upper().strip()
             generic = (
-                _GENERIC_FILAMENT_IDS.get(material)
-                or _GENERIC_FILAMENT_IDS.get(material.split("-")[0].split(" ")[0])
+                GENERIC_FILAMENT_IDS.get(material)
+                or GENERIC_FILAMENT_IDS.get(material.split("-")[0].split(" ")[0])
                 or ""
             )
             if generic:
@@ -394,48 +273,23 @@ async def apply_spool_to_slot_via_mqtt(
         )
 
     # Persist slot preset mapping for UI display (preset_name on hover card).
-    try:
-        from backend.app.models.slot_preset import SlotPresetMapping
-
-        preset_name = spool.slicer_filament_name or tray_sub_brands or tray_type
-        preset_source = "cloud"
-        if sf:
-            base_sf_mapping = sf.split("_")[0] if "_" in sf else sf
-            try:
-                int(base_sf_mapping)
-                preset_id_to_save = f"local_{base_sf_mapping}"
-                preset_source = "local"
-            except (ValueError, TypeError):
-                preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else setting_id
-        else:
-            preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else ""
-
-        if preset_id_to_save:
-            existing_mapping = await db.execute(
-                select(SlotPresetMapping).where(
-                    SlotPresetMapping.printer_id == printer_id,
-                    SlotPresetMapping.ams_id == ams_id,
-                    SlotPresetMapping.tray_id == tray_id,
-                )
-            )
-            mapping = existing_mapping.scalar_one_or_none()
-            if mapping:
-                mapping.preset_id = preset_id_to_save
-                mapping.preset_name = preset_name
-                mapping.preset_source = preset_source
-            else:
-                mapping = SlotPresetMapping(
-                    printer_id=printer_id,
-                    ams_id=ams_id,
-                    tray_id=tray_id,
-                    preset_id=preset_id_to_save,
-                    preset_name=preset_name,
-                    preset_source=preset_source,
-                )
-                db.add(mapping)
-            await db.commit()
-    except Exception as e:
-        logger.warning("Failed to save slot preset mapping for spool %d: %s", spool.id, e)
+    # 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
+    # previous spool's preset name (the PrintersPage display chain consults
+    # slot_preset_mappings.preset_name first).
+    from backend.app.services.slot_preset_writer import upsert_slot_preset_for_spool
+
+    await upsert_slot_preset_for_spool(
+        db=db,
+        spool=spool,
+        printer_id=printer_id,
+        ams_id=ams_id,
+        tray_id=tray_id,
+        tray_info_idx=tray_info_idx,
+        tray_sub_brands=tray_sub_brands,
+        tray_type=tray_type,
+        setting_id=setting_id,
+    )
 
     logger.info(
         "Auto-configured AMS slot ams=%d tray=%d for spool %d on printer %d",
@@ -538,6 +392,10 @@ class ColorLookupResult(BaseModel):
     material: str | None = None
 
 
+class ColorByMaterialResult(BaseModel):
+    color_name: str | None = None
+
+
 # ── Spool Catalog CRUD ─────────────────────────────────────────────────────
 
 
@@ -815,6 +673,73 @@ async def lookup_color(
     return ColorLookupResult(found=False)
 
 
+@router.get("/colors/by-material", response_model=ColorByMaterialResult)
+async def get_color_by_material(
+    hex: str,
+    material: str | None = None,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_auth_if_enabled),
+):
+    """Disambiguated hex→name lookup that respects material context.
+
+    ``/colors/map`` collapses every catalog entry sharing a hex to a single
+    name with "Bambu Lab > is_default > first" priority — that loses, e.g.,
+    "PLA Matte Charcoal" (#000000) behind "PLA Basic Black" (also #000000).
+    This endpoint preserves the material context so the queue scheduler's
+    Filament Override label can show the actually-sliced sub-brand colour
+    instead of the generic bucket. #1718.
+
+    Returns ``color_name=None`` when the hex isn't in the catalog at all.
+    When the hex IS in the catalog but no entry matches the requested
+    material (or none was supplied), falls back to the same priority order
+    as ``/colors/map`` so callers without a material hint don't regress.
+
+    Not gated on INVENTORY_READ for the same reason ``/colors/map`` isn't —
+    every queue / archive view that renders a sliced filament colour needs
+    this, including read-only roles.
+    """
+    key = hex.lstrip("#").lower()[:6]
+    if len(key) != 6:
+        return ColorByMaterialResult(color_name=None)
+
+    material_norm = (material or "").strip().lower()
+
+    # Catalog rows are stored as ``#RRGGBB`` (verified at write time and
+    # against production); lookup uses lower-cased hex equality so mixed-case
+    # writes from older imports still match.
+    result = await db.execute(
+        select(
+            ColorCatalogEntry.color_name,
+            ColorCatalogEntry.manufacturer,
+            ColorCatalogEntry.material,
+            ColorCatalogEntry.is_default,
+        ).where(func.lower(ColorCatalogEntry.hex_color) == f"#{key}")
+    )
+    candidates = [(name, mfg, mat, is_default) for name, mfg, mat, is_default in result.all() if name]
+    if not candidates:
+        return ColorByMaterialResult(color_name=None)
+
+    if material_norm:
+        for name, _mfg, mat, _is_default in candidates:
+            if mat and mat.strip().lower() == material_norm:
+                return ColorByMaterialResult(color_name=name)
+
+    # Same priority order as ``/colors/map`` so a caller passing no (or an
+    # unrecognised) material gets the existing answer, not a degraded one.
+    best_name: str | None = None
+    best_priority = -1
+    for name, mfg, _mat, is_default in candidates:
+        priority = 0
+        if mfg and mfg.strip().lower() == "bambu lab":
+            priority += 2
+        if is_default:
+            priority += 1
+        if priority > best_priority:
+            best_name = name
+            best_priority = priority
+    return ColorByMaterialResult(color_name=best_name)
+
+
 @router.get("/colors/search", response_model=list[ColorEntryResponse])
 async def search_colors(
     manufacturer: str | None = None,

+ 191 - 202
backend/app/api/routes/library.py

@@ -76,6 +76,32 @@ logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/library", tags=["library"])
 
 
+def _ensure_library_file_visible(
+    library_file: LibraryFile | None,
+    user: User | None,
+    can_read_all: bool,
+) -> LibraryFile:
+    """Per-file visibility gate for ownership-scoped LIBRARY reads (#1726-adjacent).
+
+    Mirrors archives.py::_ensure_archive_visible — single enforcement point so a
+    less-guarded sibling route can't accidentally leak a row. Same shape:
+
+      - Missing / soft-deleted → 404 (not 403, to avoid id-enumeration leaks).
+      - ``can_read_all`` true (LIBRARY_READ_ALL or auth disabled) → file returned.
+      - ``can_read_all`` false and ``created_by_id != user.id`` → 404.
+      - Ownerless files (``created_by_id is None``) require ALL — fail-closed.
+    """
+    if library_file is None or getattr(library_file, "deleted_at", None) is not None:
+        raise HTTPException(404, "File not found")
+    if can_read_all:
+        return library_file
+    if user is None:
+        raise HTTPException(404, "File not found")
+    if library_file.created_by_id is None or library_file.created_by_id != user.id:
+        raise HTTPException(404, "File not found")
+    return library_file
+
+
 def get_library_dir() -> Path:
     """Get the library storage directory."""
     base_dir = Path(app_settings.archive_dir)
@@ -695,7 +721,12 @@ async def _backfill_external_stl_thumbnails(folder_ids: list[int]) -> None:
 async def list_folders(
     response: Response,
     db: AsyncSession = Depends(get_db),
-    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
+    _: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_READ_ALL,
+            Permission.LIBRARY_READ_OWN,
+        )
+    ),
 ):
     """Get all folders as a tree structure."""
     # Prevent browser caching of folder list
@@ -754,7 +785,12 @@ async def list_folders(
 async def get_folders_by_project(
     project_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
+    _: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_READ_ALL,
+            Permission.LIBRARY_READ_OWN,
+        )
+    ),
 ):
     """Get all folders linked to a specific project."""
     result = await db.execute(
@@ -802,7 +838,12 @@ async def get_folders_by_project(
 async def get_folders_by_archive(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
+    _: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_READ_ALL,
+            Permission.LIBRARY_READ_OWN,
+        )
+    ),
 ):
     """Get all folders linked to a specific archive."""
     result = await db.execute(
@@ -910,7 +951,12 @@ async def create_folder(
 async def get_folder(
     folder_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
+    _: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_READ_ALL,
+            Permission.LIBRARY_READ_OWN,
+        )
+    ),
 ):
     """Get a folder by ID."""
     result = await db.execute(
@@ -1632,7 +1678,12 @@ async def list_files(
     internal_only: bool = False,
     external_only: bool = False,
     db: AsyncSession = Depends(get_db),
-    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_READ_ALL,
+            Permission.LIBRARY_READ_OWN,
+        )
+    ),
 ):
     """List files, optionally filtered by folder or project.
 
@@ -1654,7 +1705,10 @@ async def list_files(
             detail="internal_only and external_only are mutually exclusive",
         )
 
+    user, can_read_all = auth_result
     query = LibraryFile.active().options(selectinload(LibraryFile.created_by))
+    if user is not None and not can_read_all:
+        query = query.where(LibraryFile.created_by_id == user.id)
 
     if folder_id is not None:
         query = query.where(LibraryFile.folder_id == folder_id)
@@ -2394,7 +2448,12 @@ async def add_files_to_queue(
 async def get_library_file_plates(
     file_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_READ_ALL,
+            Permission.LIBRARY_READ_OWN,
+        )
+    ),
 ):
     """Get available plates from a multi-plate 3MF library file.
 
@@ -2405,9 +2464,10 @@ async def get_library_file_plates(
 
     import defusedxml.ElementTree as ET
 
+    user, can_read_all = auth_result
     # Get the library file
     result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
-    lib_file = result.scalar_one_or_none()
+    lib_file = _ensure_library_file_visible(result.scalar_one_or_none(), user, can_read_all)
 
     if not lib_file:
         raise HTTPException(status_code=404, detail="File not found")
@@ -2701,10 +2761,6 @@ async def _try_preview_slice_filaments(
     plate_id: int,
     file_path: Path,
     request_id: str | None = None,
-    bundle_id: str | None = None,
-    printer_name: str | None = None,
-    process_name: str | None = None,
-    filament_names: list[str] | None = None,
 ) -> list[dict] | None:
     """Run a preview slice via the user's configured sidecar. Same shape as
     the matching helper in archives.py — see that module for rationale.
@@ -2712,13 +2768,6 @@ async def _try_preview_slice_filaments(
     ``request_id``: when supplied, forwarded to the sidecar so the
     SliceModal's inline spinner + toast can poll the matching progress
     endpoint and show "Generating G-code (45%)" for the preview as well.
-
-    ``bundle_id`` / ``printer_name`` / ``process_name`` / ``filament_names``:
-    when all are supplied, the preview uses ``slice_with_bundle`` against
-    the named bundle's preset triplet so the preview's gram numbers reflect
-    the same profiles the real print will use. Partial context falls back
-    to the embedded-settings path so a half-completed Bundle-tier selection
-    in the modal doesn't error out.
     """
     from backend.app.api.routes.settings import get_setting
     from backend.app.services.slice_preview import get_preview_filaments
@@ -2747,10 +2796,6 @@ async def _try_preview_slice_filaments(
         file_name=file_path.name,
         api_url=api_url,
         request_id=request_id,
-        bundle_id=bundle_id,
-        printer_name=printer_name,
-        process_name=process_name,
-        filament_names=filament_names,
     )
 
 
@@ -2759,12 +2804,13 @@ async def get_library_file_filament_requirements(
     file_id: int,
     plate_id: int | None = None,
     request_id: str | None = None,
-    bundle_id: str | None = None,
-    printer_name: str | None = None,
-    process_name: str | None = None,
-    filament_names: str | None = None,
     db: AsyncSession = Depends(get_db),
-    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_READ_ALL,
+            Permission.LIBRARY_READ_OWN,
+        )
+    ),
 ):
     """Get filament requirements from a library file.
 
@@ -2774,21 +2820,13 @@ async def get_library_file_filament_requirements(
     Args:
         file_id: The library file ID
         plate_id: Optional plate index to get filaments for a specific plate
-        bundle_id / printer_name / process_name / filament_names: Optional
-            bundle context. When all four are supplied, the preview slice
-            (run for unsliced project files) uses ``slice_with_bundle``
-            against the named preset triplet instead of the embedded-
-            settings fallback. ``filament_names`` is comma- or semicolon-
-            separated to mirror the slice route's multi-color form.
     """
     import defusedxml.ElementTree as ET
 
+    user, can_read_all = auth_result
     # Get the library file
     result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
-    lib_file = result.scalar_one_or_none()
-
-    if not lib_file:
-        raise HTTPException(status_code=404, detail="File not found")
+    lib_file = _ensure_library_file_visible(result.scalar_one_or_none(), user, can_read_all)
 
     # Get the full file path
     file_path = Path(app_settings.base_dir) / lib_file.file_path
@@ -2899,14 +2937,6 @@ async def get_library_file_filament_requirements(
                 project_filaments = extract_project_filaments_from_3mf(zf)
                 used_slot_ids: set[int] = set()
                 if project_filaments and plate_id is not None:
-                    # Bundle context flows through optional query params so
-                    # callers without a Bundle-tier selection (the common
-                    # case) hit the same path as before.
-                    parsed_filament_names: list[str] | None = None
-                    if filament_names:
-                        parsed_filament_names = [
-                            n.strip() for n in filament_names.replace(";", ",").split(",") if n.strip()
-                        ] or None
                     preview = await _try_preview_slice_filaments(
                         db,
                         kind="library_file",
@@ -2914,10 +2944,6 @@ async def get_library_file_filament_requirements(
                         plate_id=plate_id,
                         file_path=file_path,
                         request_id=request_id,
-                        bundle_id=bundle_id,
-                        printer_name=printer_name,
-                        process_name=process_name,
-                        filament_names=parsed_filament_names,
                     )
                     if preview is not None:
                         used_slot_ids = {f["slot_id"] for f in preview}
@@ -3171,48 +3197,38 @@ async def _run_slicer_with_fallback(
         SlicerInputError,
     )
 
-    # Bundle dispatch path: when SliceRequest.bundle is set, the schema
-    # validator short-circuited the presets-required check, so the
-    # PresetRef fields may all be None. Skip resolve_preset_ref entirely
-    # — the sidecar will materialise the per-category JSONs from the
-    # bundle's extracted directory at slice time.
-    use_bundle = request.bundle is not None
-
     user: User | None = None
     presets: dict[str, str] = {}
     filament_jsons: list[str] = []
-    if not use_bundle:
-        # Resolve each slot via the source-aware resolver. The schema
-        # validator has already normalised legacy `*_preset_id: int`
-        # fields into `PresetRef(source='local', id=str(int))`, so all
-        # three are guaranteed non-None here.
-        if current_user_id is not None:
-            user = await db.get(User, current_user_id)
-
-        refs = {
-            "printer": request.printer_preset,
-            "process": request.process_preset,
-        }
-        for slot, ref in refs.items():
-            assert ref is not None, "schema validator guarantees PresetRef is set"
-            presets[slot] = await resolve_preset_ref(db, user, ref, slot)
-        # Multi-color: resolve each filament slot in plate order. The schema
-        # validator backfilled `filament_presets` from the legacy `filament_preset`
-        # field for single-color callers, so this list is always non-empty.
-        for ref in request.filament_presets:
-            assert ref is not None, "schema validator guarantees filament list is non-None"
-            filament_jsons.append(await resolve_preset_ref(db, user, ref, "filament"))
-
-        # Bed-type override (#1337): patch curr_bed_type onto the resolved
-        # process JSON so the slicer's StaticPrintConfig pass picks up the
-        # user's pick instead of whatever the process preset defaults to.
-        # Without this, slicing an STL of ABS onto a process preset whose
-        # default is "Cool Plate" fails with "Plate 1: Cool Plate does not
-        # support filament 1" — the reporter's exact scenario. Only applies
-        # to the resolved-preset path; bundle mode would need a sidecar-side
-        # mechanism to patch presets it materialises from disk.
-        if request.bed_type:
-            presets["process"] = _patch_process_bed_type(presets["process"], request.bed_type)
+    # Resolve each slot via the source-aware resolver. The schema
+    # validator has already normalised legacy `*_preset_id: int`
+    # fields into `PresetRef(source='local', id=str(int))`, so all
+    # three are guaranteed non-None here.
+    if current_user_id is not None:
+        user = await db.get(User, current_user_id)
+
+    refs = {
+        "printer": request.printer_preset,
+        "process": request.process_preset,
+    }
+    for slot, ref in refs.items():
+        assert ref is not None, "schema validator guarantees PresetRef is set"
+        presets[slot] = await resolve_preset_ref(db, user, ref, slot)
+    # Multi-color: resolve each filament slot in plate order. The schema
+    # validator backfilled `filament_presets` from the legacy `filament_preset`
+    # field for single-color callers, so this list is always non-empty.
+    for ref in request.filament_presets:
+        assert ref is not None, "schema validator guarantees filament list is non-None"
+        filament_jsons.append(await resolve_preset_ref(db, user, ref, "filament"))
+
+    # Bed-type override (#1337): patch curr_bed_type onto the resolved
+    # process JSON so the slicer's StaticPrintConfig pass picks up the
+    # user's pick instead of whatever the process preset defaults to.
+    # Without this, slicing an STL of ABS onto a process preset whose
+    # default is "Cool Plate" fails with "Plate 1: Cool Plate does not
+    # support filament 1" — the reporter's exact scenario.
+    if request.bed_type:
+        presets["process"] = _patch_process_bed_type(presets["process"], request.bed_type)
 
     # Slicer routing — pick the sidecar URL by preferred_slicer.
     # The per-install URL setting (Settings UI → Slicer card) wins; an
@@ -3286,11 +3302,10 @@ async def _run_slicer_with_fallback(
         target_model = await _resolve_target_printer_model(db, user, request)
         if source_model and target_model and is_dual_nozzle_model(source_model) != is_dual_nozzle_model(target_model):
             logger.info(
-                "Cross-nozzle-class re-slice (%s -> %s, %s): enabling --arrange so BS reconciles "
+                "Cross-nozzle-class re-slice (%s -> %s): enabling --arrange so BS reconciles "
                 "the embedded project layout against the target printer",
                 source_model,
                 target_model,
-                "bundle" if use_bundle else "presets",
             )
             cross_class_arrange = True
     # When this slice is dispatcher-tracked, generate a request_id so
@@ -3319,17 +3334,10 @@ async def _run_slicer_with_fallback(
     # never touches the unused slot. Replace unused-slot entries with the
     # slot-1 selection before the real slice so the loaded-filament set
     # is materially homogeneous.
-    bundle_filament_names: list[str] | None = None
     if is_3mf and request.plate is not None:
         from backend.app.services.slicer_3mf_convert import substitute_unused_plate_filaments
 
-        if use_bundle:
-            assert request.bundle is not None
-            bundle_filament_names = substitute_unused_plate_filaments(
-                primary_bytes, request.plate, list(request.bundle.filament_names)
-            )
-        else:
-            filament_jsons = substitute_unused_plate_filaments(primary_bytes, request.plate, filament_jsons)
+        filament_jsons = substitute_unused_plate_filaments(primary_bytes, request.plate, filament_jsons)
 
     # Cross-class slice-all loop (#1493): when the user asks for
     # ``plate=0`` (all plates) AND the source's nozzle class differs from
@@ -3392,39 +3400,18 @@ async def _run_slicer_with_fallback(
 
                 for plate_num in range(1, plate_count + 1):
                     plate_cb = _wrap_progress_for_plate(plate_num, plate_count)
-                    if use_bundle:
-                        assert request.bundle is not None
-                        per_plate = await service.slice_with_bundle(
-                            model_bytes=primary_bytes,
-                            model_filename=model_filename,
-                            bundle_id=request.bundle.bundle_id,
-                            printer_name=request.bundle.printer_name,
-                            process_name=request.bundle.process_name,
-                            filament_names=(
-                                bundle_filament_names
-                                if bundle_filament_names is not None
-                                else request.bundle.filament_names
-                            ),
-                            plate=plate_num,
-                            export_3mf=True,
-                            arrange=True,
-                            bed_type=request.bed_type,
-                            request_id=progress_request_id,
-                            on_progress=plate_cb,
-                        )
-                    else:
-                        per_plate = await service.slice_with_profiles(
-                            model_bytes=primary_bytes,
-                            model_filename=model_filename,
-                            printer_profile_json=presets["printer"],
-                            process_profile_json=presets["process"],
-                            filament_profile_jsons=filament_jsons,
-                            plate=plate_num,
-                            export_3mf=True,
-                            arrange=True,
-                            request_id=progress_request_id,
-                            on_progress=plate_cb,
-                        )
+                    per_plate = await service.slice_with_profiles(
+                        model_bytes=primary_bytes,
+                        model_filename=model_filename,
+                        printer_profile_json=presets["printer"],
+                        process_profile_json=presets["process"],
+                        filament_profile_jsons=filament_jsons,
+                        plate=plate_num,
+                        export_3mf=True,
+                        arrange=True,
+                        request_id=progress_request_id,
+                        on_progress=plate_cb,
+                    )
                     per_plate_results.append((plate_num, per_plate))
 
                 # Merge the N single-plate 3MFs into one multi-plate 3MF.
@@ -3445,27 +3432,6 @@ async def _run_slicer_with_fallback(
                     filament_used_g=sum(r.filament_used_g for _, r in per_plate_results),
                     filament_used_mm=sum(r.filament_used_mm for _, r in per_plate_results),
                 )
-            elif use_bundle:
-                # Bundle dispatch: sidecar materialises the JSON triplet
-                # from the stored .bbscfg by name. ``request.bundle`` is
-                # guaranteed non-None here by the use_bundle branch above.
-                assert request.bundle is not None
-                result = await service.slice_with_bundle(
-                    model_bytes=primary_bytes,
-                    model_filename=model_filename,
-                    bundle_id=request.bundle.bundle_id,
-                    printer_name=request.bundle.printer_name,
-                    process_name=request.bundle.process_name,
-                    filament_names=bundle_filament_names
-                    if bundle_filament_names is not None
-                    else request.bundle.filament_names,
-                    plate=request.plate,
-                    export_3mf=request.export_3mf,
-                    arrange=cross_class_arrange,
-                    bed_type=request.bed_type,
-                    request_id=progress_request_id,
-                    on_progress=progress_callback,
-                )
             else:
                 result = await service.slice_with_profiles(
                     model_bytes=primary_bytes,
@@ -3502,11 +3468,8 @@ async def _run_slicer_with_fallback(
             # bytes — the embedded-settings path also reads the same
             # project_settings.config and the same range validator runs
             # there too, so without sanitisation the fallback would die
-            # on the same sentinel error (#1201). Same fallback applies
-            # to the bundle path: if the resolved triplet crashes the CLI,
-            # embedded settings give the user *something* rather than a
-            # hard failure (the SliceModal flags the difference via
-            # used_embedded_settings).
+            # on the same sentinel error (#1201). The SliceModal flags
+            # the difference to the user via used_embedded_settings.
             result = await service.slice_without_profiles(
                 model_bytes=primary_bytes,
                 model_filename=model_filename,
@@ -3555,8 +3518,6 @@ async def _resolve_target_printer_model(db: AsyncSession, user: User | None, req
     """
     from backend.app.services.preset_resolver import resolve_preset_ref
 
-    if request.bundle is not None:
-        return _canonical_printer_model(request.bundle.printer_name)
     if request.printer_preset is None:
         return None
     try:
@@ -3578,11 +3539,10 @@ async def guard_nozzle_class_reslice(
 
     Cross-nozzle-class re-slicing is handled by ``_run_slicer_with_fallback``'s
     two-pass conversion (#1493): a 1mm cube is sliced with the target triplet
-    (via either ``slice_with_profiles`` or ``slice_with_bundle``, whichever
-    dispatch mode the caller is using) to produce a fresh target-shaped
+    via ``slice_with_profiles`` to produce a fresh target-shaped
     ``Metadata/project_settings.config``, which is then spliced into the
     source 3MF before the real slice. So this guard never needs to block
-    anymore — both preset and bundle paths are covered.
+    anymore.
 
     The function and its call sites in ``archives.py`` / the library re-slice
     route are kept so external pinned-version forks and downstream patches
@@ -3679,11 +3639,13 @@ async def slice_and_persist(
         folder_id=folder_id,
         filename=out_filename,
         file_path=to_relative_path(out_path),
-        # Sliced output is a `.gcode.3mf` zip with embedded G-code, but the
-        # user-facing meaning is "ready-to-print G-code" — using "gcode"
-        # gives it the same badge as plain .gcode files and distinguishes
-        # it from un-sliced `.3mf` source models.
-        file_type="gcode",
+        # The on-disk payload is a ZIP container — the file_type must
+        # record that so the preview endpoint opens it as a 3MF instead
+        # of returning the ZIP bytes as text/plain (#1709 / yanglei1980).
+        # Earlier code mis-typed sliced rows as "gcode" to share the
+        # plain-G-code badge; that broke the embedded viewer. UI badges
+        # and gates for "gcode.3mf" are explicit at the call sites.
+        file_type="gcode.3mf",
         file_size=len(result.content),
         file_hash=hashlib.sha256(result.content).hexdigest(),
         thumbnail_path=thumbnail_relative,
@@ -4098,16 +4060,19 @@ async def print_library_file(
 async def get_file(
     file_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_READ_ALL,
+            Permission.LIBRARY_READ_OWN,
+        )
+    ),
 ):
     """Get a file by ID with full details."""
+    user, can_read_all = auth_result
     result = await db.execute(
         LibraryFile.active().options(selectinload(LibraryFile.created_by)).where(LibraryFile.id == file_id)
     )
-    file = result.scalar_one_or_none()
-
-    if not file:
-        raise HTTPException(status_code=404, detail="File not found")
+    file = _ensure_library_file_visible(result.scalar_one_or_none(), user, can_read_all)
 
     # Get folder name
     folder_name = None
@@ -4250,8 +4215,11 @@ async def update_file(
     await db.commit()
     await db.refresh(file)
 
-    # Return full response (reuse get_file logic)
-    return await get_file(file_id, db)
+    # Return full response. Bypass get_file's ownership gate — caller already
+    # passed update_file's ownership gate above, so we re-fetch + serialise
+    # directly instead of calling the route function (which would try to
+    # evaluate its own Depends() at call time and trip a TypeError).
+    return await get_file(file_id, db, auth_result=(None, True))
 
 
 @router.delete("/files/{file_id}")
@@ -4311,14 +4279,17 @@ async def delete_file(
 async def download_file(
     file_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_READ_ALL,
+            Permission.LIBRARY_READ_OWN,
+        )
+    ),
 ):
     """Download a file."""
+    user, can_read_all = auth_result
     result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
-    file = result.scalar_one_or_none()
-
-    if not file:
-        raise HTTPException(status_code=404, detail="File not found")
+    file = _ensure_library_file_visible(result.scalar_one_or_none(), user, can_read_all)
 
     abs_path = to_absolute_path(file.file_path)
     if not abs_path or not abs_path.exists():
@@ -4335,7 +4306,12 @@ async def download_file(
 async def create_library_slicer_token(
     file_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_READ_ALL,
+            Permission.LIBRARY_READ_OWN,
+        )
+    ),
 ):
     """Create a short-lived download token for opening files in slicer applications.
 
@@ -4344,10 +4320,9 @@ async def create_library_slicer_token(
     """
     from backend.app.core.auth import create_slicer_download_token
 
+    user, can_read_all = auth_result
     result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
-    file = result.scalar_one_or_none()
-    if not file:
-        raise HTTPException(status_code=404, detail="File not found")
+    _ensure_library_file_visible(result.scalar_one_or_none(), user, can_read_all)
 
     token = await create_slicer_download_token("library", file_id)
     return {"token": token}
@@ -4422,28 +4397,30 @@ async def get_thumbnail(
 async def get_gcode(
     file_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_READ_ALL,
+            Permission.LIBRARY_READ_OWN,
+        )
+    ),
 ):
     """Get gcode for a file (for preview)."""
+    user, can_read_all = auth_result
     result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
-    file = result.scalar_one_or_none()
-
-    if not file:
-        raise HTTPException(status_code=404, detail="File not found")
+    file = _ensure_library_file_visible(result.scalar_one_or_none(), user, can_read_all)
 
     abs_path = to_absolute_path(file.file_path)
     if not abs_path or not abs_path.exists():
         raise HTTPException(status_code=404, detail="File not found on disk")
 
-    if file.file_type == "gcode":
-        return FastAPIFileResponse(str(abs_path), media_type="text/plain")
-    elif file.file_type in ("3mf", "gcode.3mf"):
-        # Extract gcode from 3mf zip container. `.gcode.3mf` sliced outputs
-        # carry the same `Metadata/plate_*.gcode` entries as a `.3mf`, so
-        # the unzip path is identical — just had to expand the gate.
+    # Legacy sliced rows from before #1709 stored a `.gcode.3mf` ZIP body
+    # under file_type="gcode" — the on-disk filename is the truth in that
+    # case, so detect by suffix before checking the type column.
+    is_gcode_3mf = file.file_type in ("3mf", "gcode.3mf") or file.filename.lower().endswith(".gcode.3mf")
+
+    if is_gcode_3mf:
         try:
             with zipfile.ZipFile(str(abs_path), "r") as zf:
-                # Find gcode file
                 gcode_files = [n for n in zf.namelist() if n.endswith(".gcode")]
                 if not gcode_files:
                     raise HTTPException(status_code=404, detail="No gcode found in 3MF file")
@@ -4453,6 +4430,8 @@ async def get_gcode(
                 return Response(content=gcode_content, media_type="text/plain")
         except zipfile.BadZipFile:
             raise HTTPException(status_code=400, detail="Invalid 3MF file")
+    elif file.file_type == "gcode":
+        return FastAPIFileResponse(str(abs_path), media_type="text/plain")
     else:
         raise HTTPException(status_code=400, detail="Unsupported file type")
 
@@ -4644,32 +4623,42 @@ async def bulk_delete(
 @router.get("/stats")
 async def get_library_stats(
     db: AsyncSession = Depends(get_db),
-    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_READ_ALL,
+            Permission.LIBRARY_READ_OWN,
+        )
+    ),
 ):
     """Get library statistics."""
+    user, can_read_all = auth_result
     # Stats exclude trashed files — users see counts/sizes for what's actually in the library.
-    active_only = LibraryFile.deleted_at.is_(None)
+    # Without LIBRARY_READ_ALL the stats reflect only the caller's own files —
+    # match what the file list endpoint shows so the numbers stay consistent.
+    file_filters = [LibraryFile.deleted_at.is_(None)]
+    if user is not None and not can_read_all:
+        file_filters.append(LibraryFile.created_by_id == user.id)
 
     # Total files
-    total_files_result = await db.execute(select(func.count(LibraryFile.id)).where(active_only))
+    total_files_result = await db.execute(select(func.count(LibraryFile.id)).where(*file_filters))
     total_files = total_files_result.scalar() or 0
 
-    # Total folders
+    # Total folders (folders are shared org structure, not per-user — count all)
     total_folders_result = await db.execute(select(func.count(LibraryFolder.id)))
     total_folders = total_folders_result.scalar() or 0
 
     # Total size
-    total_size_result = await db.execute(select(func.sum(LibraryFile.file_size)).where(active_only))
+    total_size_result = await db.execute(select(func.sum(LibraryFile.file_size)).where(*file_filters))
     total_size = total_size_result.scalar() or 0
 
     # Files by type
     type_result = await db.execute(
-        select(LibraryFile.file_type, func.count(LibraryFile.id)).where(active_only).group_by(LibraryFile.file_type)
+        select(LibraryFile.file_type, func.count(LibraryFile.id)).where(*file_filters).group_by(LibraryFile.file_type)
     )
     files_by_type = dict(type_result.all())
 
     # Total prints
-    total_prints_result = await db.execute(select(func.sum(LibraryFile.print_count)).where(active_only))
+    total_prints_result = await db.execute(select(func.sum(LibraryFile.print_count)).where(*file_filters))
     total_prints = total_prints_result.scalar() or 0
 
     # Disk space info

+ 19 - 4
backend/app/api/routes/pending_uploads.py

@@ -8,7 +8,7 @@ from pydantic import BaseModel
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_ownership_permission
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.pending_upload import PendingUpload
@@ -91,7 +91,12 @@ async def _augment_with_display_name(
 @router.get("/", response_model=list[PendingUploadResponse])
 async def list_pending_uploads(
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
+    _: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.QUEUE_READ_ALL,
+            Permission.QUEUE_READ_OWN,
+        )
+    ),
 ):
     """List all pending uploads."""
     result = await db.execute(
@@ -104,7 +109,12 @@ async def list_pending_uploads(
 @router.get("/count")
 async def get_pending_count(
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
+    _: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.QUEUE_READ_ALL,
+            Permission.QUEUE_READ_OWN,
+        )
+    ),
 ):
     """Get count of pending uploads."""
     result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
@@ -208,7 +218,12 @@ async def discard_all_pending(
 async def get_pending_upload(
     upload_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
+    _: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.QUEUE_READ_ALL,
+            Permission.QUEUE_READ_OWN,
+        )
+    ),
 ):
     """Get a specific pending upload."""
     result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))

+ 10 - 1
backend/app/api/routes/print_log.py

@@ -34,11 +34,20 @@ async def get_print_log(
     limit: int = Query(default=50, ge=1, le=500),
     offset: int = Query(default=0, ge=0),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
 ):
     """Get the print log."""
+    user, can_read_all = auth_result
     query = select(PrintLogEntry)
     count_query = select(func.count(PrintLogEntry.id))
+    if user is not None and not can_read_all:
+        query = query.where(PrintLogEntry.created_by_id == user.id)
+        count_query = count_query.where(PrintLogEntry.created_by_id == user.id)
 
     if printer_id is not None:
         query = query.where(PrintLogEntry.printer_id == printer_id)

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

@@ -306,9 +306,15 @@ async def list_queue(
         None, description="Filter by target model (also includes model-based items when combined with printer_id)"
     ),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.QUEUE_READ_ALL,
+            Permission.QUEUE_READ_OWN,
+        )
+    ),
 ):
     """List all queue items, optionally filtered by printer or status."""
+    user, can_read_all = auth_result
     query = (
         select(PrintQueueItem)
         .options(
@@ -320,6 +326,8 @@ async def list_queue(
         )
         .order_by(PrintQueueItem.printer_id.nulls_first(), PrintQueueItem.position)
     )
+    if user is not None and not can_read_all:
+        query = query.where(PrintQueueItem.created_by_id == user.id)
 
     if printer_id is not None:
         if printer_id == -1:
@@ -404,6 +412,18 @@ async def add_to_queue(
         archive = result.scalar_one_or_none()
         if not archive:
             raise HTTPException(400, "Archive not found")
+        # IDOR fix (maziggy/bambuddy-security #2): without this check, a
+        # caller with QUEUE_CREATE could queue any user's archive even
+        # without ARCHIVES_READ on it — Landon's PoC enumerated this on
+        # admin's archives as operator1. Gate on ARCHIVES_READ_ALL OR
+        # ownership of the archive. 404 (not 403) so we don't leak
+        # "this id exists but you can't queue it" for enumeration.
+        if (
+            current_user is not None
+            and not current_user.has_permission(Permission.ARCHIVES_READ_ALL.value)
+            and archive.created_by_id != current_user.id
+        ):
+            raise HTTPException(404, "Archive not found")
 
     # Validate library file exists (if provided) and get it for filament extraction
     library_file = None
@@ -412,6 +432,13 @@ async def add_to_queue(
         library_file = result.scalar_one_or_none()
         if not library_file:
             raise HTTPException(400, "Library file not found")
+        # Same shape: gate cross-user library-file queueing on LIBRARY_READ_ALL.
+        if (
+            current_user is not None
+            and not current_user.has_permission(Permission.LIBRARY_READ_ALL.value)
+            and library_file.created_by_id != current_user.id
+        ):
+            raise HTTPException(404, "Library file not found")
         # Bambu SD card is FAT32/exFAT — illegal filename chars would 553 at
         # FTP upload time (#1540). Reject at queue time so the user gets the
         # actionable error before waiting in queue.
@@ -685,12 +712,20 @@ async def bulk_update_queue_items(
 async def list_batches(
     status: str | None = Query(None, description="Filter by status (active, completed, cancelled)"),
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.QUEUE_READ_ALL,
+            Permission.QUEUE_READ_OWN,
+        )
+    ),
 ):
     """List all print batches with progress stats."""
+    current_user, can_read_all = auth_result
     query = select(PrintBatch).order_by(PrintBatch.created_at.desc())
     if status:
         query = query.where(PrintBatch.status == status)
+    if current_user is not None and not can_read_all:
+        query = query.where(PrintBatch.created_by_id == current_user.id)
     result = await db.execute(query)
     batches = result.scalars().all()
 
@@ -704,13 +739,25 @@ async def list_batches(
 async def get_batch(
     batch_id: int,
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.QUEUE_READ_ALL,
+            Permission.QUEUE_READ_OWN,
+        )
+    ),
 ):
     """Get a print batch with progress stats."""
+    current_user, can_read_all = auth_result
     result = await db.execute(select(PrintBatch).where(PrintBatch.id == batch_id))
     batch = result.scalar_one_or_none()
     if not batch:
         raise HTTPException(404, "Batch not found")
+    if (
+        current_user is not None
+        and not can_read_all
+        and (batch.created_by_id is None or batch.created_by_id != current_user.id)
+    ):
+        raise HTTPException(404, "Batch not found")
     return await _build_batch_response(db, batch)
 
 
@@ -782,9 +829,15 @@ async def _build_batch_response(db: AsyncSession, batch: PrintBatch) -> PrintBat
 async def get_queue_item(
     item_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.QUEUE_READ_ALL,
+            Permission.QUEUE_READ_OWN,
+        )
+    ),
 ):
     """Get a specific queue item."""
+    current_user, can_read_all = auth_result
     result = await db.execute(
         select(PrintQueueItem)
         .options(
@@ -799,6 +852,12 @@ async def get_queue_item(
     item = result.scalar_one_or_none()
     if not item:
         raise HTTPException(404, "Queue item not found")
+    if (
+        current_user is not None
+        and not can_read_all
+        and (item.created_by_id is None or item.created_by_id != current_user.id)
+    ):
+        raise HTTPException(404, "Queue item not found")
     return _enrich_response(item)
 
 

+ 6 - 2
backend/app/api/routes/printers.py

@@ -1535,8 +1535,12 @@ async def delete_printer_file(
     if not printer:
         raise HTTPException(404, "Printer not found")
 
-    success = await delete_file_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
-    if not success:
+    from backend.app.services.bambu_ftp import DeleteResult
+
+    result = await delete_file_async(printer.ip_address, printer.access_code, path, printer_model=printer.model)
+    if result == DeleteResult.NOT_FOUND:
+        raise HTTPException(404, f"File not found on printer: {path}")
+    if result == DeleteResult.FAILED:
         raise HTTPException(500, f"Failed to delete file: {path}")
 
     return {"status": "deleted", "path": path}

+ 13 - 5
backend/app/api/routes/slice_jobs.py

@@ -5,9 +5,9 @@ job_id and a status_url pointing here. The frontend polls this until
 status flips to `completed` or `failed`.
 """
 
-from fastapi import APIRouter, HTTPException
+from fastapi import APIRouter, Depends, HTTPException
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import require_ownership_permission
 from backend.app.core.permissions import Permission
 from backend.app.models.user import User
 from backend.app.services.slice_dispatch import slice_dispatch
@@ -19,9 +19,17 @@ router = APIRouter(prefix="/slice-jobs", tags=["slice-jobs"])
 async def get_slice_job(
     job_id: int,
     # Job IDs are sequential integers and the body leaks source filenames
-    # plus the resulting library_file_id / archive_id. Gate on LIBRARY_READ
-    # — same baseline a user needs to see slice sources or results.
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_READ),
+    # 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(
+        require_ownership_permission(
+            Permission.LIBRARY_READ_ALL,
+            Permission.LIBRARY_READ_OWN,
+        )
+    ),
 ):
     job = slice_dispatch.get(job_id)
     if job is None:

+ 33 - 203
backend/app/api/routes/slicer_presets.py

@@ -16,7 +16,7 @@ import json
 import logging
 import time
 
-from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
+from fastapi import APIRouter, Depends, HTTPException, Query
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
@@ -26,7 +26,7 @@ from backend.app.api.routes.orca_cloud import (
     _build_authenticated_service as _build_orca_service,
     _load_credentials as _load_orca_credentials,
 )
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_ownership_permission
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
@@ -47,12 +47,8 @@ from backend.app.services.orca_cloud import (
     OrcaCloudError,
 )
 from backend.app.services.slicer_api import (
-    BundleNotFoundError,
-    BundleSummary,
     SlicerApiError,
     SlicerApiService,
-    SlicerApiUnavailableError,
-    SlicerInputError,
 )
 from backend.app.utils.printer_models import PRINTER_MODEL_MAP
 
@@ -173,11 +169,10 @@ async def _fetch_cloud_presets(
         # one-by-one trips Bambu's limiter and returns 429 on every request
         # for users with large preset libraries (#1150 follow-up).
         #
-        # The dedup pass (see _dedupe_by_name) compensates: when a cloud entry
-        # wins over a same-named local entry, the cloud entry inherits the
-        # local entry's filament_type / filament_colour. So cloud presets that
-        # also exist locally still get metadata-aware pre-pick in the
-        # SliceModal; cloud-only presets fall back to plain priority order.
+        # The metadata-enrich pass (see _enrich_cloud_metadata) compensates:
+        # a Bambu Cloud entry without its own filament_type/colour inherits
+        # those values from a same-named local / orca_cloud / standard entry
+        # so it can still score for type/colour matches in pickFilamentForSlot.
         _cloud_cache[cache_key] = (now, slots)
         return slots, "ok"
     finally:
@@ -420,7 +415,7 @@ async def _resolve_slicer_api_url(db: AsyncSession) -> str | None:
     return url or None
 
 
-def _dedupe_by_name(
+def _enrich_cloud_metadata(
     orca_cloud: dict[str, list[UnifiedPreset]],
     cloud: dict[str, list[UnifiedPreset]],
     local: dict[str, list[UnifiedPreset]],
@@ -431,26 +426,29 @@ def _dedupe_by_name(
     dict[str, list[UnifiedPreset]],
     dict[str, list[UnifiedPreset]],
 ]:
-    """Filter so each preset name appears in exactly one tier.
+    """Backfill Bambu Cloud filament metadata; do NOT dedup tiers.
 
-    Precedence: ``orca_cloud > cloud > local > standard``. Orca Cloud is
-    highest because a user who set up Orca sync is explicitly curating
-    those profiles for use here; Bambu Cloud follows for the same reason
-    one tier down. Order within each tier is preserved.
+    Every tier surfaces its full list — a name that exists in both ``local``
+    and ``orca_cloud`` shows up in BOTH dropdown groups so the user can pick
+    either source. Tier ORDER (``local > orca_cloud > cloud > standard``)
+    is communicated by the SliceModal's group rendering and by the
+    name-collision fallback in ``findPresetByName``; this function does not
+    enforce it.
 
-    Filament metadata merges across tiers: a Bambu Cloud entry without its
-    own ``filament_type`` / ``filament_colour`` (Bambu Cloud doesn't surface
+    Filament metadata merge: a Bambu Cloud entry without its own
+    ``filament_type`` / ``filament_colour`` (Bambu Cloud doesn't surface
     these in the list response for rate-limiting reasons — see
-    :func:`_fetch_cloud_presets`) inherits values from the same-named local
-    or standard entry. Orca Cloud already carries metadata inline, so no
-    backfill is needed for it.
+    :func:`_fetch_cloud_presets`) inherits values from a same-named entry
+    in ``local`` / ``orca_cloud`` / ``standard``. This is the only reason
+    this function exists post-#1712 — without the enrich the Bambu Cloud
+    tier can't score in ``pickFilamentForSlot``.
     """
-    # Build a name → metadata lookup from the tiers that carry it (orca_cloud,
-    # local, standard). Bambu cloud is intentionally skipped — it doesn't
-    # populate filament_type/colour in the list response. Take whichever
-    # non-empty entry shows up first.
+    # Build a name → metadata lookup from the tiers that carry it (local,
+    # orca_cloud, standard). Bambu cloud is intentionally skipped — it
+    # doesn't populate filament_type/colour in the list response. Take
+    # whichever non-empty entry shows up first.
     metadata_by_name: dict[str, tuple[str | None, str | None]] = {}
-    for tier in (orca_cloud, local, standard):
+    for tier in (local, orca_cloud, standard):
         for p in tier["filament"]:
             if p.name in metadata_by_name:
                 continue
@@ -466,27 +464,7 @@ def _dedupe_by_name(
             if p.filament_colour is None and c is not None:
                 p.filament_colour = c
 
-    deduped_cloud = _empty_slots()
-    deduped_local = _empty_slots()
-    deduped_standard = _empty_slots()
-    for slot in ("printer", "process", "filament"):
-        seen = {p.name for p in orca_cloud[slot]}
-        for p in cloud[slot]:
-            if p.name in seen:
-                continue
-            deduped_cloud[slot].append(p)
-            seen.add(p.name)
-        for p in local[slot]:
-            if p.name in seen:
-                continue
-            deduped_local[slot].append(p)
-            seen.add(p.name)
-        for p in standard[slot]:
-            if p.name in seen:
-                continue
-            deduped_standard[slot].append(p)
-            seen.add(p.name)
-    return orca_cloud, deduped_cloud, deduped_local, deduped_standard
+    return orca_cloud, cloud, local, standard
 
 
 @router.get("/printer-models")
@@ -541,7 +519,7 @@ async def list_unified_presets(
     local = await _fetch_local_presets(db)
     standard = await _fetch_bundled_presets(db, refresh=refresh)
 
-    orca_cloud, cloud, local, standard = _dedupe_by_name(orca_cloud, cloud, local, standard)
+    orca_cloud, cloud, local, standard = _enrich_cloud_metadata(orca_cloud, cloud, local, standard)
 
     return UnifiedPresetsResponse(
         orca_cloud=UnifiedPresetsBySlot(**orca_cloud),
@@ -553,164 +531,16 @@ async def list_unified_presets(
     )
 
 
-def _bundle_summary_to_dict(b: BundleSummary) -> dict:
-    """Serialize a BundleSummary for the JSON response. The frontend uses
-    these arrays to populate the preset dropdowns when a user picks the
-    bundle as the slice source.
-    """
-    return {
-        "id": b.id,
-        "printer_preset_name": b.printer_preset_name,
-        "printer": b.printer,
-        "process": b.process,
-        "filament": b.filament,
-        "version": b.version,
-    }
-
-
-@router.post("/bundles", status_code=201)
-async def import_slicer_bundle(
-    file: UploadFile = File(...),
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
-):
-    """Forward a BambuStudio Printer Preset Bundle (.bbscfg) to the sidecar.
-
-    The user exports their printer's preset bundle from BambuStudio (File
-    -> Export -> Export Preset Bundle, "Printer preset bundle" option).
-    Uploading it here unpacks the bundle on the sidecar and exposes its
-    inner printer / process / filament presets to subsequent slice
-    requests via the bundle-id selector.
-
-    Idempotent: re-uploading the same file yields the same id (sidecar
-    hashes the zip content), so duplicate uploads collapse rather than
-    accumulate.
-    """
-    api_url = await _resolve_slicer_api_url(db)
-    if not api_url:
-        raise HTTPException(status_code=503, detail="No slicer sidecar configured")
-
-    # Multer on the sidecar caps bundle uploads at 50MB. We don't enforce
-    # that here — let the sidecar's filter own the limit so it stays in
-    # one place — but we do reject empty / huge files at the FastAPI
-    # layer to avoid pointlessly streaming them to the sidecar first.
-    contents = await file.read()
-    if not contents:
-        raise HTTPException(status_code=400, detail="Bundle file is empty")
-    filename = file.filename or "bundle.bbscfg"
-
-    try:
-        async with SlicerApiService(base_url=api_url) as svc:
-            summary = await svc.import_bundle(contents, filename=filename)
-    except SlicerInputError as e:
-        # Sidecar's 4xx — most likely a non-.bbscfg upload, a corrupt zip,
-        # or a path-traversal entry that the manifest validator caught.
-        # Log the detail so it lands in the support bundle: the FE-only
-        # toast was leaving us blind during triage (#1312).
-        logger.warning(
-            "Bundle import rejected by sidecar (%s, %d bytes): %s",
-            filename,
-            len(contents),
-            e,
-        )
-        raise HTTPException(status_code=400, detail=str(e)) from e
-    except SlicerApiUnavailableError as e:
-        logger.warning("Bundle import: sidecar unreachable (%s): %s", api_url, e)
-        raise HTTPException(status_code=503, detail=str(e)) from e
-    except SlicerApiError as e:
-        logger.warning(
-            "Bundle import: sidecar server error (%s, %d bytes): %s",
-            filename,
-            len(contents),
-            e,
-        )
-        # 5xx from the sidecar's import path is rare — usually a disk
-        # write failure inside DATA_PATH/bundles. 502 (bad gateway) is
-        # closer to the truth than 500 here, since we're proxying.
-        raise HTTPException(status_code=502, detail=str(e)) from e
-    return _bundle_summary_to_dict(summary)
-
-
-@router.get("/bundles")
-async def list_slicer_bundles(
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
-):
-    """List every Printer Preset Bundle currently stored on the sidecar.
-
-    Drives the SliceModal's "Bundle" tier and a Settings panel where
-    users can review / delete imported bundles. Returns ``[]`` when the
-    sidecar has no bundles imported yet.
-    """
-    api_url = await _resolve_slicer_api_url(db)
-    if not api_url:
-        # No sidecar configured: empty list rather than 503 so the modal
-        # renders cleanly. Same shape as the bundled-presets fallback.
-        return []
-    try:
-        async with SlicerApiService(base_url=api_url) as svc:
-            bundles = await svc.list_bundles()
-    except SlicerApiUnavailableError as e:
-        # Sidecar offline: surface as 503 so the frontend can show a
-        # banner. Differs from the bundled-tier behaviour because that
-        # path also has cloud + local fallbacks; bundles is the only
-        # source for its tier.
-        raise HTTPException(status_code=503, detail=str(e)) from e
-    except SlicerApiError as e:
-        raise HTTPException(status_code=502, detail=str(e)) from e
-    return [_bundle_summary_to_dict(b) for b in bundles]
-
-
-@router.get("/bundles/{bundle_id}")
-async def get_slicer_bundle(
-    bundle_id: str,
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
-):
-    """Return one bundle by id. 404 if it doesn't exist on the sidecar."""
-    api_url = await _resolve_slicer_api_url(db)
-    if not api_url:
-        raise HTTPException(status_code=503, detail="No slicer sidecar configured")
-    try:
-        async with SlicerApiService(base_url=api_url) as svc:
-            summary = await svc.get_bundle(bundle_id)
-    except BundleNotFoundError as e:
-        raise HTTPException(status_code=404, detail=str(e)) from e
-    except SlicerApiUnavailableError as e:
-        raise HTTPException(status_code=503, detail=str(e)) from e
-    except SlicerApiError as e:
-        raise HTTPException(status_code=502, detail=str(e)) from e
-    return _bundle_summary_to_dict(summary)
-
-
-@router.delete("/bundles/{bundle_id}", status_code=204)
-async def delete_slicer_bundle(
-    bundle_id: str,
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
-):
-    """Remove a stored bundle from the sidecar. Future slice requests
-    referencing this id will fail with 404 from the sidecar.
-    """
-    api_url = await _resolve_slicer_api_url(db)
-    if not api_url:
-        raise HTTPException(status_code=503, detail="No slicer sidecar configured")
-    try:
-        async with SlicerApiService(base_url=api_url) as svc:
-            await svc.delete_bundle(bundle_id)
-    except BundleNotFoundError as e:
-        raise HTTPException(status_code=404, detail=str(e)) from e
-    except SlicerApiUnavailableError as e:
-        raise HTTPException(status_code=503, detail=str(e)) from e
-    except SlicerApiError as e:
-        raise HTTPException(status_code=502, detail=str(e)) from e
-
-
 @router.get("/preview-progress/{request_id}")
 async def get_preview_slice_progress(
     request_id: str,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_READ),
+    _: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_READ_ALL,
+            Permission.LIBRARY_READ_OWN,
+        )
+    ),
 ):
     """Proxy to the sidecar's ``GET /slice/progress/:requestId``.
 

+ 39 - 7
backend/app/api/routes/spoolman_inventory.py

@@ -43,6 +43,7 @@ from backend.app.models.user import User
 from backend.app.schemas.spool import SpoolKProfileBase
 from backend.app.schemas.spoolman import SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched
 from backend.app.services.printer_manager import printer_manager
+from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
 from backend.app.services.spoolman import (
     SpoolmanClient,
     SpoolmanClientError,
@@ -55,6 +56,7 @@ from backend.app.services.spoolman_tracking import get_fallback_spool_tag_for_sl
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
     MATERIAL_TEMPS,
+    filament_id_to_setting_id,
     normalize_slicer_filament,
 )
 
@@ -1221,7 +1223,7 @@ async def sync_spoolman_ams_weights(
 async def assign_spoolman_slot(
     body: SpoolSlotAssignmentRequest,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ) -> dict:
     """Assign a Spoolman spool to a printer AMS slot (stored in local DB only).
 
@@ -1306,13 +1308,43 @@ async def assign_spoolman_slot(
             if len(tray_color) == 6:
                 tray_color = tray_color + "FF"
 
-            material_upper = tray_type.upper().strip()
-            tray_info_idx = (
-                GENERIC_FILAMENT_IDS.get(material_upper)
-                or GENERIC_FILAMENT_IDS.get(material_upper.split("-")[0].split(" ")[0])
-                or ""
+            # #1713: resolve the spool's stored slicer_filament reference
+            # (cloud preset, local preset, GF-prefix builtin, or numeric
+            # LocalPreset id) to the printer-side tray_info_idx + setting_id.
+            # Previously the Spoolman path dropped slicer_filament on the
+            # floor and only the generic-material fallback fired; the user-
+            # configured profile never reached the printer. Shared with the
+            # internal-mode route via the same helper so the two flows can't
+            # drift again.
+            tray_info_idx, setting_id, sub_brand_override = await resolve_slicer_filament(
+                db=db,
+                current_user=current_user,
+                slicer_filament=mapped.get("slicer_filament"),
+                slicer_filament_name=mapped.get("slicer_filament_name"),
+                material=tray_type,
             )
-            setting_id = ""
+            if sub_brand_override:
+                tray_sub_brands = sub_brand_override
+
+            material_upper = tray_type.upper().strip()
+            # Fall back to generic-material id when slicer_filament is empty
+            # or the resolver discarded an unresolvable value. Matches the
+            # internal-mode tail in inventory.py:_apply_spool_to_slot_inner.
+            if not tray_info_idx:
+                tray_info_idx = (
+                    GENERIC_FILAMENT_IDS.get(material_upper)
+                    or GENERIC_FILAMENT_IDS.get(material_upper.split("-")[0].split(" ")[0])
+                    or ""
+                )
+
+            # Ensure setting_id is always derivable from tray_info_idx. The
+            # local-preset path can leave it empty when the LP's setting JSON
+            # has no filament_id and falls through to the generic material id;
+            # without this fallback the slicer gets a half-configured slot
+            # (filament id without setting id) and the slot detail modal
+            # renders empty fields. Same pattern as the internal-mode tail.
+            if tray_info_idx and not setting_id:
+                setting_id = filament_id_to_setting_id(tray_info_idx)
 
             temp_defaults = MATERIAL_TEMPS.get(material_upper, (200, 240))
             temp_min = mapped.get("nozzle_temp_min") or temp_defaults[0]

+ 87 - 1
backend/app/api/routes/support.py

@@ -669,7 +669,7 @@ async def _collect_support_info() -> dict:
     in_docker = is_running_in_docker()
 
     info = {
-        "generated_at": datetime.now().isoformat(),
+        "generated_at": datetime.now(timezone.utc).isoformat(),
         "app": {
             "version": APP_VERSION,
             "debug_mode": settings.debug,
@@ -1140,6 +1140,63 @@ def _get_log_content(max_bytes: int = 10 * 1024 * 1024, sensitive_strings: dict[
     return content.encode("utf-8")
 
 
+# Top-level push_status keys that carry user-private data (filenames, BambuCloud
+# IDs). Dropped from the bundled per-printer snapshot. Keep print.cfg /
+# print.option / ams / vt_tray / vir_slot / mapping — those are the fields that
+# make the snapshot worth shipping (per-model AMS Backup detection, tray-shape
+# research, VP regression baselines).
+_RAW_DATA_DROP_KEYS = frozenset(
+    {
+        "subtask_name",
+        "gcode_file",
+        "gcode_file_prepare_percent",
+        "subtask_id",
+        "task_id",
+        "project_id",
+        "gcode_state",  # not sensitive, but mirrors current_print which we strip
+        "design_id",
+        "profile_id",
+        "model_id",
+    }
+)
+
+
+def _redact_raw_push_status(raw: dict) -> dict:
+    """Strip user-private keys from a cached push_status snapshot.
+
+    Drops the keys in :data:`_RAW_DATA_DROP_KEYS` anywhere in the tree, then
+    rewrites every entry under ``net.info[*].ip`` to ``"0.0.0.0"``. Mirrors the
+    LAN-topology leak fixed in the virtual-printer bridge (#1429) — the same
+    field exposes the printer's local IP plus the gateway/peers it sees. Returns
+    a NEW dict; the live ``state.raw_data`` is never mutated.
+    """
+
+    if not isinstance(raw, dict):
+        return {}
+
+    def _walk(value):
+        if isinstance(value, dict):
+            return {k: _walk(v) for k, v in value.items() if k not in _RAW_DATA_DROP_KEYS}
+        if isinstance(value, list):
+            return [_walk(v) for v in value]
+        return value
+
+    out = _walk(raw)
+
+    # Scrub net.info[*].ip after the structural walk — only meaningful at the
+    # top level; nested "net" blocks don't appear in Bambu push_status payloads.
+    net = out.get("net")
+    if isinstance(net, dict):
+        info_list = net.get("info")
+        if isinstance(info_list, list):
+            net["info"] = [
+                ({**entry, "ip": "0.0.0.0"} if isinstance(entry, dict) and "ip" in entry else entry)  # nosec B104 - redaction sentinel, not a bind address
+                for entry in info_list
+            ]
+
+    return out
+
+
 async def _get_recent_sanitized_logs(max_lines: int = 200) -> str:
     """Get recent log lines, sanitized for inclusion in bug reports."""
     # Collect sensitive strings from DB for redaction
@@ -1191,6 +1248,35 @@ async def generate_support_bundle(
         # Add support info JSON
         zf.writestr("support-info.json", json.dumps(support_info, indent=2, default=str))
 
+        # Per-printer cached push_status dump. Bambu firmware ships per-model
+        # config in a different shape for every family (the bit-26 / print.cfg
+        # gap that blocked AMS Backup awareness in 85fbd7fc), and shape-of-
+        # vt_tray / mapping / vir_slot has bitten the VP bridge repeatedly.
+        # Including the redacted snapshot turns every future support bundle
+        # into a ground-truth sample for that exact model+firmware. Index
+        # matches the 1-based ordering in support-info.json["printers"] so a
+        # maintainer can cross-reference without re-deriving identifiers.
+        statuses = printer_manager.get_all_statuses()
+        async with async_session() as db:
+            db_printers = (await db.execute(select(Printer))).scalars().all()
+        for i, printer in enumerate(db_printers):
+            state = statuses.get(printer.id)
+            if state is None or not state.raw_data:
+                continue
+            redacted = _redact_raw_push_status(state.raw_data)
+            snapshot = {
+                "model": printer.model or "Unknown",
+                "firmware_version": state.firmware_version,
+                "captured_at": datetime.now(timezone.utc).isoformat(),
+                "raw_data": redacted,
+            }
+            # Belt-and-suspenders: pass the JSON text through the string-based
+            # sanitizer so any user-named string (printer name, serial baked
+            # into a tray uuid) the structural pass missed still gets caught.
+            snapshot_json = json.dumps(snapshot, indent=2, default=str)
+            snapshot_json = sanitize_log_content(snapshot_json, sensitive_strings)
+            zf.writestr(f"push-status/printer-{i + 1}.json", snapshot_json)
+
         # Add log file
         log_content = _get_log_content(sensitive_strings=sensitive_strings)
         zf.writestr("bambuddy.log", log_content)

+ 5 - 5
backend/app/api/routes/system.py

@@ -5,7 +5,7 @@ import os
 import platform
 import time
 from collections.abc import Callable
-from datetime import datetime
+from datetime import datetime, timezone
 from pathlib import Path
 
 import psutil
@@ -385,7 +385,7 @@ async def _get_storage_usage_cached(refresh: bool, max_age_seconds: int) -> dict
         snapshot = await asyncio.to_thread(_scan_storage_usage)
         _storage_usage_cache = {
             **snapshot,
-            "generated_at": datetime.now().isoformat(),
+            "generated_at": datetime.now(timezone.utc).isoformat(),
         }
         _storage_usage_cache_ts = time.time()
         return {
@@ -504,10 +504,10 @@ async def get_system_info(
     # (#1690). On bare metal / VMs PID 1 is the host init, which starts at
     # boot, so the value matches psutil.boot_time() within a sub-second.
     try:
-        boot_time = datetime.fromtimestamp(psutil.Process(1).create_time())
+        boot_time = datetime.fromtimestamp(psutil.Process(1).create_time(), tz=timezone.utc)
     except (psutil.Error, OSError):
-        boot_time = datetime.fromtimestamp(psutil.boot_time())
-    uptime_seconds = (datetime.now() - boot_time).total_seconds()
+        boot_time = datetime.fromtimestamp(psutil.boot_time(), tz=timezone.utc)
+    uptime_seconds = (datetime.now(timezone.utc) - boot_time).total_seconds()
 
     # Python and system info
     import sys

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

@@ -181,12 +181,16 @@ def _parse_github_remote(url: str) -> tuple[str, str] | None:
     return (parts[0], parts[1])
 
 
-async def _origin_points_at_repo(git_path: str, git_config: list[str], base_dir, expected_repo: str) -> bool:
+async def _origin_points_at_repo(git_path: str, git_config: list[str], app_dir, expected_repo: str) -> bool:
     """Return True iff the working tree's `origin` already resolves to
     `<owner>/<repo>` matching `expected_repo` (e.g. "maziggy/bambuddy"),
     regardless of whether it's the SSH or HTTPS form. Used to skip the
     `git remote set-url origin https://...` rewrite when the developer's
-    SSH origin is already correct — see `_perform_update` for context."""
+    SSH origin is already correct — see `_perform_update` for context.
+
+    ``app_dir`` is the working tree (where ``.git`` lives), not the data
+    dir — see #1715 for the separate-mount layout that proved why this
+    must NOT be ``base_dir``."""
     try:
         process = await asyncio.create_subprocess_exec(
             git_path,
@@ -194,7 +198,7 @@ async def _origin_points_at_repo(git_path: str, git_config: list[str], base_dir,
             "remote",
             "get-url",
             "origin",
-            cwd=str(base_dir),
+            cwd=str(app_dir),
             stdout=asyncio.subprocess.PIPE,
             stderr=asyncio.subprocess.PIPE,
         )
@@ -555,7 +559,16 @@ async def _perform_update(target_ref: str):
     global _update_status
 
     try:
-        base_dir = settings.base_dir
+        # Every git step runs against the working tree (app_dir), NOT base_dir.
+        # On a standard install with DATA_DIR=INSTALL_PATH/data, git happens
+        # to walk up from a subdirectory of the repo to find .git so cwd=base_dir
+        # used to silently work — but only by accident. On a native install with
+        # DATA_DIR mounted at an unrelated path (e.g. /srv/bambuddy/data while
+        # the install is /opt/bambuddy — see #1715), git can't walk up and every
+        # operation fails with "not a git repository". safe.directory has the
+        # same requirement: it must equal the repo root git discovers, not the
+        # data dir, or every call returns "fatal: detected dubious ownership."
+        app_dir = settings.app_dir
 
         # Find git executable (may not be in PATH when running as systemd service)
         git_path = _find_executable("git")
@@ -570,8 +583,9 @@ async def _perform_update(target_ref: str):
 
         logger.info("Using git at: %s", git_path)
 
-        # Git config to avoid safe.directory issues
-        git_config = ["-c", f"safe.directory={base_dir}"]
+        # Git config to avoid safe.directory issues — must point at the working
+        # tree (where .git lives), see app_dir comment above.
+        git_config = ["-c", f"safe.directory={app_dir}"]
 
         _update_status = {
             "status": "downloading",
@@ -593,7 +607,7 @@ async def _perform_update(target_ref: str):
         # correct repo are preserved; only missing / wrong / corrupted
         # origins get reset to HTTPS.
         https_url = f"https://github.com/{GITHUB_REPO}.git"
-        if not await _origin_points_at_repo(git_path, git_config, base_dir, GITHUB_REPO):
+        if not await _origin_points_at_repo(git_path, git_config, app_dir, GITHUB_REPO):
             process = await asyncio.create_subprocess_exec(
                 git_path,
                 *git_config,
@@ -601,7 +615,7 @@ async def _perform_update(target_ref: str):
                 "set-url",
                 "origin",
                 https_url,
-                cwd=str(base_dir),
+                cwd=str(app_dir),
                 stdout=asyncio.subprocess.PIPE,
                 stderr=asyncio.subprocess.PIPE,
             )
@@ -635,7 +649,7 @@ async def _perform_update(target_ref: str):
             "--tags",
             "--force",
             "origin",
-            cwd=str(base_dir),
+            cwd=str(app_dir),
             stdout=asyncio.subprocess.PIPE,
             stderr=asyncio.subprocess.PIPE,
         )
@@ -671,7 +685,7 @@ async def _perform_update(target_ref: str):
             "reset",
             "--hard",
             target_ref,
-            cwd=str(base_dir),
+            cwd=str(app_dir),
             stdout=asyncio.subprocess.PIPE,
             stderr=asyncio.subprocess.PIPE,
         )
@@ -696,12 +710,9 @@ async def _perform_update(target_ref: str):
         }
 
         # Install Python dependencies — must run from the source-code directory
-        # (where requirements.txt lives), not the data dir. On native installs
-        # systemd sets DATA_DIR=INSTALL_PATH/data, so `base_dir` is the data dir,
-        # not the working tree. `git reset` above worked from base_dir because
-        # git walks up looking for .git, but `pip install -r requirements.txt`
-        # needs the file in cwd literally.
-        app_dir = settings.app_dir
+        # (where requirements.txt lives). app_dir is already resolved at the top
+        # of this function; see the comment there for why every step uses it
+        # instead of base_dir.
         process = await asyncio.create_subprocess_exec(
             sys.executable,
             "-m",

+ 14 - 3
backend/app/api/routes/users.py

@@ -12,6 +12,7 @@ from backend.app.api.routes.settings import get_external_login_url
 from backend.app.core.auth import (
     ALGORITHM,
     SECRET_KEY,
+    RequireAdminIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     get_current_user_optional,
     get_password_hash,
@@ -66,7 +67,13 @@ async def list_users(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
     db: AsyncSession = Depends(get_db),
 ):
-    """List all users."""
+    """List all users.
+
+    Read-only — gated on ``USERS_READ`` only. Operator-visible UIs
+    (Stats filter-by-user, Archives Print Log username column, File
+    Manager username autocomplete) consume this endpoint via custom-
+    group ``users:read`` grants without admin role. The admin-only
+    boundary lives on the write endpoints below."""
     result = await db.execute(select(User).options(selectinload(User.groups)).order_by(User.created_at))
     users = result.scalars().all()
     return [_user_to_response(user) for user in users]
@@ -76,6 +83,7 @@ async def list_users(
 @router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
 async def create_user(
     user_data: UserCreate,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),
     db: AsyncSession = Depends(get_db),
 ):
@@ -185,7 +193,7 @@ async def get_user(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
     db: AsyncSession = Depends(get_db),
 ):
-    """Get a user by ID."""
+    """Get a user by ID. Read-only — gated on ``USERS_READ`` only."""
     result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
     user = result.scalar_one_or_none()
     if not user:
@@ -201,6 +209,7 @@ async def get_user(
 async def update_user(
     user_id: int,
     user_data: UserUpdate,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ):
@@ -311,7 +320,8 @@ async def get_user_items_count(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
     db: AsyncSession = Depends(get_db),
 ):
-    """Get count of items created by this user."""
+    """Get count of items created by this user. Read-only — gated on
+    ``USERS_READ`` only."""
     # Verify user exists
     result = await db.execute(select(User).where(User.id == user_id))
     if not result.scalar_one_or_none():
@@ -350,6 +360,7 @@ async def get_user_items_count(
 async def delete_user(
     user_id: int,
     delete_items: bool = Query(False, description="Delete all items created by this user"),
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_DELETE),
     db: AsyncSession = Depends(get_db),
 ):

+ 22 - 8
backend/app/core/auth.py

@@ -61,9 +61,19 @@ logger = logging.getLogger(__name__)
 _APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str] = {
     # can_read_status — read-only access to status, history, and configuration
     Permission.PRINTERS_READ: "can_read_status",
+    # Legacy flat permissions retained for back-compat with custom API keys —
+    # the role bootstraps no longer use these, but custom keys may still
+    # carry can_read_status scope mapping. New endpoints gate on the
+    # ARCHIVES_READ_OWN / _ALL split (maziggy/bambuddy-security #2).
     Permission.ARCHIVES_READ: "can_read_status",
+    Permission.ARCHIVES_READ_OWN: "can_read_status",
+    Permission.ARCHIVES_READ_ALL: "can_read_status",
     Permission.QUEUE_READ: "can_read_status",
+    Permission.QUEUE_READ_OWN: "can_read_status",
+    Permission.QUEUE_READ_ALL: "can_read_status",
     Permission.LIBRARY_READ: "can_read_status",
+    Permission.LIBRARY_READ_OWN: "can_read_status",
+    Permission.LIBRARY_READ_ALL: "can_read_status",
     Permission.PROJECTS_READ: "can_read_status",
     Permission.FILAMENTS_READ: "can_read_status",
     Permission.INVENTORY_READ: "can_read_status",
@@ -1019,13 +1029,17 @@ def require_admin_if_auth_enabled():
     key" — the inner ``admin_checker`` then treated ``None`` as auth-
     disabled and admitted the caller. If any route had ever adopted this
     dep, any API key with no scope flags set would have satisfied an
-    admin requirement.
-
-    Today no route uses this dep, but rather than leave the footgun
-    armed, the dep is rewritten to distinguish the two cases by
-    consulting ``is_auth_enabled`` directly and rejecting API-keyed
-    requests with 403. "Admin" requires a user-identity role, which API
-    keys do not carry.
+    admin requirement. The dep distinguishes the two cases by consulting
+    ``is_auth_enabled`` directly and rejecting API-keyed requests with
+    403. "Admin" requires a user-identity role, which API keys do not
+    carry.
+
+    Admin semantics: uses ``User.is_admin`` (``role == "admin"`` OR
+    Administrators-group membership) so a default-install operator who
+    was made admin by being added to Administrators rather than by
+    flipping the legacy role column passes. Earlier this check looked
+    only at ``role`` and would have locked group-only admins out of the
+    user-management routes once those routes started requiring it.
     """
 
     async def admin_checker(
@@ -1091,7 +1105,7 @@ def require_admin_if_auth_enabled():
                     detail="Could not validate credentials",
                     headers={"WWW-Authenticate": "Bearer"},
                 )
-            if user.role != "admin":
+            if not user.is_admin:
                 raise HTTPException(
                     status_code=status.HTTP_403_FORBIDDEN,
                     detail="Requires admin role",

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

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

+ 101 - 1
backend/app/core/database.py

@@ -2974,7 +2974,20 @@ async def seed_default_groups():
     logger = logging.getLogger(__name__)
 
     # Map old permissions to new ones for migration
-    # Administrators get *_all permissions, Operators get *_own permissions
+    # Administrators get *_all permissions, Operators get *_own permissions.
+    #
+    # NOTE on the read-flag asymmetry: write permissions (`update`, `delete`,
+    # `reprint`) are removed from the legacy flag and remapped to the OWN/ALL
+    # split — the legacy flag is dead on the API side. Read permissions are
+    # different: the frontend still gates UI actions (download buttons in
+    # ArchivesPage, preview button in FileManagerPage) on the LEGACY
+    # `archives:read` / `library:read` / `queue:read` strings. For admin we
+    # therefore keep the legacy flag (the `*_all` companion gets added via the
+    # backfill block below). For non-admin roles the legacy IS renamed to
+    # `_own` — that closes the IDOR (operators with a custom `archives:read`
+    # row can no longer read cross-user data) and the UI gates degrade to
+    # disabled-button state until the frontend is migrated to also accept
+    # `_own` (separate change). See maziggy/bambuddy-security #2.
     PERMISSION_MIGRATION_ALL = {
         "queue:update": "queue:update_all",
         "queue:delete": "queue:delete_all",
@@ -2988,11 +3001,20 @@ async def seed_default_groups():
     PERMISSION_MIGRATION_OWN = {
         "queue:update": "queue:update_own",
         "queue:delete": "queue:delete_own",
+        # Read permissions: any role NOT flagged as Administrator gets
+        # ownership-scoped reads. Pre-existing custom roles with the legacy
+        # `*:read` flag silently saw every user's items; the OWN variant
+        # closes that IDOR. Roles that genuinely need cross-user visibility
+        # must be re-granted `*:read_all` explicitly by an administrator
+        # after upgrade — fail-closed by default (per CWE-636).
+        "queue:read": "queue:read_own",
         "archives:update": "archives:update_own",
         "archives:delete": "archives:delete_own",
         "archives:reprint": "archives:reprint_own",
+        "archives:read": "archives:read_own",
         "library:update": "library:update_own",
         "library:delete": "library:delete_own",
+        "library:read": "library:read_own",
     }
 
     async with async_session() as session:
@@ -3040,11 +3062,14 @@ async def seed_default_groups():
                         for _own_perm, all_perm in [
                             ("queue:update_own", "queue:update_all"),
                             ("queue:delete_own", "queue:delete_all"),
+                            ("queue:read_own", "queue:read_all"),
                             ("archives:update_own", "archives:update_all"),
                             ("archives:delete_own", "archives:delete_all"),
                             ("archives:reprint_own", "archives:reprint_all"),
+                            ("archives:read_own", "archives:read_all"),
                             ("library:update_own", "library:update_all"),
                             ("library:delete_own", "library:delete_all"),
+                            ("library:read_own", "library:read_all"),
                         ]:
                             # Add *_all if not present
                             if all_perm not in new_permissions:
@@ -3113,6 +3138,81 @@ async def seed_default_groups():
                 admin_group.permissions = perms
         await session.commit()
 
+        # Backfill the read flag set for the Administrators group on existing
+        # installs (maziggy/bambuddy-security #2). Two layers:
+        #
+        # (a) New OWN/ALL splits — `archives:read_own` etc. Fresh installs get
+        #     these via ALL_PERMISSIONS; upgrades need the explicit backfill
+        #     so admin's permission set matches a fresh install's.
+        #
+        # (b) Legacy `archives:read` / `library:read` / `queue:read`. The
+        #     frontend still gates download / preview UI on these LEGACY
+        #     strings (see ArchivesPage / FileManagerPage), so admin needs
+        #     them retained even though the new API uses the OWN/ALL split.
+        #     The PERMISSION_MIGRATION_ALL map deliberately doesn't rename
+        #     read flags for admin — this backfill ensures they're present
+        #     even if they were stripped by hand or by an older migration.
+        #
+        # Also includes orca_cloud:auth for parity with fresh-install
+        # behaviour (ALL_PERMISSIONS covers it; backfill makes sure an
+        # admin role that's been customised since seed still has it).
+        result = await session.execute(select(Group).where(Group.name == "Administrators"))
+        admin_group = result.scalar_one_or_none()
+        if admin_group and admin_group.permissions is not None:
+            perms = list(admin_group.permissions)
+            added = False
+            for new_perm in (
+                "archives:read",
+                "archives:read_own",
+                "archives:read_all",
+                "library:read",
+                "library:read_own",
+                "library:read_all",
+                "queue:read",
+                "queue:read_own",
+                "queue:read_all",
+                "orca_cloud:auth",
+            ):
+                if new_perm not in perms:
+                    perms.append(new_perm)
+                    added = True
+                    logger.info("Added %s to Administrators group (backfill)", new_perm)
+            if added:
+                admin_group.permissions = perms
+        await session.commit()
+
+        # Same OWN-tier backfill for non-admin system groups. Operators and
+        # Viewers are seeded with _own on fresh installs (see DEFAULT_GROUPS),
+        # but the legacy-rename migration above won't run on a role that
+        # didn't carry the legacy `archives:read` flag. Without this block,
+        # an existing Operators row whose permissions list lacks the legacy
+        # flag would never get archives:read_own and operators would lose
+        # read access after upgrade. Re-check by group name so customised
+        # rows still get the correct OWN tier on next startup.
+        #
+        # Operators also get orca_cloud:auth backfilled — fresh installs now
+        # include it in the DEFAULT_GROUPS bootstrap, so this keeps upgrades
+        # consistent. Viewers do NOT get orca_cloud:auth (read-only role,
+        # not expected to author slicer presets / sync to Orca Cloud).
+        for non_admin_group_name in ("Operators", "Viewers"):
+            grp = (await session.execute(select(Group).where(Group.name == non_admin_group_name))).scalar_one_or_none()
+            if grp is None or grp.permissions is None:
+                continue
+            perms = list(grp.permissions)
+            changed = False
+            for own_perm in ("archives:read_own", "library:read_own", "queue:read_own"):
+                if own_perm not in perms:
+                    perms.append(own_perm)
+                    changed = True
+                    logger.info("Added %s to %s group (backfill)", own_perm, non_admin_group_name)
+            if non_admin_group_name == "Operators" and "orca_cloud:auth" not in perms:
+                perms.append("orca_cloud:auth")
+                changed = True
+                logger.info("Added orca_cloud:auth to Operators group (backfill)")
+            if changed:
+                grp.permissions = perms
+        await session.commit()
+
         # Backfill inventory forecast permissions for existing groups.
         # inventory:forecast_read was added after initial seeding, so groups
         # that already have inventory:read (or inventory:update) need it added.

+ 30 - 9
backend/app/core/permissions.py

@@ -25,7 +25,12 @@ class Permission(StrEnum):
     PRINTERS_CLEAR_PLATE = "printers:clear_plate"  # Confirm plate cleared for next print
 
     # Archives
+    # ARCHIVES_READ kept for backward-compat with legacy custom roles, but new
+    # role bootstraps use the ownership-split variants below. seed_default_groups
+    # migrates pre-existing role rows: Administrators → ALL, everyone else → OWN.
     ARCHIVES_READ = "archives:read"
+    ARCHIVES_READ_OWN = "archives:read_own"
+    ARCHIVES_READ_ALL = "archives:read_all"
     ARCHIVES_CREATE = "archives:create"
     ARCHIVES_UPDATE_OWN = "archives:update_own"
     ARCHIVES_UPDATE_ALL = "archives:update_all"
@@ -37,6 +42,8 @@ class Permission(StrEnum):
 
     # Queue
     QUEUE_READ = "queue:read"
+    QUEUE_READ_OWN = "queue:read_own"
+    QUEUE_READ_ALL = "queue:read_all"
     QUEUE_CREATE = "queue:create"
     QUEUE_UPDATE_OWN = "queue:update_own"
     QUEUE_UPDATE_ALL = "queue:update_all"
@@ -46,6 +53,8 @@ class Permission(StrEnum):
 
     # Library
     LIBRARY_READ = "library:read"
+    LIBRARY_READ_OWN = "library:read_own"
+    LIBRARY_READ_ALL = "library:read_all"
     LIBRARY_UPLOAD = "library:upload"
     LIBRARY_UPDATE_OWN = "library:update_own"
     LIBRARY_UPDATE_ALL = "library:update_all"
@@ -185,7 +194,9 @@ PERMISSION_CATEGORIES = {
         Permission.PRINTERS_CLEAR_PLATE,
     ],
     "Archives": [
-        Permission.ARCHIVES_READ,
+        Permission.ARCHIVES_READ,  # legacy — kept for back-compat with custom roles
+        Permission.ARCHIVES_READ_OWN,
+        Permission.ARCHIVES_READ_ALL,
         Permission.ARCHIVES_CREATE,
         Permission.ARCHIVES_UPDATE_OWN,
         Permission.ARCHIVES_UPDATE_ALL,
@@ -196,7 +207,9 @@ PERMISSION_CATEGORIES = {
         Permission.ARCHIVES_PURGE,
     ],
     "Queue": [
-        Permission.QUEUE_READ,
+        Permission.QUEUE_READ,  # legacy — kept for back-compat with custom roles
+        Permission.QUEUE_READ_OWN,
+        Permission.QUEUE_READ_ALL,
         Permission.QUEUE_CREATE,
         Permission.QUEUE_UPDATE_OWN,
         Permission.QUEUE_UPDATE_ALL,
@@ -205,7 +218,9 @@ PERMISSION_CATEGORIES = {
         Permission.QUEUE_REORDER,
     ],
     "Library": [
-        Permission.LIBRARY_READ,
+        Permission.LIBRARY_READ,  # legacy — kept for back-compat with custom roles
+        Permission.LIBRARY_READ_OWN,
+        Permission.LIBRARY_READ_ALL,
         Permission.LIBRARY_UPLOAD,
         Permission.LIBRARY_UPDATE_OWN,
         Permission.LIBRARY_UPDATE_ALL,
@@ -350,25 +365,31 @@ DEFAULT_GROUPS = {
             Permission.PRINTERS_AMS_RFID.value,
             Permission.PRINTERS_CLEAR_PLATE.value,
             # Archives - own items only
-            Permission.ARCHIVES_READ.value,
+            Permission.ARCHIVES_READ_OWN.value,
             Permission.ARCHIVES_CREATE.value,
             Permission.ARCHIVES_UPDATE_OWN.value,
             Permission.ARCHIVES_DELETE_OWN.value,
             Permission.ARCHIVES_REPRINT_OWN.value,
             # Queue - own items only
-            Permission.QUEUE_READ.value,
+            Permission.QUEUE_READ_OWN.value,
             Permission.QUEUE_CREATE.value,
             Permission.QUEUE_UPDATE_OWN.value,
             Permission.QUEUE_DELETE_OWN.value,
             Permission.QUEUE_REORDER.value,
             # Library - own items only
-            Permission.LIBRARY_READ.value,
+            Permission.LIBRARY_READ_OWN.value,
             Permission.LIBRARY_UPLOAD.value,
             Permission.LIBRARY_UPDATE_OWN.value,
             Permission.LIBRARY_DELETE_OWN.value,
             # MakerWorld integration
             Permission.MAKERWORLD_VIEW.value,
             Permission.MAKERWORLD_IMPORT.value,
+            # Orca Cloud — needed for the Slice modal's Orca Cloud preset
+            # picker to populate. Workshops that use Orca Cloud presets
+            # need every operator to be able to authenticate. Bambu Cloud
+            # (CLOUD_AUTH) stays admin-only — that one is a more sensitive
+            # account binding.
+            Permission.ORCA_CLOUD_AUTH.value,
             # Projects - full access
             Permission.PROJECTS_READ.value,
             Permission.PROJECTS_CREATE.value,
@@ -438,9 +459,9 @@ DEFAULT_GROUPS = {
         "permissions": [
             # Read-only access
             Permission.PRINTERS_READ.value,
-            Permission.ARCHIVES_READ.value,
-            Permission.QUEUE_READ.value,
-            Permission.LIBRARY_READ.value,
+            Permission.ARCHIVES_READ_OWN.value,
+            Permission.QUEUE_READ_OWN.value,
+            Permission.LIBRARY_READ_OWN.value,
             Permission.PROJECTS_READ.value,
             Permission.FILAMENTS_READ.value,
             Permission.INVENTORY_READ.value,

+ 240 - 98
backend/app/main.py

@@ -333,6 +333,16 @@ logging.info("Bambuddy starting - debug=%s, log_level=%s", app_settings.debug, l
 # Track active prints: {(printer_id, filename): archive_id}
 _active_prints: dict[tuple[int, str], int] = {}
 
+# #1721: stage-22 pre-captured finish photo bytes per printer. on_finish_photo_moment
+# fires when stg_cur enters 22 ("Filament unloading") at end-of-print — toolhead
+# parked, bed not yet dropped — and grabs a single camera frame into this cache.
+# `_background_finish_photo` (inside on_print_complete) consumes the cached bytes
+# instead of running its own grab-now chain when present, so the finish photo
+# captures the better-framed pre-bed-drop moment without us having to force
+# timelapse on at dispatch (the #1397 mechanism that caused #1721's per-layer
+# nozzle parking on slicer profiles with Timelapse Type = Smooth).
+_stage22_finish_frames: dict[int, bytes] = {}
+
 # Per-printer "connected" edge tracker. Used by `on_printer_status_change`
 # to fire `reconcile_stale_active_prints` exactly once per (re)connection
 # (#1542 follow-up — power-cycle ghost prints). The value is True after
@@ -1695,6 +1705,24 @@ async def on_ams_change(printer_id: int, ams_data: list):
                                         printer_id,
                                         result["id"],
                                     )
+                                # Reconcile slot_preset_mappings (the same row internal
+                                # mode keeps in sync via inventory + spool_tag_matcher).
+                                # Without this the slot card surfaces the previous spool's
+                                # preset name — same bug shape, different inventory mode.
+                                from backend.app.services.slot_preset_writer import (
+                                    upsert_slot_preset_for_spoolman_spool,
+                                )
+
+                                await upsert_slot_preset_for_spoolman_spool(
+                                    db=db,
+                                    spoolman_spool=result,
+                                    tray_info_idx=tray.tray_info_idx or "",
+                                    tray_sub_brands=tray.tray_sub_brands or "",
+                                    tray_type=tray.tray_type or "",
+                                    printer_id=printer_id,
+                                    ams_id=ams_id,
+                                    tray_id=tray.tray_id,
+                                )
                     except Exception as e:
                         logger.error("Error syncing AMS %s tray %s: %s", ams_id, tray.tray_id, e)
 
@@ -1928,6 +1956,10 @@ async def on_print_start(printer_id: int, data: dict):
     # Clear any stale user-stopped flag from previous print cycles
     _user_stopped_printers.discard(printer_id)
 
+    # #1721: drop any leftover pre-captured finish frame from a prior print
+    # so a never-consumed cache entry can't bleed into the new print's photo.
+    _stage22_finish_frames.pop(printer_id, None)
+
     # Cancel any active bed cooldown waiter for this printer
     if _bed_cool_waiters.pop(printer_id, None):
         logger.info("[BED-COOL] Cancelled bed cooldown waiter for printer %s (new print started)", printer_id)
@@ -2198,6 +2230,33 @@ async def on_print_start(printer_id: int, data: dict):
                 # Update archive status to printing
                 archive.status = "printing"
                 archive.started_at = datetime.now(timezone.utc)
+
+                # Reprint of an archive reuses the source row. Without resetting
+                # ``timelapse_path`` _scan_for_timelapse_with_retries early-returns
+                # ("already has timelapse") and _capture_finish_photo_from_timelapse
+                # extracts the *original* print's last frame, which then ships in
+                # the completion notification (#1707). Clear the path so the
+                # scanner runs fresh; also unlink the old video file so reprints
+                # don't accumulate orphans in the archive directory. Photos list
+                # is left alone — accumulating one finish photo per run is fine.
+                stale_timelapse_relpath = archive.timelapse_path
+                if stale_timelapse_relpath:
+                    archive.timelapse_path = None
+                    try:
+                        stale_path = app_settings.base_dir / stale_timelapse_relpath
+                        if stale_path.is_file():
+                            stale_path.unlink()
+                            logger.info(
+                                "Deleted stale timelapse %s on reprint of archive %s",
+                                stale_timelapse_relpath,
+                                expected_archive_id,
+                            )
+                    except OSError as e:
+                        logger.warning(
+                            "Failed to delete stale timelapse %s on reprint: %s",
+                            stale_timelapse_relpath,
+                            e,
+                        )
                 # Persist a restart-stable id so a later restart resumes this
                 # archive by subtask_id instead of name-matching + duplicating
                 # it (#1485). The printer often hasn't echoed subtask_id back
@@ -3276,84 +3335,6 @@ async def _capture_finish_photo_from_timelapse(
         await asyncio.sleep(poll_interval)
 
 
-async def _cleanup_forced_timelapse(archive_id: int, printer_id: int) -> None:
-    """Delete the timelapse Bambuddy forced on for #1397's finish-photo path.
-
-    Called from the finish-photo background task after the extractor has had
-    its turn (regardless of whether extraction succeeded — the user never
-    asked for a video and we shouldn't leave one behind even if ffmpeg
-    failed). Cleanup is best-effort and never raises: a printer that's
-    offline at cleanup time means a single orphaned file on the SD card,
-    not a broken Bambuddy flow.
-
-    Cleans both:
-      - the locally-attached file (clears archive.timelapse_path)
-      - the printer-side file via FTP DELE
-    """
-    from backend.app.models.archive import PrintArchive
-    from backend.app.models.printer import Printer
-    from backend.app.services.bambu_ftp import delete_file_async
-
-    logger = logging.getLogger(__name__)
-
-    local_relpath: str | None = None
-    printer = None
-
-    async with async_session() as db:
-        archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-        archive = archive_result.scalar_one_or_none()
-        if not archive or not archive.bambuddy_forced_timelapse:
-            return
-
-        local_relpath = archive.timelapse_path
-        if local_relpath:
-            local_abspath = app_settings.base_dir / local_relpath
-            try:
-                if local_abspath.exists():
-                    local_abspath.unlink()
-                    logger.info(
-                        "[FORCED-TIMELAPSE] Deleted local timelapse %s for archive %s",
-                        local_relpath,
-                        archive_id,
-                    )
-            except OSError as e:
-                logger.warning("[FORCED-TIMELAPSE] Could not delete local timelapse %s: %s", local_relpath, e)
-            archive.timelapse_path = None
-            await db.commit()
-
-        printer_result = await db.execute(select(Printer).where(Printer.id == printer_id))
-        printer = printer_result.scalar_one_or_none()
-
-    if printer is None or not local_relpath:
-        return
-
-    # _scan_for_timelapse_with_retries used the original filename when it
-    # attached, so the basename of timelapse_path matches the printer-side
-    # filename. Try the directories the scanner walks (#1397).
-    filename = Path(local_relpath).name
-    for remote_dir in ("/timelapse", "/timelapse/video", "/record", "/recording"):
-        remote_path = f"{remote_dir}/{filename}"
-        try:
-            ok = await delete_file_async(
-                printer.ip_address,
-                printer.access_code,
-                remote_path,
-                printer_model=printer.model,
-            )
-        except Exception as e:
-            logger.debug("[FORCED-TIMELAPSE] FTP delete attempt failed for %s: %s", remote_path, e)
-            continue
-        if ok:
-            logger.info("[FORCED-TIMELAPSE] Deleted printer-side timelapse %s", remote_path)
-            return
-
-    logger.warning(
-        "[FORCED-TIMELAPSE] Could not delete printer-side timelapse %s for archive %s (file may already be gone)",
-        filename,
-        archive_id,
-    )
-
-
 async def on_print_running_observed(printer_id: int, data: dict):
     """Restart-recovery: capture a fresh timelapse baseline for a print that
     started before Bambuddy came up.
@@ -3547,6 +3528,117 @@ async def reconcile_stale_active_prints(printer_id: int) -> int:
     return reconciled
 
 
+async def on_finish_photo_moment(printer_id: int, data: dict):
+    """Pre-capture a finish photo when the printer enters stage 22 / FINISH (#1721).
+
+    Fires either at the stage-22 ("Filament unloading") edge — toolhead
+    parked, bed not yet dropped, optimal framing — or as a FINISH-state
+    fallback for prints that skip stage 22 (cancel, external-spool-only,
+    HMS halt, firmware variants). Grabs one frame via the same
+    external-camera / RTSP path the post-completion fallback uses, stores
+    the JPEG bytes in ``_stage22_finish_frames[printer_id]``, and lets
+    ``_background_finish_photo`` consume the cached bytes when it runs.
+
+    Replaces the #1397 "force timelapse on at dispatch" mechanism, which
+    caused per-layer nozzle parking on slicer profiles with Timelapse Type
+    set to Smooth (#1721). No force-on now means the user's explicit
+    timelapse=off in the slicer send dialog is respected.
+    """
+    logger = logging.getLogger(__name__)
+    trigger = data.get("trigger", "unknown")
+    timelapse_was_active = bool(data.get("timelapse_was_active"))
+    logger.info(
+        "[FINISH-PHOTO-MOMENT] printer=%s trigger=%s timelapse_active=%s",
+        printer_id,
+        trigger,
+        timelapse_was_active,
+    )
+
+    # If a timelapse is actively recording, skip the pre-capture — the
+    # post-completion path will extract the last frame from the recorded
+    # video, which still provides the best framing (toolhead parked,
+    # before bed drop) without the per-layer parking side effects.
+    if timelapse_was_active:
+        logger.info(
+            "[FINISH-PHOTO-MOMENT] timelapse active for printer %s — skipping pre-capture (last-frame extraction will run post-completion)",
+            printer_id,
+        )
+        return
+
+    try:
+        async with async_session() as db:
+            from backend.app.api.routes.settings import get_setting
+            from backend.app.models.printer import Printer
+
+            capture_setting = await get_setting(db, "capture_finish_photo")
+            if capture_setting is not None and capture_setting.lower() != "true":
+                logger.info("[FINISH-PHOTO-MOMENT] capture_finish_photo disabled — skipping pre-capture")
+                return
+
+            result = await db.execute(select(Printer).where(Printer.id == printer_id))
+            printer = result.scalar_one_or_none()
+            if printer is None:
+                logger.warning(
+                    "[FINISH-PHOTO-MOMENT] printer %s not found in DB",
+                    printer_id,
+                )
+                return
+
+        frame_bytes: bytes | None = None
+
+        if printer.external_camera_enabled and printer.external_camera_url:
+            from backend.app.services.external_camera import capture_frame
+
+            frame_bytes = await capture_frame(
+                printer.external_camera_url,
+                printer.external_camera_type or "mjpeg",
+                snapshot_url=printer.external_camera_snapshot_url,
+            )
+            if frame_bytes:
+                logger.info(
+                    "[FINISH-PHOTO-MOMENT] captured external-camera frame (%d bytes)",
+                    len(frame_bytes),
+                )
+        else:
+            from backend.app.api.routes.camera import get_buffered_frame
+
+            buffered = get_buffered_frame(printer_id)
+            if buffered:
+                frame_bytes = buffered
+                logger.info(
+                    "[FINISH-PHOTO-MOMENT] used buffered RTSP frame (%d bytes)",
+                    len(frame_bytes),
+                )
+            else:
+                from backend.app.services.camera import capture_camera_frame_bytes
+
+                frame_bytes = await capture_camera_frame_bytes(
+                    ip_address=printer.ip_address,
+                    access_code=printer.access_code,
+                    model=printer.model,
+                    timeout=15,
+                )
+                if frame_bytes:
+                    logger.info(
+                        "[FINISH-PHOTO-MOMENT] captured RTSP frame (%d bytes)",
+                        len(frame_bytes),
+                    )
+
+        if frame_bytes:
+            _stage22_finish_frames[printer_id] = frame_bytes
+        else:
+            logger.warning(
+                "[FINISH-PHOTO-MOMENT] no frame captured for printer %s — post-completion fallback will retry",
+                printer_id,
+            )
+    except Exception as e:
+        logger.warning(
+            "[FINISH-PHOTO-MOMENT] pre-capture failed for printer %s: %s",
+            printer_id,
+            e,
+        )
+
+
 async def on_print_complete(printer_id: int, data: dict):
     """Handle print completion - update the archive status."""
     import time
@@ -3741,7 +3833,7 @@ async def on_print_complete(printer_id: int, data: dict):
                     archive_filename = archive_row.scalar_one_or_none()
 
             if printer:
-                from backend.app.services.bambu_ftp import delete_file_async
+                from backend.app.services.bambu_ftp import DeleteResult, delete_file_async
                 from backend.app.utils.filename import derive_remote_filename
 
                 # Primary candidate: the exact path the dispatcher uploaded to
@@ -3759,8 +3851,23 @@ async def on_print_complete(printer_id: int, data: dict):
                     if fallback not in candidate_paths:
                         candidate_paths.append(fallback)
 
+                # Three outcomes track across all candidates so the final log
+                # line reflects what actually happened. The A1 in #1721 always
+                # ends here with ``any_not_found=True`` and the others False
+                # — its firmware auto-cleans the SD card before our cleanup
+                # runs, every candidate FTP-DELE returns 550, and the old
+                # code burned 3 retries × 2 s × 3 candidates per print
+                # logging a misleading "may linger" WARNING on a successful
+                # print.
+                any_deleted = False
+                any_real_failure = False
+                any_not_found = False
+
                 for remote_path in candidate_paths:
-                    # Retry up to 3 times — the printer may still lock the filesystem briefly after a print ends
+                    # Retry only the FAILED case — 550 NOT_FOUND will never
+                    # recover by waiting, so a "file isn't here" answer
+                    # advances immediately to the next candidate without
+                    # consuming the retry budget.
                     for attempt in range(1, 4):
                         try:
                             delete_result = await delete_file_async(
@@ -3769,24 +3876,43 @@ async def on_print_complete(printer_id: int, data: dict):
                                 remote_path,
                                 printer_model=printer.model,
                             )
-                            if delete_result:
-                                logger.info("Deleted %s from printer %s SD card", remote_path, printer.name)
-                                break
                         except Exception as e:
-                            delete_result = False
+                            delete_result = DeleteResult.FAILED
                             logger.warning(
                                 "SD card cleanup attempt %d/3 raised for %s: %s",
                                 attempt,
                                 remote_path,
                                 e,
                             )
-                        if not delete_result and attempt < 3:
+
+                        if delete_result == DeleteResult.DELETED:
+                            any_deleted = True
+                            logger.info("Deleted %s from printer %s SD card", remote_path, printer.name)
+                            break
+                        if delete_result == DeleteResult.NOT_FOUND:
+                            any_not_found = True
+                            break  # 550 will not recover; try next candidate
+                        # FAILED: real error — retry with backoff, then give up
+                        if attempt < 3:
                             await asyncio.sleep(2)
-                        elif not delete_result:
+                        else:
+                            any_real_failure = True
                             logger.warning(
-                                "SD card cleanup failed after 3 attempts for %s (file may linger on SD card)",
+                                "SD card cleanup failed after 3 attempts for %s "
+                                "(network/auth/transient error — file may linger on SD card)",
                                 remote_path,
                             )
+
+                if not any_deleted and not any_real_failure and any_not_found:
+                    # Every candidate said "not here." Either the printer
+                    # firmware swept the SD card itself (common on A1) or the
+                    # dispatcher's upload path doesn't match our candidate
+                    # rule. Either way: nothing to clean up, no warning.
+                    logger.debug(
+                        "SD card cleanup: nothing to delete on %s — every candidate returned 550 "
+                        "(printer likely self-cleaned)",
+                        printer.name,
+                    )
     except Exception as e:
         logger.warning("SD card file cleanup failed for printer %s: %s", printer_id, e)
 
@@ -4356,7 +4482,11 @@ async def on_print_complete(printer_id: int, data: dict):
                             # recording — it captures the moment after the toolhead parks
                             # but before the bed drops, which the live-camera grab below
                             # would miss (#1397). Skipped for external cameras (those have
-                            # their own framing and don't see a Bambu timelapse).
+                            # their own framing and don't see a Bambu timelapse). Only
+                            # runs when the USER explicitly enabled timelapse for this
+                            # print — #1721 removed Bambuddy's force-on at dispatch
+                            # because it caused per-layer nozzle parking on Smooth-mode
+                            # slicer profiles.
                             prefer_timelapse_source = bool(data.get("timelapse_was_active")) and not (
                                 printer.external_camera_enabled and printer.external_camera_url
                             )
@@ -4367,6 +4497,27 @@ async def on_print_complete(printer_id: int, data: dict):
                                     archive_dir=archive_dir,
                                 )
 
+                            # #1721: replacement framing path — on_finish_photo_moment
+                            # pre-captured a frame at the stage-22 / FINISH edge (toolhead
+                            # parked, bed not yet dropped) and cached the JPEG bytes in
+                            # _stage22_finish_frames. Consume them now so the saved photo
+                            # has the better framing instead of the post-bed-drop angle
+                            # the live-camera fallback below would give.
+                            if not photo_filename:
+                                cached_frame = _stage22_finish_frames.pop(printer_id, None)
+                                if cached_frame:
+                                    photos_dir = archive_dir / "photos"
+                                    photos_dir.mkdir(parents=True, exist_ok=True)
+                                    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+                                    photo_filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
+                                    photo_path = photos_dir / photo_filename
+                                    await asyncio.to_thread(photo_path.write_bytes, cached_frame)
+                                    logger.info(
+                                        "[PHOTO-BG] Saved stage-22 pre-captured frame: %s (%d bytes)",
+                                        photo_filename,
+                                        len(cached_frame),
+                                    )
+
                             # Fallback chain: external camera → buffered live frame →
                             # fresh RTSP capture. Only runs if the timelapse path above
                             # didn't already produce a photo.
@@ -4426,16 +4577,6 @@ async def on_print_complete(printer_id: int, data: dict):
                                 await db.commit()
                                 logger.info("[PHOTO-BG] Saved: %s", photo_filename)
 
-                            # When Bambuddy forced timelapse on for this print, delete
-                            # the timelapse afterward (#1397). The user didn't ask for
-                            # a video to keep — only the finish photo. Runs even when
-                            # photo extraction failed, so we don't leave debris.
-                            if archive.bambuddy_forced_timelapse:
-                                await _cleanup_forced_timelapse(
-                                    archive_id=archive_id,
-                                    printer_id=printer_id,
-                                )
-
                             if photo_filename:
                                 return photo_filename
             return None
@@ -5360,6 +5501,7 @@ async def lifespan(app: FastAPI):
     printer_manager.set_print_start_callback(on_print_start)
     printer_manager.set_print_complete_callback(on_print_complete)
     printer_manager.set_print_running_observed_callback(on_print_running_observed)
+    printer_manager.set_finish_photo_moment_callback(on_finish_photo_moment)
     printer_manager.set_ams_change_callback(on_ams_change)
 
     # Rehydrate persisted awaiting-plate-clear gate (#961) so prompts survive restarts

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

@@ -24,37 +24,6 @@ class PresetRef(BaseModel):
     )
 
 
-class SliceBundleSpec(BaseModel):
-    """Per-request reference to a Printer Preset Bundle stored on the slicer
-    sidecar. When SliceRequest.bundle is set, the dispatch skips PresetRef
-    resolution entirely and asks the sidecar to pick its inner JSON triplet
-    by name from the bundle's extracted directory — much faster than
-    re-uploading three profile JSONs every slice and matches the preset
-    triplet the user actually slices with in BambuStudio.
-    """
-
-    bundle_id: str = Field(
-        ...,
-        min_length=1,
-        description="Sidecar-side bundle id from POST /api/v1/slicer/bundles.",
-    )
-    printer_name: str = Field(
-        ...,
-        min_length=1,
-        description="Preset name within the bundle's printer/ directory (with or without the BambuStudio '# ' prefix).",
-    )
-    process_name: str = Field(
-        ...,
-        min_length=1,
-        description="Preset name within the bundle's process/ directory.",
-    )
-    filament_names: list[str] = Field(
-        ...,
-        min_length=1,
-        description="Per-slot filament preset names within the bundle's filament/ directory. Index 0 = slot 1.",
-    )
-
-
 class SliceRequest(BaseModel):
     """Body for `POST /library/files/{file_id}/slice`.
 
@@ -98,15 +67,6 @@ class SliceRequest(BaseModel):
     # is empty so older clients keep working.
     filament_presets: list[PresetRef] = Field(default_factory=list)
 
-    # Bundle dispatch alternative — when set, presets above are ignored and
-    # the slicer dispatch picks per-category JSONs from a previously-imported
-    # .bbscfg on the sidecar. Validator below short-circuits the
-    # presets-required check when this is non-None.
-    bundle: SliceBundleSpec | None = Field(
-        default=None,
-        description="When set, slice via a sidecar-side bundle instead of resolved preset refs.",
-    )
-
     plate: int | None = Field(
         default=None,
         ge=0,
@@ -142,13 +102,7 @@ class SliceRequest(BaseModel):
         ``filament_presets`` list satisfies the requirement on its own; an
         empty list falls back to the singular fields, which then promote
         into a one-element list.
-
-        When ``bundle`` is set, the dispatch picks the JSON triplet from
-        the sidecar bundle directly so PresetRef resolution is skipped —
-        return early before the presets-required checks below.
         """
-        if self.bundle is not None:
-            return self
         for slot, ref_attr, legacy_attr in (
             ("printer", "printer_preset", "printer_preset_id"),
             ("process", "process_preset", "process_preset_id"),

+ 8 - 7
backend/app/schemas/slicer_presets.py

@@ -59,13 +59,14 @@ class UnifiedPresetsBySlot(BaseModel):
 
 
 class UnifiedPresetsResponse(BaseModel):
-    """Each tier carries only the names that didn't appear in a higher tier.
-
-    Priority order: ``orca_cloud > cloud > local > standard``. Orca Cloud is
-    highest because it's the most-recently-explicitly-curated source for
-    users who set up Orca sync (they did it on purpose; their Orca picks
-    should outrank everything else). Bambu Cloud follows as the next-most-
-    curated tier. Local imports beat the slicer's stock fallback.
+    """Every tier carries its full preset list — no cross-tier dedup.
+
+    Priority order: ``local > orca_cloud > cloud > standard``. The order
+    drives auto-pick (first non-empty tier wins, name-lookup walks tiers
+    in this order, filament scoring tiebreaks by per-tier bonus) and
+    determines the visual rendering order of the SliceModal's optgroups,
+    but a name that exists in multiple tiers appears in EACH of their
+    groups so the user can pick any source.
 
     ``cloud_status`` / ``orca_cloud_status`` let the frontend show a banner
     explaining why a cloud tier is empty when the user expected to see it

+ 65 - 20
backend/app/services/archive.py

@@ -914,28 +914,64 @@ async def _null_print_log_thumbnail_paths(db: AsyncSession, archive_id: int) ->
     await db.execute(sa_update(PrintLogEntry).where(PrintLogEntry.archive_id == archive_id).values(thumbnail_path=None))
 
 
-async def _cancel_pending_queue_items(db: AsyncSession, archive_id: int) -> None:
-    """Cancel pending queue items pointing at *archive_id* (#1348 follow-up).
-
-    Called from ``soft_delete_archive`` only — hard-delete is covered by the
-    ``ON DELETE CASCADE`` on ``print_queue.archive_id``.  A queue item
-    pointing at an archive whose 3MF has been removed from disk can never
-    actually dispatch, so cancelling at delete time both (a) tells the user
-    why the item disappeared from the pending list, and (b) stops the queue
-    page from 404-storming the archive thumbnail / plates / plate-thumbnail
-    endpoints when the row is rendered. Only ``pending`` items are touched;
-    ``printing`` is a rare race the printer-side fail-path catches, and
-    completed / failed / cancelled rows are historical and untouched.
+async def _delete_related_queue_items(db: AsyncSession, archive_id: int) -> int:
+    """Delete every queue item pointing at *archive_id* (#1734).
+
+    Called from ``soft_delete_archive``. Hard-delete is covered by the
+    ``ON DELETE CASCADE`` on ``print_queue.archive_id`` — same end state
+    via the FK. Pre-#1734 this helper merely flipped pending rows to
+    ``status='cancelled'`` while leaving every other status alone and
+    leaving the rows in the DB, which surprised users who expected the
+    queue lines to disappear when their backing archive went away. Worse,
+    a Send-All archive backed N queue items (one per plate, #1733) — soft-
+    deleting that archive left N "cancelled" rows behind, none of which
+    could ever dispatch.
+
+    Now we delete unconditionally regardless of status. ``printing`` rows
+    are blocked one layer up at the route (``delete_archive`` returns 409
+    when a related row is mid-print) so we never delete an actively-
+    running queue row out from under the dispatcher. Completed / failed
+    / cancelled rows go too — they're queue history, not print history.
+    PrintLogEntry rows are the authoritative print history and are
+    untouched (FK ``ON DELETE SET NULL``).
+
+    Returns the number of rows removed so the caller can report it.
     """
-    from sqlalchemy import update as sa_update
+    from sqlalchemy import delete as sa_delete
 
     from backend.app.models.print_queue import PrintQueueItem
 
-    await db.execute(
-        sa_update(PrintQueueItem)
-        .where(PrintQueueItem.archive_id == archive_id, PrintQueueItem.status == "pending")
-        .values(status="cancelled", waiting_reason="Source archive deleted")
-    )
+    result = await db.execute(sa_delete(PrintQueueItem).where(PrintQueueItem.archive_id == archive_id))
+    return result.rowcount or 0
+
+
+async def _count_related_queue_items(db: AsyncSession, archive_id: int) -> tuple[int, int]:
+    """Return ``(total, printing)`` queue items linked to *archive_id*.
+
+    Used by the archive GET response so the frontend delete-confirm modal
+    can surface how much the deletion will wipe out, and by the delete
+    route so it can 409 when a related row is currently printing (#1734).
+    """
+    from sqlalchemy import func as sa_func, select as sa_select
+
+    from backend.app.models.print_queue import PrintQueueItem
+
+    total = (
+        await db.execute(
+            sa_select(sa_func.count()).select_from(PrintQueueItem).where(PrintQueueItem.archive_id == archive_id)
+        )
+    ).scalar_one()
+    printing = (
+        await db.execute(
+            sa_select(sa_func.count())
+            .select_from(PrintQueueItem)
+            .where(
+                PrintQueueItem.archive_id == archive_id,
+                PrintQueueItem.status == "printing",
+            )
+        )
+    ).scalar_one()
+    return int(total or 0), int(printing or 0)
 
 
 class ArchiveService:
@@ -1313,8 +1349,14 @@ class ArchiveService:
         date_to: date | None = None,
         limit: int = 50,
         offset: int = 0,
+        visible_to_user_id: int | None = None,
     ) -> list[PrintArchive]:
-        """List archives with optional filtering."""
+        """List archives with optional filtering.
+
+        ``visible_to_user_id`` scopes results to archives that user owns. Used
+        when the caller has ARCHIVES_READ_OWN but not ARCHIVES_READ_ALL — pass
+        ``None`` to skip the filter (caller has read-all or auth is disabled).
+        """
         from sqlalchemy.orm import selectinload
 
         query = (
@@ -1341,6 +1383,9 @@ class ArchiveService:
             dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
             query = query.where(PrintArchive.created_at <= dt_to)
 
+        if visible_to_user_id is not None:
+            query = query.where(PrintArchive.created_by_id == visible_to_user_id)
+
         query = query.limit(limit).offset(offset)
         result = await self.db.execute(query)
         return list(result.scalars().all())
@@ -1366,7 +1411,7 @@ class ArchiveService:
         dir_to_delete = self._resolve_archive_dir_for_delete(archive)
 
         await _null_print_log_thumbnail_paths(self.db, archive_id)
-        await _cancel_pending_queue_items(self.db, archive_id)
+        await _delete_related_queue_items(self.db, archive_id)
         archive.deleted_at = datetime.now(timezone.utc)
         await self.db.commit()
 

+ 2 - 50
backend/app/services/background_dispatch.py

@@ -493,17 +493,6 @@ class BackgroundDispatchService:
         if self._is_cancel_requested(job.id):
             raise DispatchJobCancelled(f"Dispatch job {job.id} cancelled")
 
-    async def _resolve_effective_timelapse(self, db, archive, job: PrintDispatchJob) -> bool:
-        """Dispatch-flow wrapper around the shared resolver (#1397).
-
-        Returns the effective value to pass to ``start_print(timelapse=...)``.
-        """
-        return await resolve_effective_timelapse(
-            db,
-            archive,
-            user_wanted_timelapse=bool(job.options.get("timelapse", False)),
-        )
-
     def _build_state_payload_unlocked(self, recent_event: dict[str, Any] | None = None) -> dict[str, Any]:
         processing = len(self._active_jobs)
         dispatched = len(self._queued_jobs)
@@ -685,7 +674,7 @@ class BackgroundDispatchService:
 
                 self._raise_if_cancel_requested(job)
 
-                effective_timelapse = await self._resolve_effective_timelapse(db, archive, job)
+                effective_timelapse = bool(job.options.get("timelapse", False))
 
                 await self._set_active_message(job, f"Starting print on {printer_name}...")
                 started = printer_manager.start_print(
@@ -892,7 +881,7 @@ class BackgroundDispatchService:
 
                 self._raise_if_cancel_requested(job)
 
-                effective_timelapse = await self._resolve_effective_timelapse(db, archive, job)
+                effective_timelapse = bool(job.options.get("timelapse", False))
 
                 await self._set_active_message(job, f"Starting print on {printer_name}...")
                 started = printer_manager.start_print(
@@ -1108,41 +1097,4 @@ class BackgroundDispatchService:
         return lower.endswith(".gcode") or lower.endswith(".gcode.3mf")
 
 
-async def resolve_effective_timelapse(db, archive, user_wanted_timelapse: bool) -> bool:
-    """Resolve whether this print should record a timelapse (#1397).
-
-    Shared by both the on-demand dispatch path (``background_dispatch.py``,
-    used by Print Now / Reprint flows) and the queued-dispatch path
-    (``print_scheduler.py``, used by the print queue). Both must apply the
-    same override semantics or the queue path's prints would slip through
-    without a finish photo.
-
-    Bambuddy forces timelapse recording on when:
-      - the global ``capture_finish_photo`` setting is enabled, AND
-      - the user did NOT opt in to a timelapse for this specific print
-
-    The forced bit is recorded on the archive so the post-extraction
-    cleanup path can delete the timelapse afterward (the user didn't
-    ask for a video to keep, only the framed finish photo, #1397).
-    """
-    from backend.app.api.routes.settings import get_setting
-
-    if user_wanted_timelapse:
-        return True
-
-    # User didn't ask — check the master capture-finish-photo toggle.
-    capture_setting = await get_setting(db, "capture_finish_photo")
-    capture_enabled = capture_setting is None or capture_setting.lower() == "true"
-    if not capture_enabled:
-        return False
-
-    archive.bambuddy_forced_timelapse = True
-    await db.commit()
-    logging.getLogger(__name__).info(
-        "[FORCED-TIMELAPSE] Forcing timelapse on for archive %s (capture_finish_photo enabled, user did not opt in)",
-        archive.id,
-    )
-    return True
-
-
 background_dispatch = BackgroundDispatchService()

+ 46 - 12
backend/app/services/bambu_ftp.py

@@ -7,6 +7,7 @@ import ssl
 import threading
 import time
 from collections.abc import Awaitable, Callable
+from enum import Enum
 from ftplib import FTP, FTP_TLS  # nosec B402
 from io import BytesIO
 from pathlib import Path
@@ -17,6 +18,22 @@ logger = logging.getLogger(__name__)
 T = TypeVar("T")
 
 
+class DeleteResult(Enum):
+    """Outcome of an FTP delete attempt.
+
+    Distinguishes "file isn't on the printer" (550, recovery impossible by
+    retrying) from "delete failed for some other reason" (network, auth,
+    transient FTP error — worth retrying). The post-print SD-card cleanup in
+    main.py used to flatten both into ``False`` and log a "may linger" WARNING
+    on every successful print where the printer self-cleaned its SD card
+    before our cleanup ran (#1721 reporter's A1).
+    """
+
+    DELETED = "deleted"
+    NOT_FOUND = "not_found"
+    FAILED = "failed"
+
+
 class FileNotOnPrinterError(Exception):
     """Raised when a remote FTP path returns 550 (file not found).
 
@@ -507,14 +524,16 @@ class BambuFTPClient:
                 )
 
             if callback_exception is not None:
-                cleanup_ok = False
+                cleanup_result: DeleteResult = DeleteResult.FAILED
                 try:
-                    cleanup_ok = self.delete_file(remote_path)
+                    cleanup_result = self.delete_file(remote_path)
                 except Exception as cleanup_error:
                     logger.warning("FTP cancel cleanup failed for %s: %s", remote_path, cleanup_error)
 
-                if cleanup_ok:
-                    logger.info("FTP cancel cleanup succeeded for %s", remote_path)
+                # NOT_FOUND is success here — the partial file is gone (printer
+                # may have already swept on cancel), which is the goal.
+                if cleanup_result in (DeleteResult.DELETED, DeleteResult.NOT_FOUND):
+                    logger.info("FTP cancel cleanup succeeded for %s (%s)", remote_path, cleanup_result.value)
                     raise callback_exception
 
                 raise RuntimeError(
@@ -621,17 +640,28 @@ class BambuFTPClient:
         except (OSError, ftplib.Error):
             return False
 
-    def delete_file(self, remote_path: str) -> bool:
-        """Delete a file from the printer."""
+    def delete_file(self, remote_path: str) -> DeleteResult:
+        """Delete a file from the printer.
+
+        Returns :class:`DeleteResult` distinguishing the file-not-found case
+        (550) from network / auth / transient FTP failure. Callers that just
+        want "did it work" should check ``result == DeleteResult.DELETED``.
+        """
         if not self._ftp:
-            return False
+            return DeleteResult.FAILED
 
         try:
             self._ftp.delete(remote_path)
-            return True
+            return DeleteResult.DELETED
+        except ftplib.error_perm as e:
+            if str(e).startswith("550"):
+                logger.debug("FTP delete: %s not on printer (550)", remote_path)
+                return DeleteResult.NOT_FOUND
+            logger.warning("Failed to delete %s: %s", remote_path, e)
+            return DeleteResult.FAILED
         except (OSError, ftplib.Error) as e:
             logger.warning("Failed to delete %s: %s", remote_path, e)
-            return False
+            return DeleteResult.FAILED
 
     def get_file_size(self, remote_path: str) -> int | None:
         """Get the size of a file."""
@@ -1055,23 +1085,27 @@ async def delete_file_async(
     remote_path: str,
     socket_timeout: float | None = None,
     printer_model: str | None = None,
-) -> bool:
+) -> DeleteResult:
     """Async wrapper for deleting a file.
 
+    Returns :class:`DeleteResult` so callers can distinguish ``NOT_FOUND``
+    (550 — file isn't on the printer, no retry value) from ``FAILED``
+    (network / auth / transient — worth retrying or surfacing).
+
     Args:
         socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
         printer_model: Printer model for A1-specific workarounds
     """
     loop = asyncio.get_event_loop()
 
-    def _delete():
+    def _delete() -> DeleteResult:
         client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
         if client.connect():
             try:
                 return client.delete_file(remote_path)
             finally:
                 client.disconnect()
-        return False
+        return DeleteResult.FAILED
 
     return await loop.run_in_executor(None, _delete)
 

+ 224 - 79
backend/app/services/bambu_mqtt.py

@@ -31,6 +31,108 @@ logger = logging.getLogger(__name__)
 _AMS_MODULE_PREFIXES = ("ams/", "n3f/", "n3s/")
 
 
+def apply_tray_exist_bits(
+    units: list,
+    tray_exist_bits_str: str | int | None,
+    *,
+    power_on_flag: bool = True,
+    log_label: str | None = None,
+) -> int:
+    """Wipe stale per-tray filament fields on slots whose `tray_exist_bits` bit is 0.
+
+    `tray_exist_bits` is firmware's canonical "which slots have a spool" bitmask
+    (BambuStudio uses it too). For every slot whose bit is 0, promote the tray
+    `state` to 9 (firmware's "no spool" code) and clear `tray_type` / `tray_color`
+    / `tray_info_idx` / `tag_uid` / `tray_uuid` / `remain` etc so downstream
+    readers (Bambuddy's AMS card, the VP slicer-facing cache, inventory short-
+    circuits keyed on `state in {9, 10}`) all see one canonical empty-slot signal
+    instead of guessing from payload shape (#1322, #147).
+
+    Two callers share this helper to keep their views consistent:
+
+    1. ``_handle_ams_data`` for Bambuddy's internal AMS state (printer card).
+    2. ``virtual_printer.mqtt_bridge._on_printer_raw`` for the cached slicer-
+       facing push_status (#1726 — without this the VP would forward stale
+       per-tray fields for empty slots, and BambuStudio's Sync would render
+       phantom loaded slots).
+
+    Skipped only on the printer-shutdown pattern: all-zero bits paired with
+    ``power_on_flag=False`` (#765). Non-zero bits with ``power_on_flag=False``
+    is valid idle-printer state (#1365 — X1C between prints) and MUST be applied
+    so spool removal is detected without requiring a manual reconnect.
+
+    AMS-HT units (``id >= 128``) use a separate addressing scheme and are
+    skipped here.
+
+    `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
+    on the wire. ``None`` / empty / unparseable → no-op.
+
+    Mutates ``units`` in place. Returns the number of slots cleared.
+    """
+    if not tray_exist_bits_str:
+        return 0
+    try:
+        if isinstance(tray_exist_bits_str, int):
+            tray_exist_bits = tray_exist_bits_str
+        else:
+            tray_exist_bits = int(tray_exist_bits_str, 16)
+    except (ValueError, TypeError):
+        return 0
+    if tray_exist_bits == 0 and not power_on_flag:
+        return 0
+    if not isinstance(units, list):
+        return 0
+
+    cleared = 0
+    for ams_unit in units:
+        if not isinstance(ams_unit, dict):
+            continue
+        ams_id_raw = ams_unit.get("id")
+        if ams_id_raw is None:
+            continue
+        try:
+            ams_id = int(ams_id_raw) if isinstance(ams_id_raw, str) else ams_id_raw
+        except (ValueError, TypeError):
+            continue
+        if not isinstance(ams_id, int) or ams_id >= 128:
+            # Skip AMS-HT (id >= 128) — separate addressing scheme.
+            continue
+        for tray in ams_unit.get("tray", []):
+            if not isinstance(tray, dict):
+                continue
+            tray_id_raw = tray.get("id")
+            if tray_id_raw is None:
+                continue
+            try:
+                tray_id = int(tray_id_raw) if isinstance(tray_id_raw, str) else tray_id_raw
+            except (ValueError, TypeError):
+                continue
+            if not isinstance(tray_id, int):
+                continue
+            global_bit = ams_id * 4 + tray_id
+            slot_exists = (tray_exist_bits >> global_bit) & 1
+            if slot_exists:
+                continue
+            tray["state"] = 9
+            if tray.get("tray_type"):
+                if log_label:
+                    logger.debug(
+                        f"[{log_label}] Clearing empty slot: AMS {ams_id} slot {tray_id} "
+                        f"(tray_exist_bits bit {global_bit} = 0)"
+                    )
+                tray["tray_type"] = ""
+                tray["tray_sub_brands"] = ""
+                tray["tray_color"] = ""
+                tray["tray_id_name"] = ""
+                tray["tag_uid"] = "0000000000000000"
+                tray["tray_uuid"] = "00000000000000000000000000000000"
+                tray["tray_info_idx"] = ""
+                tray["remain"] = 0
+                cleared += 1
+    return cleared
+
+
 @dataclass
 class MQTTLogEntry:
     """Log entry for MQTT message debugging."""
@@ -334,6 +436,7 @@ class BambuMQTTClient:
         on_bed_temp_update: Callable[[float], None] | None = None,
         on_drying_complete: Callable[[int], None] | None = None,
         on_print_running_observed: Callable[[dict], None] | None = None,
+        on_finish_photo_moment: Callable[[dict], None] | None = None,
     ):
         self.ip_address = ip_address
         self.serial_number = serial_number
@@ -357,6 +460,17 @@ class BambuMQTTClient:
         # the same shape as on_print_start (filename / subtask_name /
         # remaining_time / raw_data / ams_mapping).
         self.on_print_running_observed = on_print_running_observed
+        # #1721: fired the moment the printer enters the end-of-print
+        # "Filament unloading" phase (stg_cur=22 while progress>=99 or
+        # we've hit the last layer / remaining_time<=0). This is the
+        # framing #1397 was after — toolhead parked, bed not yet
+        # dropped — but reached via a clean state signal instead of
+        # the per-layer M622 J1 macros which caused per-layer nozzle
+        # parks on slicer profiles with Timelapse Type = Smooth.
+        # A FINISH-state fallback below fires this same callback if
+        # stage 22 never arrives (cancel mid-print, external-spool-
+        # only prints, HMS halt before unload, firmware variants).
+        self.on_finish_photo_moment = on_finish_photo_moment
         # Per-AMS previous dry_time, used to detect the falling edge above.
         # Seeded lazily as we observe each AMS unit.
         self._previous_dry_times: dict[int, int] = {}
@@ -369,6 +483,10 @@ class BambuMQTTClient:
         self._was_running: bool = False  # Track if we've seen RUNNING state for current print
         self._completion_triggered: bool = False  # Prevent duplicate completion triggers
         self._timelapse_during_print: bool = False  # Track if timelapse was active during this print
+        # #1721: one-shot guard so the end-of-print stage-22 detector
+        # and the FINISH-state fallback don't both fire on the same
+        # print. Reset to False on every print start.
+        self._finish_photo_captured: bool = False
         self._last_valid_progress: float = 0.0  # Last non-zero progress (firmware resets on cancel)
         self._last_valid_layer_num: int = 0  # Last non-zero layer (firmware resets on cancel)
         # The subtask_id minted for the most recent start_print() command. The
@@ -1789,73 +1907,17 @@ class BambuMQTTClient:
         # Convert back to list, sorted by ID for consistent ordering
         merged_ams = sorted(existing_by_id.values(), key=lambda x: x.get("id", 0))
 
-        # Check tray_exist_bits to clear empty slots (Issue #147)
-        # New AMS models don't send empty tray data - they just update tray_exist_bits
-        # Each bit in tray_exist_bits represents a slot: bit=0 means empty, bit=1 means has spool
-        # Skip ONLY the printer-shutdown pattern: all-zero bits paired with
-        # power_on_flag=False (#765). On shutdown that combination would wipe all
-        # slot data and cause auto-unlink to remove spool assignments. Non-zero
-        # bits with power_on_flag=False are valid AMS state from an idle printer
-        # (#1365 — X1C reports power_on_flag=False between prints while the AMS
-        # keeps reporting its actual slot inventory); the update MUST be applied
-        # so spool removal is detected without requiring a manual reconnect.
-        tray_exist_bits_str = ams_data.get("tray_exist_bits") if isinstance(ams_data, dict) else None
-        power_on = ams_data.get("power_on_flag", True) if isinstance(ams_data, dict) else True
-        if tray_exist_bits_str:
-            try:
-                tray_exist_bits = int(tray_exist_bits_str, 16)
-            except (ValueError, TypeError) as e:
-                logger.debug("[%s] Could not parse tray_exist_bits: %s", self.serial_number, e)
-                tray_exist_bits = None
-
-            if tray_exist_bits is not None and not (tray_exist_bits == 0 and not power_on):
-                for ams_unit in merged_ams:
-                    ams_id_raw = ams_unit.get("id")
-                    if ams_id_raw is None:
-                        continue
-                    # Convert to int (may be string from JSON)
-                    ams_id = int(ams_id_raw) if isinstance(ams_id_raw, str) else ams_id_raw
-                    if ams_id >= 128:  # Skip HT AMS (id >= 128)
-                        continue
-                    # Bits for this AMS unit: bits (ams_id*4) to (ams_id*4 + 3)
-                    for tray in ams_unit.get("tray", []):
-                        tray_id_raw = tray.get("id")
-                        if tray_id_raw is None:
-                            continue
-                        # Convert to int (may be string from JSON)
-                        tray_id = int(tray_id_raw) if isinstance(tray_id_raw, str) else tray_id_raw
-                        global_bit = ams_id * 4 + tray_id
-                        slot_exists = (tray_exist_bits >> global_bit) & 1
-                        if not slot_exists:
-                            # #1322 follow-up (by @RosdasHH): the bitmask is
-                            # BambuStudio's canonical "no spool" signal, and
-                            # works across every firmware variant (P1S, A1
-                            # Mini, post-restart, post-Reset-Slot, steady-
-                            # state). Promote to state=9 (firmware's
-                            # explicit "no spool" code) so downstream
-                            # readers — printers.py's API serializer,
-                            # inventory.py's `tray_state in {9, 10}`
-                            # short-circuit, the AMS card — see one
-                            # canonical signal instead of guessing from
-                            # payload shape. Int (not "9") to match the
-                            # downstream `==` comparison.
-                            tray["state"] = 9
-                            if tray.get("tray_type"):
-                                # Stale data from before the slot went empty
-                                # — clear it so the AMS view doesn't render a
-                                # colour/material that's no longer there.
-                                logger.debug(
-                                    f"[{self.serial_number}] Clearing empty slot: AMS {ams_id} slot {tray_id} "
-                                    f"(tray_exist_bits bit {global_bit} = 0)"
-                                )
-                                tray["tray_type"] = ""
-                                tray["tray_sub_brands"] = ""
-                                tray["tray_color"] = ""
-                                tray["tray_id_name"] = ""
-                                tray["tag_uid"] = "0000000000000000"
-                                tray["tray_uuid"] = "00000000000000000000000000000000"
-                                tray["tray_info_idx"] = ""
-                                tray["remain"] = 0
+        # Empty-slot cleanup via tray_exist_bits (#147, #1322, #765, #1365).
+        # Shared with the VP bridge cache so the slicer-facing view stays in
+        # sync with Bambuddy's AMS card (#1726). See the helper's docstring
+        # for the full rationale and the printer-shutdown guard.
+        if isinstance(ams_data, dict):
+            apply_tray_exist_bits(
+                merged_ams,
+                ams_data.get("tray_exist_bits"),
+                power_on_flag=ams_data.get("power_on_flag", True),
+                log_label=self.serial_number,
+            )
 
         self.state.raw_data["ams"] = merged_ams
 
@@ -2061,12 +2123,46 @@ class BambuMQTTClient:
         # Calibration stage tracking
         if "stg_cur" in data:
             new_stg = data["stg_cur"]
+            prev_stg = self.state.stg_cur
             # Always log ANY stg_cur change for debugging filament operations
-            if new_stg != self.state.stg_cur:
+            if new_stg != prev_stg:
                 logger.debug(
-                    f"[{self.serial_number}] stg_cur changed: {self.state.stg_cur} -> {new_stg} ({get_stage_name(new_stg)})"
+                    f"[{self.serial_number}] stg_cur changed: {prev_stg} -> {new_stg} ({get_stage_name(new_stg)})"
                 )
             self.state.stg_cur = new_stg
+            # #1721 end-of-print finish photo trigger.
+            # Stage 22 = "Filament unloading" fires at end-of-print AND
+            # during mid-print color swaps. The end-of-print gate
+            # (progress>=99 / layer>=total / remaining<=0) disambiguates
+            # — those signals only line up at the real end. Edge-only
+            # (prev != 22) so the trigger fires once per stage entry.
+            if (
+                new_stg == 22
+                and prev_stg != 22
+                and self._was_running
+                and not self._finish_photo_captured
+                and self.on_finish_photo_moment
+            ):
+                progress = self.state.progress or 0.0
+                layer_num = self.state.layer_num or 0
+                total_layers = self.state.total_layers or 0
+                remaining = self.state.remaining_time or 0
+                is_end_of_print = progress >= 99 or (total_layers > 0 and layer_num >= total_layers) or remaining <= 0
+                if is_end_of_print:
+                    self._finish_photo_captured = True
+                    logger.info(
+                        f"[{self.serial_number}] FINISH PHOTO MOMENT (stage-22) — "
+                        f"progress={progress}, layer={layer_num}/{total_layers}, "
+                        f"remaining={remaining}min, timelapse_active={self._timelapse_during_print}"
+                    )
+                    self.on_finish_photo_moment(
+                        {
+                            "trigger": "stage_22",
+                            "filename": self._previous_gcode_file or self.state.gcode_file,
+                            "subtask_name": self.state.subtask_name,
+                            "timelapse_was_active": self._timelapse_during_print,
+                        }
+                    )
         if "stg" in data:
             self.state.stg = data["stg"] if isinstance(data["stg"], list) else []
 
@@ -2100,6 +2196,26 @@ class BambuMQTTClient:
         # bit 8 = 1 → LEFT extruder (active_extruder=1)
         if "device" in data and isinstance(data.get("device"), dict):
             device = data["device"]
+            # One-shot identification probe: surface whatever the firmware uses to
+            # name itself so an unknown model in a support bundle becomes self-
+            # diagnosing. INFO level so it shows up without debug logging. Falls
+            # back to dumping device.keys() if none of the known fields are present
+            # (so a future Bambu rename like `model_name` is still observable).
+            if not getattr(self, "_device_id_logged", False):
+                id_fields = {
+                    k: device.get(k)
+                    for k in ("dev_model_name", "dev_product_name", "dev_id", "project_name")
+                    if k in device
+                }
+                if id_fields:
+                    logger.info("[%s] Device identification: %s", self.serial_number, id_fields)
+                else:
+                    logger.info(
+                        "[%s] Device identification: no known id fields; device.keys=%s",
+                        self.serial_number,
+                        sorted(device.keys()),
+                    )
+                self._device_id_logged = True
             if "extruder" in device and "state" in device["extruder"]:
                 state_val = device["extruder"]["state"]
                 # Extract bit 8 for extruder position
@@ -2958,6 +3074,8 @@ class BambuMQTTClient:
             # Reset completion tracking for new print
             self._was_running = True
             self._completion_triggered = False
+            # #1721: rearm the end-of-print finish-photo trigger for the new print
+            self._finish_photo_captured = False
             # Reset last valid progress/layer for usage tracking
             self._last_valid_progress = 0.0
             self._last_valid_layer_num = 0
@@ -3069,6 +3187,26 @@ class BambuMQTTClient:
                 f"timelapse_during_print: {self._timelapse_during_print}"
             )
             timelapse_was_active = self._timelapse_during_print
+            # #1721 fallback: if the stage-22 trigger never fired (cancel,
+            # external-spool-only, HMS halt, or firmware variant that skips
+            # the unload phase) fire the finish-photo moment now. Bed has
+            # already dropped, framing is worse, but we still capture.
+            # Only on successful completion — aborted/failed prints don't
+            # produce a meaningful finish photo.
+            if status == "completed" and not self._finish_photo_captured and self.on_finish_photo_moment:
+                self._finish_photo_captured = True
+                logger.info(
+                    f"[{self.serial_number}] FINISH PHOTO MOMENT (FINISH fallback) — "
+                    f"stage-22 never fired; capturing at FINISH-state transition"
+                )
+                self.on_finish_photo_moment(
+                    {
+                        "trigger": "finish_state",
+                        "filename": self._previous_gcode_file or current_file,
+                        "subtask_name": self.state.subtask_name,
+                        "timelapse_was_active": timelapse_was_active,
+                    }
+                )
             self._completion_triggered = True
             self._was_running = False
             self._timelapse_during_print = False  # Reset for next print
@@ -3439,17 +3577,24 @@ class BambuMQTTClient:
                     "use_ams": use_ams,
                     "cfg": "0",
                     # extrude_cali_flag gates flow-dynamics calibration:
-                    # 1 = run it, 2 = skip and reuse the stored PA value.
-                    # BambuStudio always pairs this with flow_cali and never
-                    # sends 0; a hardcoded 0 made the printer skip calibration
-                    # regardless of the flow_cali toggle (#1478).
-                    "extrude_cali_flag": 1 if flow_cali else 2,
+                    # 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,
                     "extrude_cali_manual_mode": 0,
-                    # 1 = run, 2 = skip. BambuStudio exposes the toggle only for
-                    # dual-nozzle machines (H2D/H2D Pro/H2C/X2D); on single-nozzle
-                    # printers we always send 2 so firmware never wastes cycles
-                    # on a calibration their head doesn't support (#1682).
-                    "nozzle_offset_cali": 1 if (nozzle_offset_cali and is_dual_nozzle) else 2,
+                    # 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,
                     "subtask_name": filename.replace(".3mf", "").replace(".gcode", ""),
                     "profile_id": "0",
                     "project_id": submission_id,

+ 5 - 0
backend/app/services/export.py

@@ -80,6 +80,7 @@ class ExportService:
         date_from: datetime | None = None,
         date_to: datetime | None = None,
         search: str | None = None,
+        visible_to_user_id: int | None = None,
     ) -> tuple[bytes, str, str]:
         """Export archives to CSV or Excel format.
 
@@ -92,6 +93,8 @@ class ExportService:
             date_from: Filter by start date
             date_to: Filter by end date
             search: Search filter
+            visible_to_user_id: Scope rows to those owned by this user (used
+                when the caller has ARCHIVES_READ_OWN but not _ALL).
 
         Returns:
             Tuple of (file_bytes, filename, content_type)
@@ -112,6 +115,8 @@ class ExportService:
             query = query.where(PrintArchive.created_at >= date_from)
         if date_to:
             query = query.where(PrintArchive.created_at <= date_to)
+        if visible_to_user_id is not None:
+            query = query.where(PrintArchive.created_by_id == visible_to_user_id)
         if search:
             like_pattern = f"%{search}%"
             query = query.where(

+ 7 - 0
backend/app/services/firmware_check.py

@@ -62,6 +62,7 @@ MODEL_TO_API_KEY = {
     "A1 Mini": "a1-mini",
     "A1-Mini": "a1-mini",
     "A1mini": "a1-mini",
+    "A2L": "a2l",
     "H2D": "h2d",
     "H2C": "h2c",
     "H2S": "h2s",
@@ -88,6 +89,7 @@ MODEL_TO_API_KEY = {
     "N1": "a1-mini",
     "N6": "x2d",
     "N7": "p2s",
+    "N9": "a2l",
 }
 
 # Reverse mapping: API key to model codes
@@ -103,6 +105,7 @@ API_KEY_TO_DEV_MODEL = {
     "x1e": "C13",
     "x2d": "N6",
     "h2d-pro": "O1E",
+    "a2l": "N9",
 }
 
 # Wiki firmware release history pages (primary version source)
@@ -118,6 +121,10 @@ API_KEY_TO_WIKI_PATH = {
     "p2s": "/en/p2s/manual/p2s-firmware-release-history",
     "x2d": "/en/x2d/manual/x2d-firmware-release-history",
     "h2d-pro": "/en/h2d-pro/manual/firmware-release-history",
+    # A2L wiki path follows the established pattern but isn't yet published —
+    # _fetch_all_versions_from_wiki silently returns [] on 404 so this is safe
+    # to ship before Bambu publishes the page.
+    "a2l": "/en/a2l/manual/a2l-firmware-release-history",
 }
 
 

+ 17 - 7
backend/app/services/local_backup.py

@@ -7,7 +7,7 @@ on a configurable schedule with retention management.
 import asyncio
 import logging
 import os
-from datetime import datetime, timedelta, timezone
+from datetime import datetime, timedelta, timezone, tzinfo
 from pathlib import Path
 from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
 
@@ -20,21 +20,31 @@ from backend.app.models.settings import Settings
 logger = logging.getLogger(__name__)
 
 
-def _local_zone() -> ZoneInfo:
+def _local_zone() -> tzinfo:
     """Resolve the local timezone for scheduled-backup HH:MM interpretation.
 
     Uses the container's ``TZ`` env var (the same value the support package
     surfaces); falls back to UTC when unset or unrecognised so a missing TZ
     keeps the legacy behaviour rather than crashing. See #1602 follow-up.
+
+    On Windows the embedded Python in our installer doesn't carry an IANA
+    tz database, so ``ZoneInfo(...)`` — including ``ZoneInfo("UTC")`` —
+    raises ``ZoneInfoNotFoundError`` unless the ``tzdata`` PyPI package is
+    installed. requirements.txt now pins ``tzdata`` on win32, but to keep
+    this resilient on installs that haven't refreshed deps we fall through
+    to the stdlib ``datetime.timezone.utc`` as a last resort; it satisfies
+    every ``astimezone`` / ``str()`` call site without needing the IANA DB.
     """
     tz_name = os.environ.get("TZ", "").strip()
-    if not tz_name:
-        return ZoneInfo("UTC")
+    if tz_name:
+        try:
+            return ZoneInfo(tz_name)
+        except ZoneInfoNotFoundError:
+            logger.warning("Unrecognised TZ env value %r, scheduling in UTC", tz_name)
     try:
-        return ZoneInfo(tz_name)
-    except ZoneInfoNotFoundError:
-        logger.warning("Unrecognised TZ env value %r, scheduling in UTC", tz_name)
         return ZoneInfo("UTC")
+    except ZoneInfoNotFoundError:
+        return timezone.utc
 
 
 SCHEDULE_INTERVALS = {

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

@@ -164,6 +164,22 @@ async def _resolve_cloud(db: AsyncSession, user: User | None, ref: PresetRef, sl
             slot,
         )
         payload = detail
+    if isinstance(payload, dict):
+        # Bambu Cloud labels presets with `type: "printer"` / `"print"` /
+        # `"filament"`, but the BS / Orca CLI's `--load-settings` parser only
+        # accepts `"machine"` / `"process"` / `"filament"`. Without this
+        # rewrite the CLI exits -5 with `operator(): unknown config type`
+        # and the sidecar surfaces a generic "The input preset file is
+        # invalid and can not be parsed" — see preset_resolver header
+        # comment for the silent-fail history. `from` gets the same
+        # treatment: Bambu Cloud's filament details routinely arrive with
+        # `from: ""` (or no `from` at all) and the CLI rejects either with
+        # `operator(): ... from  unsupported` (same -5 exit). The standard
+        # tier already pins `from: "system"` for exactly this reason; the
+        # cloud tier needs the same pin because it lands at the same `--load-
+        # settings` parser. The sidecar's `normalizeFromField` only rewrites
+        # the `"User"` / `"System"` casings, not empty / missing values.
+        payload = {**payload, "type": _SLOT_TO_PROFILE_TYPE[slot], "from": "system"}
     return json.dumps(payload)
 
 
@@ -222,6 +238,16 @@ async def _resolve_orca_cloud(db: AsyncSession, user: User | None, ref: PresetRe
             slot,
         )
         content = profile
+    if isinstance(content, dict):
+        # Orca natively uses `machine` / `process` / `filament` for `type`,
+        # which is what the CLI wants — but Bambu-imported profiles synced
+        # through Orca Cloud can carry `printer` / `print` instead, and the
+        # CLI's `--load-settings` parser rejects those the same way it does
+        # for the Bambu Cloud tier. Force the slot-appropriate value so the
+        # source tier doesn't decide whether slicing works. `from` gets the
+        # same forced pin to `"system"` for the same reason — see the
+        # Bambu Cloud branch above.
+        content = {**content, "type": _SLOT_TO_PROFILE_TYPE[slot], "from": "system"}
     return json.dumps(content)
 
 

+ 7 - 15
backend/app/services/print_scheduler.py

@@ -2187,21 +2187,13 @@ class PrintScheduler:
         pre_subtask_id = getattr(pre_status, "subtask_id", None) if pre_status else None
         pre_gcode_file = getattr(pre_status, "gcode_file", None) if pre_status else None
 
-        # #1397: force timelapse on when capture_finish_photo is enabled so
-        # the finish-photo extractor has something to pull from. Same override
-        # semantics as background_dispatch.py — both queue paths must apply
-        # the same rule or queued prints slip through without a finish photo.
-        # When archive_print failed (library_file path, line 1968 except), we
-        # have no archive to mark — fall back to the literal user choice; the
-        # downstream finish-photo path can't run without an archive anyway.
-        if archive is not None:
-            from backend.app.services.background_dispatch import resolve_effective_timelapse
-
-            effective_timelapse = await resolve_effective_timelapse(
-                db, archive, user_wanted_timelapse=bool(item.timelapse)
-            )
-        else:
-            effective_timelapse = bool(item.timelapse)
+        # #1721: respect the user's explicit timelapse choice. The #1397
+        # force-on at dispatch was removed because it caused per-layer nozzle
+        # parking on slicer profiles with Timelapse Type = Smooth. Finish-photo
+        # capture is now driven by the stg_cur=22 transition in bambu_mqtt.py
+        # ("Filament unloading", toolhead parked, bed not yet dropped) with a
+        # FINISH-state fallback — no need to force a video.
+        effective_timelapse = bool(item.timelapse)
 
         # Start the print with AMS mapping, plate_id and print options
         started = printer_manager.start_print(

+ 8 - 1
backend/app/services/printer_diagnostic.py

@@ -18,6 +18,7 @@ from backend.app.models.printer import Printer
 from backend.app.schemas.printer import DiagnosticCheck, PrinterDiagnosticResult
 from backend.app.services.discovery import is_running_in_docker
 from backend.app.services.printer_manager import printer_manager
+from backend.app.utils.printer_models import has_external_storage
 
 logger = logging.getLogger(__name__)
 
@@ -176,8 +177,14 @@ async def run_connection_diagnostic(
     # banner. An FTP upload-and-verify probe was tried and rejected — the
     # /cache directory is always writable from Bambuddy regardless of
     # either toggle, so the probe always passes and detects nothing.
+    #
+    # Skip entirely on models with no external-storage slot at all (A1
+    # and A1 Mini). They never set home_flag bit 11, so a naive read of
+    # `store_to_sdcard` would fall through to a false `fail` for every
+    # A1-series user (#1703).
     state = printer_manager.get_status(printer.id) if printer else None
-    if state is None or not state.connected:
+    model_has_slot = has_external_storage(getattr(printer, "model", None)) if printer else True
+    if not model_has_slot or state is None or not state.connected:
         checks.append(DiagnosticCheck(id="external_storage", status="skip"))
     elif getattr(state, "store_to_sdcard", None) is True:
         checks.append(DiagnosticCheck(id="external_storage", status="pass"))

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

@@ -173,6 +173,7 @@ class PrinterManager:
         self._on_print_start: Callable[[int, dict], None] | None = None
         self._on_print_complete: Callable[[int, dict], None] | None = None
         self._on_print_running_observed: Callable[[int, dict], None] | None = None
+        self._on_finish_photo_moment: Callable[[int, dict], None] | None = None
         self._on_status_change: Callable[[int, PrinterState], None] | None = None
         self._on_ams_change: Callable[[int, list], None] | None = None
         self._on_layer_change: Callable[[int, int], None] | None = None
@@ -322,6 +323,19 @@ class PrinterManager:
         hook to recover."""
         self._on_print_running_observed = callback
 
+    def set_finish_photo_moment_callback(self, callback: Callable[[int, dict], None]):
+        """Set callback for the #1721 finish-photo moment.
+
+        Fires on the stage-22 (\"Filament unloading\") edge at end-of-print
+        — the framing window where the toolhead is parked but the bed
+        hasn't dropped yet. Falls back to firing at the FINISH-state
+        transition for prints that skip stage 22 (cancel, external-spool-
+        only, HMS halt, firmware variants). Payload includes the
+        ``trigger`` key (``\"stage_22\"`` or ``\"finish_state\"``) and
+        ``timelapse_was_active`` so the photo path can choose between
+        live-camera capture and timelapse last-frame extraction."""
+        self._on_finish_photo_moment = callback
+
     def set_status_change_callback(self, callback: Callable[[int, PrinterState], None]):
         """Set callback for status change events."""
         self._on_status_change = callback
@@ -389,6 +403,10 @@ class PrinterManager:
             if self._on_print_running_observed:
                 self._schedule_async(self._on_print_running_observed(printer_id, data))
 
+        def on_finish_photo_moment(data: dict):
+            if self._on_finish_photo_moment:
+                self._schedule_async(self._on_finish_photo_moment(printer_id, data))
+
         def on_ams_change(ams_data: list):
             if self._on_ams_change:
                 self._schedule_async(self._on_ams_change(printer_id, ams_data))
@@ -418,6 +436,7 @@ class PrinterManager:
             on_bed_temp_update=on_bed_temp_update,
             on_drying_complete=on_drying_complete,
             on_print_running_observed=on_print_running_observed,
+            on_finish_photo_moment=on_finish_photo_moment,
         )
 
         client.connect()

+ 23 - 88
backend/app/services/slice_preview.py

@@ -8,22 +8,12 @@ Bambu Studio applies its own pruning to painted-face data at slice time.
 
 This module wraps the sidecar's slice call so the endpoint can run a preview
 slice, parse the result's slice_info, and return the actual filament list.
-Two slice modes are supported:
-
-  * "embedded settings" mode (default) — calls ``slice_without_profiles`` so
-    the slicer falls back on the file's own ``Metadata/project_settings.config``.
-    Used when the SliceModal opens before the user has picked a profile
-    triplet and we just want the slot-mapping (which is a model property,
-    independent of process settings).
-
-  * "bundle" mode — when the caller passes a bundle id + per-category preset
-    names, calls ``slice_with_bundle`` so the preview reflects the same
-    triplet the real print will use. More accurate gram numbers; same slot
-    mapping. Used after the SliceModal's Bundle tier resolves.
-
-Results are cached by ``(kind, source_id, plate_id, content_hash, bundle_key)``
-so different bundle picks on the same file don't collide and repeat opens
-on the same plate + same bundle are instant. LRU eviction keeps the cache
+The preview always uses the file's embedded settings (``slice_without_profiles``):
+the slot-mapping is a model property, independent of process settings, so
+we don't need to thread the user's profile triplet through here.
+
+Results are cached by ``(kind, source_id, plate_id, content_hash)`` so
+repeat opens on the same plate are instant. LRU eviction keeps the cache
 bounded. Hash invalidation handles in-place file replacement; no TTL is
 used because preview-slice output is deterministic for a given input.
 """
@@ -47,19 +37,15 @@ from backend.app.services.slicer_api import (
 logger = logging.getLogger(__name__)
 
 _PREVIEW_CACHE_MAX = 256
-# Cache key includes a bundle-context fingerprint (or "" when no bundle was
-# supplied) so a "preview without profiles" result and a "preview with
-# bundle X" result for the same file/plate occupy distinct entries instead
-# of clobbering each other.
-_PreviewCacheKey = tuple[str, int, int, str, str]
+_PreviewCacheKey = tuple[str, int, int, str]
 # Cache values: list[dict] on success, [] on parsed-but-empty (slicer
 # returned a 3MF without filament data for this plate — caching the negative
 # avoids burning 30s+ per modal open on a known-bad input).
 _preview_cache: OrderedDict[_PreviewCacheKey, list[dict]] = OrderedDict()
-# Per-key locks prevent N concurrent modal opens on the same (file, plate,
-# bundle) from launching N redundant preview slices — only the first one
-# runs, the rest wait and read from the cache. Locks are evicted alongside
-# cache entries to keep the dict bounded; we do NOT cache transient sidecar
+# Per-key locks prevent N concurrent modal opens on the same (file, plate)
+# from launching N redundant preview slices — only the first one runs, the
+# rest wait and read from the cache. Locks are evicted alongside cache
+# entries to keep the dict bounded; we do NOT cache transient sidecar
 # failures (network errors etc.) so those retry naturally on next request.
 _preview_locks: dict[_PreviewCacheKey, asyncio.Lock] = {}
 
@@ -68,25 +54,6 @@ def _content_hash(file_bytes: bytes) -> str:
     return hashlib.sha256(file_bytes).hexdigest()[:16]
 
 
-def _bundle_context_fingerprint(
-    bundle_id: str | None,
-    printer_name: str | None,
-    process_name: str | None,
-    filament_names: list[str] | None,
-) -> str:
-    """Derive a stable cache-key fragment for the bundle context. Empty
-    string when no bundle is supplied — preserves cache compatibility with
-    the no-bundle ("embedded settings") path so existing entries remain
-    valid. SHA-256 prefix keeps the key short while collision-resistant
-    enough for a 256-entry LRU.
-    """
-    if not (bundle_id and printer_name and process_name and filament_names):
-        return ""
-    parts = [bundle_id, printer_name, process_name, *filament_names]
-    raw = "\x1f".join(parts).encode("utf-8")
-    return hashlib.sha256(raw).hexdigest()[:12]
-
-
 async def get_preview_filaments(
     *,
     kind: str,
@@ -96,33 +63,20 @@ async def get_preview_filaments(
     file_name: str,
     api_url: str,
     request_id: str | None = None,
-    bundle_id: str | None = None,
-    printer_name: str | None = None,
-    process_name: str | None = None,
-    filament_names: list[str] | None = None,
 ) -> list[dict] | None:
     """Run a preview slice for ``plate_id``, parse the resulting slice_info,
     and return the per-plate filament list.
 
-    By default uses the file's embedded settings (``slice_without_profiles``).
-    When all four ``bundle_*`` params are provided, uses ``slice_with_bundle``
-    so the preview matches the profile triplet the real print will use —
-    same slot mapping, more-accurate gram numbers. Partial bundle context
-    (e.g. id without preset names) falls back to the embedded path rather
-    than failing, so an in-progress modal selection doesn't surface errors.
+    Uses the file's embedded settings (``slice_without_profiles``) since the
+    slot mapping is a model property, independent of any user-picked profile
+    triplet.
 
     Returns ``None`` when the preview slice fails — the caller should fall
     back to whatever heuristic it has (typically the project_filaments +
     painted-face approach in ``threemf_tools``).
     """
     h = _content_hash(file_bytes)
-    bundle_fp = _bundle_context_fingerprint(
-        bundle_id,
-        printer_name,
-        process_name,
-        filament_names,
-    )
-    key: _PreviewCacheKey = (kind, source_id, plate_id, h, bundle_fp)
+    key: _PreviewCacheKey = (kind, source_id, plate_id, h)
     cached = _preview_cache.get(key)
     if cached is not None:
         _preview_cache.move_to_end(key)
@@ -139,38 +93,19 @@ async def get_preview_filaments(
 
         try:
             async with SlicerApiService(base_url=api_url) as svc:
-                if bundle_fp:
-                    # All four bundle params present (guaranteed non-None by
-                    # _bundle_context_fingerprint returning non-empty);
-                    # the type-checker can't see that, so assert for narrowing.
-                    assert bundle_id and printer_name and process_name
-                    assert filament_names is not None
-                    result = await svc.slice_with_bundle(
-                        model_bytes=file_bytes,
-                        model_filename=file_name,
-                        bundle_id=bundle_id,
-                        printer_name=printer_name,
-                        process_name=process_name,
-                        filament_names=filament_names,
-                        plate=plate_id,
-                        export_3mf=True,
-                        request_id=request_id,
-                    )
-                else:
-                    result = await svc.slice_without_profiles(
-                        model_bytes=file_bytes,
-                        model_filename=file_name,
-                        plate=plate_id,
-                        export_3mf=True,
-                        request_id=request_id,
-                    )
+                result = await svc.slice_without_profiles(
+                    model_bytes=file_bytes,
+                    model_filename=file_name,
+                    plate=plate_id,
+                    export_3mf=True,
+                    request_id=request_id,
+                )
         except SlicerApiError as e:
             logger.warning(
-                "Preview slice failed for %s/%s plate %s (bundle=%s): %s",
+                "Preview slice failed for %s/%s plate %s: %s",
                 kind,
                 source_id,
                 plate_id,
-                bundle_id or "-",
                 e,
             )
             return None

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

@@ -47,42 +47,6 @@ class SliceResult(NamedTuple):
     filament_used_mm: float
 
 
-class BundleSummary(NamedTuple):
-    """Sidecar's view of a stored Printer Preset Bundle (.bbscfg).
-
-    Mirrors the JSON shape returned by `/profiles/bundle(s)` on the
-    sidecar — `printer`, `process`, `filament` are each a list of preset
-    names available within the bundle (without the `.json` extension and
-    without the BambuStudio "# " user-clone prefix; the sidecar accepts
-    both forms when looking them up at slice time).
-    """
-
-    id: str
-    printer_preset_name: str
-    printer: list[str]
-    process: list[str]
-    filament: list[str]
-    version: str | None
-
-
-class BundleNotFoundError(SlicerApiError):
-    """Sidecar returned 404 for the bundle id (deleted, never imported)."""
-
-
-def _parse_bundle_summary(payload: dict) -> BundleSummary:
-    """Build a BundleSummary from the sidecar's JSON. Tolerant of missing
-    optional fields so a sidecar that adds keys later doesn't break parsing.
-    """
-    return BundleSummary(
-        id=str(payload.get("id") or ""),
-        printer_preset_name=str(payload.get("printer_preset_name") or ""),
-        printer=list(payload.get("printer") or []),
-        process=list(payload.get("process") or []),
-        filament=list(payload.get("filament") or []),
-        version=payload.get("version"),
-    )
-
-
 _shared_http_client: httpx.AsyncClient | None = None
 
 
@@ -191,102 +155,6 @@ class SlicerApiService:
             raise SlicerApiUnavailableError(f"Slicer sidecar /profiles/bundled returned {response.status_code}")
         return response.json()
 
-    async def import_bundle(
-        self,
-        zip_bytes: bytes,
-        *,
-        filename: str = "bundle.bbscfg",
-    ) -> BundleSummary:
-        """POST /profiles/bundle — upload a BambuStudio Printer Preset Bundle.
-
-        Idempotent on the sidecar side: re-uploading the same file yields the
-        same id (deterministic SHA-256 prefix of the zip content) and the
-        sidecar reuses its existing extracted directory, so re-importing is
-        always safe.
-
-        Raises:
-            SlicerInputError: 4xx — bundle isn't a valid .bbscfg, or fails the
-                sidecar's path-traversal / manifest validation.
-            SlicerApiUnavailableError: connection error or 5xx.
-        """
-        files = {"file": (filename, zip_bytes, "application/zip")}
-        try:
-            response = await self._client.post(
-                f"{self.base_url}/profiles/bundle",
-                files=files,
-                timeout=60.0,
-            )
-        except httpx.RequestError as exc:
-            raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
-        if response.status_code >= 500:
-            raise SlicerApiServerError(
-                f"Slicer sidecar /profiles/bundle failed ({response.status_code}): {_format_sidecar_error(response)}",
-            )
-        if response.status_code >= 400:
-            raise SlicerInputError(
-                f"Slicer sidecar rejected bundle ({response.status_code}): {_format_sidecar_error(response)}",
-            )
-        return _parse_bundle_summary(response.json())
-
-    async def list_bundles(self) -> list[BundleSummary]:
-        """GET /profiles/bundles — list every imported bundle and its presets.
-
-        Returns an empty list when the sidecar's bundle store is empty (the
-        sidecar returns ``[]`` rather than 404 in that case). Network errors
-        and 5xx surface as ``SlicerApiUnavailableError`` so callers can
-        decide whether to render an empty UI or a "sidecar offline" banner.
-        """
-        try:
-            response = await self._client.get(f"{self.base_url}/profiles/bundles", timeout=10.0)
-        except httpx.RequestError as exc:
-            raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
-        if response.status_code >= 400:
-            raise SlicerApiUnavailableError(
-                f"Slicer sidecar /profiles/bundles returned {response.status_code}",
-            )
-        payload = response.json()
-        if not isinstance(payload, list):
-            raise SlicerApiServerError("Slicer sidecar returned non-array bundle list")
-        return [_parse_bundle_summary(b) for b in payload if isinstance(b, dict)]
-
-    async def get_bundle(self, bundle_id: str) -> BundleSummary:
-        """GET /profiles/bundles/<id> — single bundle summary.
-
-        Raises:
-            BundleNotFoundError: 404 — id does not exist on the sidecar.
-            SlicerApiUnavailableError: connection error or 5xx.
-        """
-        try:
-            response = await self._client.get(
-                f"{self.base_url}/profiles/bundles/{bundle_id}",
-                timeout=10.0,
-            )
-        except httpx.RequestError as exc:
-            raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
-        if response.status_code == 404:
-            raise BundleNotFoundError(f"Bundle {bundle_id!r} not found on sidecar")
-        if response.status_code >= 400:
-            raise SlicerApiUnavailableError(
-                f"Slicer sidecar /profiles/bundles/{bundle_id} returned {response.status_code}",
-            )
-        return _parse_bundle_summary(response.json())
-
-    async def delete_bundle(self, bundle_id: str) -> None:
-        """DELETE /profiles/bundles/<id> — remove a stored bundle."""
-        try:
-            response = await self._client.delete(
-                f"{self.base_url}/profiles/bundles/{bundle_id}",
-                timeout=10.0,
-            )
-        except httpx.RequestError as exc:
-            raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
-        if response.status_code == 404:
-            raise BundleNotFoundError(f"Bundle {bundle_id!r} not found on sidecar")
-        if response.status_code >= 400:
-            raise SlicerApiUnavailableError(
-                f"Slicer sidecar DELETE /profiles/bundles/{bundle_id} returned {response.status_code}",
-            )
-
     async def _poll_progress(
         self,
         request_id: str,
@@ -438,114 +306,6 @@ class SlicerApiService:
             filament_used_mm=_safe_float(response.headers.get("x-filament-used-mm")),
         )
 
-    async def slice_with_bundle(
-        self,
-        *,
-        model_bytes: bytes,
-        model_filename: str,
-        bundle_id: str,
-        printer_name: str,
-        process_name: str,
-        filament_names: list[str],
-        plate: int | None = None,
-        export_3mf: bool = False,
-        arrange: bool = False,
-        bed_type: str | None = None,
-        request_id: str | None = None,
-        on_progress: Callable[[dict], None] | None = None,
-    ) -> SliceResult:
-        """POST /slice with bundle id + per-category preset names.
-
-        Asks the sidecar to materialize the printer / process / filament
-        JSONs from a previously-imported `.bbscfg`, instead of accepting
-        them as multipart attachments. Equivalent to
-        ``slice_with_profiles`` from the user's perspective — same return
-        shape, same 4xx/5xx semantics, same progress-poll wiring — but
-        the sidecar saves the round-trip of re-uploading the JSONs every
-        time a user kicks off a slice with the same bundle.
-
-        ``filament_names`` is plate-slot-ordered: index 0 is slot 1, etc.
-        Single-color callers pass a one-element list. The sidecar joins
-        them as semicolon-separated `--load-filaments` for the CLI.
-
-        Raises:
-            SlicerInputError: 4xx — bundle / preset name not found, etc.
-            SlicerApiServerError: sidecar 5xx (CLI failure on resolved
-                triplet — same conditions that fail slice_with_profiles).
-            SlicerApiUnavailableError: connection error.
-        """
-        files = {
-            "file": (model_filename, model_bytes, _guess_model_content_type(model_filename)),
-        }
-        data: dict[str, str | list[str]] = {
-            "bundle": bundle_id,
-            "printerName": printer_name,
-            "processName": process_name,
-        }
-        # The sidecar's SlicingSettings supports both `filamentName` (single
-        # legacy field, kept for clients that pre-date multi-color) and
-        # `filamentNames` (semicolon/comma-separated, matches multi-color
-        # uploads). Always send the array form so a single-slot case still
-        # ends up in the same code path on the sidecar.
-        data["filamentNames"] = ";".join(filament_names)
-        if plate is not None:
-            data["plate"] = str(plate)
-        if export_3mf:
-            data["exportType"] = "3mf"
-        if arrange:
-            # See slice_with_profiles for the rationale: cross-class re-slices
-            # (#1493) need --arrange so BS repositions objects for the target
-            # bed instead of inheriting the source printer's coordinate layout.
-            data["arrange"] = "true"
-        if bed_type is not None:
-            # #1337: bed-plate override flows through to the sidecar as a
-            # standalone field. The sidecar wraps this as --curr_bed_type on
-            # the CLI invocation, overriding whatever the bundle's process
-            # JSON specifies. Bambuddy can't patch the bundle's JSON locally
-            # (the sidecar materialises it from disk), so this round-trip is
-            # the only path. Silently no-ops on sidecar versions that don't
-            # yet recognise the field — the user's slice still runs with the
-            # bundle's default plate, no crash.
-            data["bedType"] = bed_type
-        if request_id is not None:
-            data["requestId"] = request_id
-
-        progress_task: asyncio.Task | None = None
-        if request_id is not None and on_progress is not None:
-            progress_task = asyncio.create_task(
-                self._poll_progress(request_id, on_progress),
-                name=f"slicer-progress-{request_id}",
-            )
-
-        try:
-            response = await self._client.post(
-                f"{self.base_url}/slice",
-                files=files,
-                data=data,
-                timeout=self.timeout_seconds,
-            )
-        except httpx.RequestError as exc:
-            raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
-        finally:
-            if progress_task is not None:
-                progress_task.cancel()
-                try:
-                    await progress_task
-                except (asyncio.CancelledError, Exception):
-                    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")),
-        )
-
     async def slice_without_profiles(
         self,
         *,

+ 205 - 0
backend/app/services/slicer_filament_resolver.py

@@ -0,0 +1,205 @@
+"""Shared spool ``slicer_filament`` → ``(tray_info_idx, setting_id)`` resolver.
+
+The internal-inventory and Spoolman-inventory routes both need to translate
+a spool's stored slicer-preset reference (cloud preset ID / local preset ID /
+GF-prefix Bambu filament ID / free-text material name) into the two MQTT
+fields ``ams_filament_setting`` consumes: the printer-side ``tray_info_idx``
+(filament_id) and the slicer-side ``setting_id``. The two routes were drifting
+in lockstep before #1713 — internal mode resolved everything, Spoolman mode
+silently dropped slicer_filament on the floor and only the generic-material
+fallback fired. This module is the single chokepoint so the two flows can't
+diverge again.
+
+Resolver outcomes:
+
+- Returns ``("", "", None)`` when ``slicer_filament`` is empty, unresolvable,
+  or sanitised away as a slicer-rejected value (literal material name,
+  PFUS / PFCN cloud setting_id). The caller is responsible for the
+  generic-material fallback when this happens.
+- Returns ``(tray_info_idx, setting_id, sub_brand_override)`` otherwise.
+  The third element is non-empty when a cloud-detail lookup or a local-
+  preset name provides a more specific brand label than the spool's own
+  ``"<brand> <material> <subtype>"`` concatenation — the caller should
+  prefer it over its computed default.
+
+The resolver is async because the GFS / PFUS / PFCN branches need cloud
+authentication and the local-preset branch reads ``LocalPreset`` from the
+DB. Pass ``current_user=None`` to skip cloud auth (the on_ams_change
+replay path uses this); cloud-prefix presets then fall back to a static
+``normalize_slicer_filament`` parse, which is correct when the slot was
+already configured by an earlier authenticated assign and the printer's
+calibration table preserves the real filament_id.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.user import User
+from backend.app.utils.filament_ids import (
+    GENERIC_FILAMENT_IDS,
+    MATERIAL_TEMPS,
+    filament_id_to_setting_id,
+    normalize_slicer_filament,
+)
+
+logger = logging.getLogger(__name__)
+
+_KNOWN_MATERIALS = set(MATERIAL_TEMPS.keys()) | set(GENERIC_FILAMENT_IDS.keys())
+
+
+async def resolve_slicer_filament(
+    *,
+    db: AsyncSession,
+    current_user: User | None,
+    slicer_filament: str | None,
+    slicer_filament_name: str | None,
+    material: str | None,
+) -> tuple[str, str, str | None]:
+    """Resolve a spool's slicer-preset reference to printer-side ids.
+
+    ``slicer_filament``: the spool's stored reference (e.g. ``"GFA01"``,
+    ``"PFUS990b6e19965353"``, ``"38"`` for a numeric LocalPreset id, or
+    free-text). May be empty or None — returns the empty tuple in that case.
+
+    ``slicer_filament_name``: optional builtin-name realignment hint. When
+    set and the resolved tray_info_idx maps to a different builtin name,
+    the resolver swaps to the builtin whose name matches (e.g. user picked
+    "Bambu PLA Matte" but the cloud lookup landed on "Bambu PLA Basic").
+
+    ``material``: spool material string for the local-preset fallback
+    branch when the LocalPreset's setting JSON doesn't carry a filament_id.
+
+    Returns ``(tray_info_idx, setting_id, sub_brand_override)`` — all empty
+    when nothing resolved. ``sub_brand_override`` is non-None when a more
+    specific brand label is available (cloud detail name or local preset
+    name); ``None`` means the caller should use its own default.
+    """
+    sf = (slicer_filament or "").strip()
+    if not sf:
+        return ("", "", None)
+
+    tray_info_idx = ""
+    setting_id = ""
+    sub_brand_override: str | None = None
+
+    base_sf = sf.split("_")[0] if "_" in sf else sf
+
+    # Cloud-side preset IDs in three known shapes:
+    #   GFS…   — Bambu official cloud preset
+    #   PFUS…  — cloud user-created preset
+    #   PFCN…  — cloud shared / partner preset (e.g. Polymaker's "(Custom)"
+    #            Bambu Lab H2D variant, #1648)
+    # All three need a cloud-detail lookup to extract the underlying
+    # filament_id; without it the raw cloud id ends up in tray_info_idx
+    # and the printer's calibration table can't resolve it.
+    if base_sf.startswith("GFS") or base_sf.startswith("PFUS") or base_sf.startswith("PFCN"):
+        setting_id = base_sf
+        try:
+            from backend.app.api.routes.cloud import build_authenticated_cloud
+
+            cloud = await build_authenticated_cloud(db, current_user)
+            if cloud is not None and cloud.is_authenticated:
+                try:
+                    detail = await cloud.get_setting_detail(base_sf)
+                    if detail.get("filament_id"):
+                        tray_info_idx = detail["filament_id"]
+                        cloud_name = detail.get("name", "")
+                        if cloud_name:
+                            sub_brand_override = cloud_name.replace(r"@.*$", "").split("@")[0].strip()
+                    elif detail.get("base_id"):
+                        bid = detail["base_id"].split("_")[0]
+                        if bid.startswith("GFS") and len(bid) >= 5:
+                            tray_info_idx = f"GF{bid[3:]}"
+                        else:
+                            tray_info_idx = bid
+                finally:
+                    await cloud.close()
+            elif cloud is not None:
+                await cloud.close()
+        except Exception as e:
+            logger.warning("Slicer-filament resolve: cloud lookup failed for %r: %s", sf, e)
+
+        if not tray_info_idx:
+            tray_info_idx, setting_id = normalize_slicer_filament(sf)
+    elif base_sf.startswith("GF"):
+        tray_info_idx, setting_id = normalize_slicer_filament(sf)
+    else:
+        try:
+            local_id = int(sf)
+            from backend.app.models.local_preset import LocalPreset as LP
+
+            lp_result = await db.execute(select(LP).where(LP.id == local_id, LP.preset_type == "filament"))
+            lp = lp_result.scalar_one_or_none()
+            if lp:
+                # Local preset's setting JSON carries the printer-recognized
+                # filament_id (e.g. "P4d64437") — use that directly so the
+                # slicer can resolve the specific preset. Falls through to
+                # generic material id only when the JSON doesn't carry one.
+                lp_filament_id = ""
+                if lp.setting:
+                    try:
+                        setting_data = json.loads(lp.setting)
+                        raw_fid = setting_data.get("filament_id")
+                        if isinstance(raw_fid, str) and raw_fid:
+                            lp_filament_id = raw_fid
+                    except (json.JSONDecodeError, AttributeError):
+                        pass
+                if lp_filament_id:
+                    tray_info_idx = lp_filament_id
+                    setting_id = filament_id_to_setting_id(lp_filament_id)
+                else:
+                    mat = (material or lp.filament_type or "").upper().strip()
+                    tray_info_idx = (
+                        GENERIC_FILAMENT_IDS.get(mat) or GENERIC_FILAMENT_IDS.get(mat.split("-")[0].split(" ")[0]) or ""
+                    )
+                if lp.name:
+                    sub_brand_override = lp.name.split("@")[0].strip()
+        except (ValueError, TypeError):
+            tray_info_idx, setting_id = normalize_slicer_filament(sf)
+
+    # Realign tray_info_idx to a builtin whose name matches slicer_filament_name
+    # when the current resolution lands on a builtin with a different name
+    # (e.g. cloud detail returned PLA Basic but the spool was labelled PLA Matte).
+    if tray_info_idx and slicer_filament_name:
+        from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES
+
+        expected_name = _BUILTIN_FILAMENT_NAMES.get(tray_info_idx, "")
+        if expected_name and expected_name != slicer_filament_name:
+            for fid, fname in _BUILTIN_FILAMENT_NAMES.items():
+                if fname == slicer_filament_name:
+                    tray_info_idx = fid
+                    setting_id = filament_id_to_setting_id(fid)
+                    break
+
+    # Defend against tray_info_idx values the slicer cannot resolve. Three
+    # shapes leak through and must be discarded so the caller's generic-
+    # material fallback can rescue the slot:
+    #   1. Literal material names ("PLA", "PETG-CF") that pass through
+    #      normalize_slicer_filament unchanged when the spool's slicer_filament
+    #      is free-text rather than a real preset ID.
+    #   2. PFUS-prefix cloud setting_ids — valid as setting_id but rejected
+    #      by the slicer as tray_info_idx (the printer's calibration table
+    #      indexes by filament_id, and a PFUS isn't one). This normally gets
+    #      realigned to a P-prefix local id via the caller's printer_kp
+    #      lookup, but on the replay path in main.py.on_ams_change
+    #      current_user=None skips cloud auth and leaves the raw PFUS in
+    #      tray_info_idx — overwriting the correctly-configured slot from
+    #      the original assign.
+    #   3. PFCN-prefix cloud shared / partner presets (e.g. Polymaker's
+    #      "(Custom)" H2D variants, #1648) — same shape problem as PFUS.
+    # Valid tray_info_idx values: "GF" + letter + digits (Bambu official) or
+    # "P" followed by hex (user/local presets, NOT "PFUS" or "PFCN").
+    if tray_info_idx and (
+        tray_info_idx.upper() in _KNOWN_MATERIALS
+        or tray_info_idx.startswith("PFUS")
+        or tray_info_idx.startswith("PFCN")
+    ):
+        tray_info_idx = ""
+        setting_id = ""
+
+    return (tray_info_idx, setting_id, sub_brand_override)

+ 158 - 0
backend/app/services/slot_preset_writer.py

@@ -0,0 +1,158 @@
+"""Shared upsert for the slot_preset_mappings row that drives the AMS slot
+card's displayed preset name.
+
+Three call sites must keep this row in sync with the currently-assigned spool:
+
+- ``api.routes.inventory.apply_spool_to_slot_via_mqtt`` (internal manual assign)
+- ``services.spool_tag_matcher.auto_assign_spool`` (internal RFID auto-assign)
+- ``main.auto_sync_spoolman_ams_trays`` (Spoolman RFID-driven sync)
+
+If any of them skips this row, the slot card surfaces the previous spool's
+preset name because the PrintersPage display chain consults
+slot_preset_mappings.preset_name first — it overrides cloudInfo.name and the
+spool's own slicer_filament_name.
+"""
+
+from __future__ import annotations
+
+import logging
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.slot_preset import SlotPresetMapping
+from backend.app.models.spool import Spool
+from backend.app.utils.filament_ids import filament_id_to_setting_id
+
+logger = logging.getLogger(__name__)
+
+
+async def upsert_slot_preset(
+    *,
+    db: AsyncSession,
+    printer_id: int,
+    ams_id: int,
+    tray_id: int,
+    preset_id: str,
+    preset_name: str,
+    preset_source: str = "cloud",
+) -> None:
+    """Primitive upsert. No-op when ``preset_id`` is empty (the column is
+    NOT NULL on the model, and an empty string isn't a useful key to
+    overwrite by). Soft-fails on DB errors so a broken upsert never
+    cascades into the surrounding spool-assign flow.
+    """
+    if not preset_id:
+        return
+    try:
+        existing = await db.execute(
+            select(SlotPresetMapping).where(
+                SlotPresetMapping.printer_id == printer_id,
+                SlotPresetMapping.ams_id == ams_id,
+                SlotPresetMapping.tray_id == tray_id,
+            )
+        )
+        mapping = existing.scalar_one_or_none()
+        if mapping:
+            mapping.preset_id = preset_id
+            mapping.preset_name = preset_name
+            mapping.preset_source = preset_source
+        else:
+            mapping = SlotPresetMapping(
+                printer_id=printer_id,
+                ams_id=ams_id,
+                tray_id=tray_id,
+                preset_id=preset_id,
+                preset_name=preset_name,
+                preset_source=preset_source,
+            )
+            db.add(mapping)
+        await db.commit()
+    except Exception as e:
+        logger.warning(
+            "Failed to save slot preset mapping for printer=%d ams=%d tray=%d: %s",
+            printer_id,
+            ams_id,
+            tray_id,
+            e,
+        )
+
+
+async def upsert_slot_preset_for_spool(
+    *,
+    db: AsyncSession,
+    spool: Spool,
+    printer_id: int,
+    ams_id: int,
+    tray_id: int,
+    tray_info_idx: str = "",
+    tray_sub_brands: str = "",
+    tray_type: str = "",
+    setting_id: str = "",
+) -> None:
+    """Convenience wrapper for internal-mode call sites — derives the
+    (preset_id, preset_name, preset_source) triple from a ``Spool`` ORM object,
+    then defers to ``upsert_slot_preset``.
+
+    Local numeric ``spool.slicer_filament`` (e.g. ``"50"``) → ``local_50``;
+    cloud-form ids (GFS… / GFA… via ``filament_id_to_setting_id`` on the
+    tray's ``tray_info_idx``) → standard setting_id form.
+    """
+    preset_name = spool.slicer_filament_name or tray_sub_brands or tray_type
+    preset_source = "cloud"
+    sf = spool.slicer_filament or ""
+    if sf:
+        base_sf_mapping = sf.split("_")[0] if "_" in sf else sf
+        try:
+            int(base_sf_mapping)
+            preset_id = f"local_{base_sf_mapping}"
+            preset_source = "local"
+        except (ValueError, TypeError):
+            preset_id = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else setting_id
+    else:
+        preset_id = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else ""
+
+    await upsert_slot_preset(
+        db=db,
+        printer_id=printer_id,
+        ams_id=ams_id,
+        tray_id=tray_id,
+        preset_id=preset_id,
+        preset_name=preset_name,
+        preset_source=preset_source,
+    )
+
+
+async def upsert_slot_preset_for_spoolman_spool(
+    *,
+    db: AsyncSession,
+    spoolman_spool: dict,
+    tray_info_idx: str,
+    tray_sub_brands: str,
+    tray_type: str,
+    printer_id: int,
+    ams_id: int,
+    tray_id: int,
+) -> None:
+    """Spoolman-mode counterpart. Derives ``preset_name`` from the Spoolman
+    filament's display name (falling back to material if absent) and
+    ``preset_id`` from the AMS-reported tray_info_idx (the cloud filament
+    id the printer is currently using). ``preset_source`` is always
+    ``"cloud"`` since Spoolman doesn't carry a local-preset concept.
+
+    The ``spoolman_spool`` dict is the shape returned by
+    ``SpoolmanClient.sync_ams_tray`` — ``spool["filament"]["name"]`` etc.
+    """
+    filament = spoolman_spool.get("filament") or {}
+    preset_name = filament.get("name") or filament.get("material") or tray_sub_brands or tray_type
+    preset_id = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else ""
+
+    await upsert_slot_preset(
+        db=db,
+        printer_id=printer_id,
+        ams_id=ams_id,
+        tray_id=tray_id,
+        preset_id=preset_id,
+        preset_name=preset_name or "",
+        preset_source="cloud",
+    )

+ 17 - 0
backend/app/services/spool_tag_matcher.py

@@ -578,4 +578,21 @@ async def auto_assign_spool(
     except Exception as e:
         logger.warning("K-profile apply failed for spool %d (RFID match): %s", spool.id, e)
 
+    # Reconcile slot_preset_mappings so the AMS slot card stops surfacing the
+    # previous spool's preset name. Shared with the manual-assign path
+    # (inventory.apply_spool_to_slot_via_mqtt). Outside the try above so a
+    # transient MQTT failure doesn't leave the display row stale.
+    from backend.app.services.slot_preset_writer import upsert_slot_preset_for_spool
+
+    await upsert_slot_preset_for_spool(
+        db=db,
+        spool=spool,
+        printer_id=printer_id,
+        ams_id=ams_id,
+        tray_id=tray_id,
+        tray_info_idx=tray_info_idx,
+        tray_sub_brands=tray.get("tray_sub_brands", "") if tray else "",
+        tray_type=tray.get("tray_type", "") if tray else "",
+    )
+
     return assignment

+ 146 - 0
backend/app/services/virtual_printer/_debug.py

@@ -0,0 +1,146 @@
+"""Env-flagged wire-payload dump for VP MQTT debug (gated; off by default).
+
+Set ``BAMBUDDY_VP_DUMP_WIRE=1`` to enable two complementary capture modes:
+
+1. ``dump_wire``: most recent inbound (bridge cache input) and outbound
+   (slicer-facing 1Hz push) MQTT payloads, one file per VP per direction,
+   overwritten each tick. Triages shape-of-payload bugs (e.g. #1622 round 1)
+   where the question is "is the bridge missing fields in the cache, or is
+   something else stripping them on the way out to the slicer?" Compare
+   ``*_in.json`` and ``*_out.json`` for the failing VP against a known-good
+   one (e.g. H2D vs P1S).
+
+2. ``append_event``: time-ordered JSONL log of every slicer↔bridge↔printer
+   command payload that flows through the VP (excludes the cached-as-base
+   1Hz push, which dump_wire already covers). Triages command-flow bugs
+   (e.g. #1622 round 2 / round 3) where the cached state looks right but a
+   slicer-initiated write (ams_filament_setting / extrusion_cali_set /
+   xcam / system) ends up corrupting state, or where the slicer's choice
+   of command flow depends on what the bridge replies to its initial
+   info.get_version / pushall probe. One line per event with wall-clock
+   timestamp.
+
+Layout:
+- snapshot: ``<log_dir>/vp_wire/<sanitized_vp_name>_<direction>.json``
+- events:   ``<log_dir>/vp_wire/<sanitized_vp_name>_cmd.jsonl``
+
+Failure modes are swallowed at debug level — debug instrumentation must
+never break the bridge or slicer-facing 1Hz loop. Disable by unsetting the
+env var; the in-progress files stay on disk and can be deleted manually.
+``_cmd.jsonl`` appends forever while enabled; for long debug sessions,
+delete between captures rather than relying on rotation.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import re
+from datetime import datetime, timezone
+
+from backend.app.core.config import settings as app_settings
+
+logger = logging.getLogger(__name__)
+
+_ENV_FLAG = "BAMBUDDY_VP_DUMP_WIRE"
+_NAME_SAFE = re.compile(r"[^A-Za-z0-9._-]+")
+
+
+def _enabled() -> bool:
+    return os.environ.get(_ENV_FLAG, "").strip().lower() in ("1", "true", "yes", "on")
+
+
+def _sanitize(name: str) -> str:
+    safe = _NAME_SAFE.sub("_", name or "vp").strip("_")
+    return safe or "vp"
+
+
+def dump_wire(vp_name: str, direction: str, payload: dict | bytes | str) -> None:
+    """Write ``payload`` to ``<log_dir>/vp_wire/<vp_name>_<direction>.json``.
+
+    No-op when the env flag is unset. Accepts dict (json-encoded with
+    ``indent=2``), bytes (decoded as utf-8 with errors='replace'), or
+    str (written verbatim).
+    """
+    if not _enabled():
+        return
+    try:
+        target_dir = app_settings.log_dir / "vp_wire"
+        target_dir.mkdir(parents=True, exist_ok=True)
+        path = target_dir / f"{_sanitize(vp_name)}_{_sanitize(direction)}.json"
+        if isinstance(payload, dict):
+            text = json.dumps(payload, indent=2, default=str)
+        elif isinstance(payload, bytes):
+            text = payload.decode("utf-8", errors="replace")
+        else:
+            text = str(payload)
+        tmp = path.with_suffix(path.suffix + ".tmp")
+        tmp.write_text(text, encoding="utf-8")
+        tmp.replace(path)
+    except OSError as e:
+        logger.debug("[%s] vp_wire dump (%s) failed: %s", vp_name, direction, e)
+
+
+def _command_label(payload: dict) -> str:
+    """Best-effort one-word label for the command, used as a grep handle in the JSONL.
+
+    Bambu's MQTT request/response shape is ``{"<channel>": {"command": "<name>", ...}}``
+    where channel is ``print``/``pushing``/``info``/``system``/``xcam``/etc.
+    Returns ``"<channel>.<command>"`` when we can find it, ``"?"`` otherwise.
+    """
+    if not isinstance(payload, dict):
+        return "?"
+    for channel, body in payload.items():
+        if isinstance(body, dict):
+            cmd = body.get("command")
+            if isinstance(cmd, str) and cmd:
+                return f"{channel}.{cmd}"
+    return "?"
+
+
+def append_event(vp_name: str, direction: str, topic: str, payload: dict | bytes | str) -> None:
+    """Append one event line to ``<log_dir>/vp_wire/<vp_name>_cmd.jsonl``.
+
+    No-op when the env flag is unset. ``direction`` should be one of
+    ``"slicer_to_bridge"`` (slicer-originated publish reaching the bridge),
+    ``"printer_to_slicer"`` (real-printer response fanned out to the slicer),
+    or ``"bridge_to_slicer"`` (bridge-synthesised reply: info.get_version
+    answer, project_file ack, on-demand pushall response). A diff between
+    a working VP and a broken VP can then be read top-to-bottom in causal
+    order. Bytes payloads are utf-8 decoded then json-parsed best-effort;
+    un-parseable payloads are logged as ``{"raw": "<text>"}`` so the line
+    is still valid JSON.
+    """
+    if not _enabled():
+        return
+    try:
+        target_dir = app_settings.log_dir / "vp_wire"
+        target_dir.mkdir(parents=True, exist_ok=True)
+        path = target_dir / f"{_sanitize(vp_name)}_cmd.jsonl"
+
+        if isinstance(payload, bytes):
+            try:
+                parsed: dict | str = json.loads(payload.decode("utf-8", errors="replace").rstrip("\x00 \r\n\t"))
+            except (json.JSONDecodeError, UnicodeDecodeError):
+                parsed = {"raw": payload.decode("utf-8", errors="replace")}
+        elif isinstance(payload, str):
+            try:
+                parsed = json.loads(payload.rstrip("\x00 \r\n\t"))
+            except json.JSONDecodeError:
+                parsed = {"raw": payload}
+        else:
+            parsed = payload
+
+        record = {
+            "ts": datetime.now(timezone.utc).isoformat(timespec="milliseconds"),
+            "dir": direction,
+            "topic": topic,
+            "cmd": _command_label(parsed) if isinstance(parsed, dict) else "?",
+            "payload": parsed,
+        }
+        line = json.dumps(record, default=str) + "\n"
+        with path.open("a", encoding="utf-8") as fp:
+            fp.write(line)
+    except OSError as e:
+        logger.debug("[%s] vp_wire append (%s) failed: %s", vp_name, direction, e)

+ 116 - 70
backend/app/services/virtual_printer/manager.py

@@ -44,6 +44,8 @@ VIRTUAL_PRINTER_MODELS = {
     "C13": "X1E",  # X1E
     # X2 Series
     "N6": "X2D",  # X2D
+    # A2 Series (single-FDM + integrated cutter/plotter)
+    "N9": "A2L",  # A2L
     # P Series
     "C11": "P1P",  # P1P
     "C12": "P1S",  # P1S
@@ -76,6 +78,8 @@ MODEL_SERIAL_PREFIXES = {
     "C13": "03W00A",  # X1E
     # X2 Series
     "N6": "20P90A",  # X2D (first 4 chars "20P9" match real serials)
+    # A2 Series
+    "N9": "26A19A",  # A2L (first 5 chars "26A19" match real serials)
     # P Series
     "C11": "01S00A",  # P1P
     "C12": "01P00A",  # P1S
@@ -584,44 +588,21 @@ class VirtualPrinterInstance:
                     target_model = None
                     if not self.target_printer_id and self.model:
                         target_model = VIRTUAL_PRINTER_MODELS.get(self.model)
-                    plate_id = self._extract_plate_id(file_path)
-
-                    # Parse the 3MF for per-slot filament requirements (#1188).
-                    # The manual /print-queue/ POST flow does this at queue-add
-                    # time; the VP path used to skip it, so the scheduler fell
-                    # through to model-only matching and dispatched onto whatever
-                    # printer happened to be free regardless of loaded colour.
-                    # required_filament_types is populated unconditionally — it's
-                    # cheap, lets the scheduler reject obvious mis-matches even
-                    # without force_color_match. filament_overrides only carries
-                    # force_color_match=True when the per-VP setting is on, so
-                    # upgraders keep the old behaviour by default.
-                    required_filament_types_json: str | None = None
-                    filament_overrides_json: str | None = None
-                    requirements = extract_filament_requirements(file_path, plate_id)
-                    if requirements:
-                        types = sorted({r["type"] for r in requirements if r.get("type")})
-                        if types:
-                            required_filament_types_json = json.dumps(types)
-                        if self.queue_force_color_match:
-                            overrides = [
-                                {
-                                    "slot_id": r["slot_id"],
-                                    "type": r.get("type", ""),
-                                    "color": r.get("color", ""),
-                                    "force_color_match": True,
-                                }
-                                for r in requirements
-                                if r.get("type") and r.get("color")
-                            ]
-                            if overrides:
-                                filament_overrides_json = json.dumps(overrides)
-
-                    # Pick the next free position the same way the manual
-                    # /print-queue/ POST does — previously hardcoded to 1,
-                    # which created duplicate position=1 rows on every
-                    # VP upload and made queue execution order
-                    # non-deterministic for any non-empty queue.
+                    # #1733: multi-plate "Send All" uploads ship every plate in
+                    # one 3MF — `slice_info.config` lists each `<plate>` with
+                    # its own index. Enqueue one PrintQueueItem per plate so
+                    # the scheduler runs each separately. Single-plate "Send"
+                    # comes through as `[N]` (one plate index) so the loop
+                    # below runs once and the existing behaviour is preserved.
+                    plate_ids = self._extract_plate_ids(file_path)
+
+                    # Pick a base position the same way the manual /print-queue/
+                    # POST does, then hand consecutive positions to each plate
+                    # so a Send All keeps plate-order execution inside the
+                    # queue (#1733). Previously hardcoded to 1, which created
+                    # duplicate position=1 rows on every VP upload and made
+                    # queue execution order non-deterministic for any non-
+                    # empty queue.
                     from sqlalchemy import func, select as _sql_select
 
                     queue_scope = _sql_select(func.max(PrintQueueItem.position)).where(
@@ -636,27 +617,72 @@ class VirtualPrinterInstance:
                         max_pos = int(max_pos_raw) if max_pos_raw is not None else 0
                     except (TypeError, ValueError):
                         max_pos = 0
-                    next_position = max_pos + 1
-
-                    queue_item = PrintQueueItem(
-                        printer_id=self.target_printer_id,
-                        target_model=target_model,
-                        archive_id=archive.id,
-                        plate_id=plate_id,
-                        position=next_position,
-                        status="pending",
-                        manual_start=not self.auto_dispatch,
-                        required_filament_types=required_filament_types_json,
-                        filament_overrides=filament_overrides_json,
-                        bed_levelling=bed_levelling,
-                        flow_cali=flow_cali,
-                        vibration_cali=vibration_cali,
-                        layer_inspect=layer_inspect,
-                        timelapse=timelapse,
-                    )
-                    db.add(queue_item)
+
+                    # Parse per-plate filament requirements (#1188). Each plate
+                    # has its own filament set in `slice_info.config`, so the
+                    # `required_filament_types` / `filament_overrides` columns
+                    # on each queue item reflect THAT plate, not the file's
+                    # first plate. Scoping was already plate-aware via #1697 —
+                    # the `extract_filament_requirements(path, plate_id)` filter
+                    # returns just the plate's filaments. required_filament_types
+                    # is populated unconditionally — it's cheap, lets the
+                    # scheduler reject obvious mis-matches even without
+                    # force_color_match. filament_overrides only carries
+                    # force_color_match=True when the per-VP setting is on, so
+                    # upgraders keep the old behaviour by default.
+                    queue_item_ids: list[int] = []
+                    for offset, plate_id in enumerate(plate_ids, start=1):
+                        required_filament_types_json: str | None = None
+                        filament_overrides_json: str | None = None
+                        requirements = extract_filament_requirements(file_path, plate_id)
+                        if requirements:
+                            types = sorted({r["type"] for r in requirements if r.get("type")})
+                            if types:
+                                required_filament_types_json = json.dumps(types)
+                            if self.queue_force_color_match:
+                                overrides = [
+                                    {
+                                        "slot_id": r["slot_id"],
+                                        "type": r.get("type", ""),
+                                        "color": r.get("color", ""),
+                                        "force_color_match": True,
+                                    }
+                                    for r in requirements
+                                    if r.get("type") and r.get("color")
+                                ]
+                                if overrides:
+                                    filament_overrides_json = json.dumps(overrides)
+
+                        queue_item = PrintQueueItem(
+                            printer_id=self.target_printer_id,
+                            target_model=target_model,
+                            archive_id=archive.id,
+                            plate_id=plate_id,
+                            position=max_pos + offset,
+                            status="pending",
+                            manual_start=not self.auto_dispatch,
+                            required_filament_types=required_filament_types_json,
+                            filament_overrides=filament_overrides_json,
+                            bed_levelling=bed_levelling,
+                            flow_cali=flow_cali,
+                            vibration_cali=vibration_cali,
+                            layer_inspect=layer_inspect,
+                            timelapse=timelapse,
+                        )
+                        db.add(queue_item)
+                        await db.flush()  # populate queue_item.id before logging
+                        queue_item_ids.append(queue_item.id)
                     await db.commit()
-                    logger.info("[VP %s] Added to queue: %s", self.name, queue_item.id)
+                    if len(queue_item_ids) == 1:
+                        logger.info("[VP %s] Added to queue: %s", self.name, queue_item_ids[0])
+                    else:
+                        logger.info(
+                            "[VP %s] Added %d queue items for multi-plate upload (plates %s): %s",
+                            self.name,
+                            len(queue_item_ids),
+                            plate_ids,
+                            queue_item_ids,
+                        )
                     await self._broadcast_archive_created(archive)
                 else:
                     logger.error("Failed to archive file: %s", file_path.name)
@@ -696,8 +722,27 @@ class VirtualPrinterInstance:
             logger.debug("[VP %s] archive_created broadcast failed: %s", self.name, e)
 
     @staticmethod
-    def _extract_plate_id(file_path: Path) -> int | None:
-        """Extract plate index from 3MF slice_info.config."""
+    def _extract_plate_ids(file_path: Path) -> list[int]:
+        """Extract every plate index from a 3MF's slice_info.config.
+
+        A multi-plate "Send All" from BambuStudio / OrcaSlicer uploads a
+        single 3MF containing every plate the user selected. Each plate
+        has its own ``<plate>`` block with a ``<metadata key="index"
+        value="N"/>`` child and its own ``Metadata/plate_N.gcode`` payload
+        inside the same zip. Returning the full ordered list lets the VP
+        queue path create one queue item per plate (`_add_to_print_queue`
+        loops over the result), so "Send All" of a 3-plate file produces
+        3 queue items sharing the same archive — one per plate to print.
+
+        Single-plate "Send" hits the same code path and returns ``[N]``
+        for whichever plate the user selected; the loop runs once and the
+        existing single-plate behaviour is preserved.
+
+        Returns ``[1]`` when the 3MF is missing ``slice_info.config``,
+        unparseable, or contains no plate-index metadata — the original
+        single-plate fallback. Production logs at debug so a non-3MF
+        upload doesn't spam, but the trail survives for support bundles.
+        """
         try:
             import xml.etree.ElementTree as ET
             import zipfile
@@ -706,19 +751,20 @@ class VirtualPrinterInstance:
                 if "Metadata/slice_info.config" in zf.namelist():
                     content = zf.read("Metadata/slice_info.config").decode()
                     root = ET.fromstring(content)  # noqa: S314  # nosec B314
-                    plate = root.find(".//plate")
-                    if plate is not None:
+                    plate_ids: list[int] = []
+                    for plate in root.findall(".//plate"):
                         for meta in plate.findall("metadata"):
                             if meta.get("key") == "index" and meta.get("value"):
-                                return int(meta.get("value"))
+                                try:
+                                    plate_ids.append(int(meta.get("value")))
+                                except ValueError:
+                                    continue
+                                break
+                    if plate_ids:
+                        return plate_ids
         except Exception as e:
-            # Malformed / missing slice_info.config — fall through to None.
-            # Logged at debug so a non-3MF or unconventional 3MF doesn't
-            # spam production logs; a debug trail exists for support
-            # bundles when wrong-plate dispatches are reported.
-            logger.debug("[VP] _extract_plate_id failed for %s: %s", file_path.name, e)
-            return None
-        return None
+            logger.debug("[VP] _extract_plate_ids failed for %s: %s", file_path.name, e)
+        return [1]
 
     # -- Service lifecycle --
 

+ 117 - 74
backend/app/services/virtual_printer/mqtt_bridge.py

@@ -41,6 +41,9 @@ import logging
 import socket
 from typing import TYPE_CHECKING
 
+from backend.app.services.bambu_mqtt import apply_tray_exist_bits
+from backend.app.services.virtual_printer._debug import append_event, dump_wire
+
 if TYPE_CHECKING:
     from backend.app.services.bambu_mqtt import BambuMQTTClient
     from backend.app.services.printer_manager import PrinterManager
@@ -50,38 +53,18 @@ logger = logging.getLogger(__name__)
 
 REFRESH_INTERVAL_SECONDS = 30.0
 
-# Top-level push_status fields that Bambu firmware sends in FULL pushall
-# responses (on `pushall` request / printer reconnect) but typically OMITS
-# from 1 Hz incremental push_status updates. Without preserving these
-# fields across incremental updates, the bridge cache would lose AMS info
-# (and friends) between pushalls — slicers reading the cache would see a
-# stripped-down state and the fix would only re-appear on a manual printer
-# power-cycle (#1371). Mirrors the same set Bambuddy itself preserves in
-# bambu_mqtt.py:2686-2711 for its own internal raw_data, with a few more
-# entries that the slicer cares about (net, ipcam, lights_report).
-_SLICER_VISIBLE_STICKY_KEYS: tuple[str, ...] = (
-    "ams",
-    "vt_tray",
-    "ams_extruder_map",
-    "mapping",
-    "net",
-    "ipcam",
-    "lights_report",
-    # Pre-flight / Prepare-tab fields that BambuStudio reads off cached
-    # push_status. Bambu firmware emits them in full pushall but typically
-    # OMITS them from 1 Hz incremental updates, so without sticky-preservation
-    # the cache drops them after the very next tick and the slicer's
-    # "block Send while busy / unknown firmware" branch kicks in. Same shape
-    # as #1228 (storage indicators) and #1558 (live-progress fields) —
-    # cached-branch field-shape parity, not a new mechanism.
-    "upgrade_state",  # Send pre-flight reads dis_state / force_upgrade
-    "xcam",  # Prepare-tab reads spaghetti / first-layer / halt sensitivity
-    "hw_switch_state",  # Hardware switch state (Prepare tab)
-    "nozzle_diameter",
-    "nozzle_type",
-    "online",  # Module online map (ahb / rfid / version)
-    "ams_status",  # AMS overall status; can be ams_status-only incremental
-)
+# Bambuddy's internal printer state in bambu_mqtt.py (around line 2686+) is
+# updated per-field — each `if "X" in data: self.state.X = ...` block leaves
+# every other field untouched, so the state accumulates everything the
+# printer has ever sent. The bridge cache below mirrors that pattern: when
+# the incoming push_status omits a field, the previous value is preserved
+# verbatim; only fields actually present in the new push overwrite. This
+# stops capability/lifecycle fields (cali_version, print_type, mc_print_stage,
+# device, ...) draining out of the cache between pushalls, which surfaced
+# as #1622 (BambuStudio's Device-tab UIs greying out on P1S after the
+# cache drained to a thin incremental snapshot). The `ams` field still
+# gets unit-/tray-level deep merge via `_merge_ams_dict` because firmware
+# sends partial `ams` blobs under the same key (#1387).
 
 
 def _ip_to_uint32_le(ip_str: str) -> int:
@@ -389,18 +372,38 @@ class MQTTBridge:
         # but that fires before the bridge attaches as a raw-message consumer,
         # so without this nudge the cache stays empty until the next periodic
         # query (which can be minutes away).
-        request_fn = getattr(current, "_request_version", None)
-        if callable(request_fn):
-            try:
-                request_fn()
-            except Exception:
-                logger.exception("[%s] MQTT bridge: _request_version failed", self.vp_name)
-        request_status_fn = getattr(current, "request_status_update", None)
-        if callable(request_status_fn):
-            try:
-                request_status_fn()
-            except Exception:
-                logger.exception("[%s] MQTT bridge: request_status_update failed", self.vp_name)
+        #
+        # The bind frequently races the real printer's MQTT TLS handshake — a
+        # slicer-side reconnect re-resolves the client before the underlying
+        # session has reconnected, especially on A1 firmware where the bridge
+        # cycles more aggressively (#1721). When that happens, the nudge is a
+        # no-op — the next periodic pushall populates the cache anyway — but
+        # `request_status_update` logs WARNING on the not-connected return path
+        # and pollutes every support bundle with a benign line.
+        #
+        # Gate both nudges on the client being actually connected. The fall-
+        # through path is unchanged: when the client comes up, the next
+        # `_resolve_client` tick re-enters this branch on identity change OR
+        # the periodic pushall in `bambu_mqtt.py` fills the cache.
+        client_connected = bool(getattr(getattr(current, "state", None), "connected", False))
+        if not client_connected:
+            logger.debug(
+                "[%s] MQTT bridge: post-bind nudge skipped (printer client not connected yet)",
+                self.vp_name,
+            )
+        else:
+            request_fn = getattr(current, "_request_version", None)
+            if callable(request_fn):
+                try:
+                    request_fn()
+                except Exception:
+                    logger.exception("[%s] MQTT bridge: _request_version failed", self.vp_name)
+            request_status_fn = getattr(current, "request_status_update", None)
+            if callable(request_status_fn):
+                try:
+                    request_status_fn()
+                except Exception:
+                    logger.exception("[%s] MQTT bridge: request_status_update failed", self.vp_name)
 
     def _unbind_client(self) -> None:
         if self._target_client is None:
@@ -600,40 +603,73 @@ class MQTTBridge:
             new_state = copy.deepcopy(print_data)
             # Bambu firmware sends two kinds of push_status: full pushall
             # responses (on `pushall` requests / printer reconnect) which
-            # include AMS, vt_tray, net, etc. — and ~1 Hz incremental
-            # updates with just the fields that changed (typically temps,
-            # fan, wifi). Without preserving sticky fields from the previous
-            # cache, the first incremental push after a pushall would wipe
-            # AMS info from the bridge cache, and slicers reading the cache
-            # between pushalls would see a stripped-down printer state with
-            # no AMS visible until the next pushall — typically only when
-            # the user power-cycles the printer (#1371). Mirror the same
-            # preservation pattern Bambuddy uses for its own internal state
-            # in bambu_mqtt.py (see _SLICER_VISIBLE_STICKY_KEYS below).
+            # include the full top-level field set (AMS, vt_tray, net,
+            # cali_version, print_type, mc_print_stage, device, ...) — and
+            # ~1 Hz incrementals with just the fields that changed (temps,
+            # fan, wifi, status). Carry over every prev field the incoming
+            # push doesn't overwrite, mirroring the per-field accumulate
+            # pattern in bambu_mqtt.py's internal state handler — without
+            # this the cache thins out to whatever the latest incremental
+            # carried (~17 keys on P1S in #1622), and the slicer's Device-
+            # tab capability gates (manage-calibration, AMS-assign dropdown,
+            # …) flip off because their gating fields drained from the
+            # cache. The deep-copy is defensive: without it the carried-
+            # over nested dicts/lists are shared with the previous cache,
+            # so any in-place mutation later would corrupt both.
             prev = self._latest_print_state
             if prev is not None:
-                for sticky_key in _SLICER_VISIBLE_STICKY_KEYS:
-                    if sticky_key not in new_state:
-                        if sticky_key in prev:
-                            # Defensive deep copy — without this the carried-over
-                            # nested dicts/lists are shared between new_state and
-                            # the previous cache, so any in-place mutation later
-                            # (current or future code paths) would corrupt both.
-                            new_state[sticky_key] = copy.deepcopy(prev[sticky_key])
+                for prev_key, prev_value in prev.items():
+                    if prev_key not in new_state:
+                        new_state[prev_key] = copy.deepcopy(prev_value)
+                # Firmware sends partial `ams` blobs (status-only / unit-
+                # targeted / tray-targeted) under the same key on
+                # incremental updates, which would overwrite the cached
+                # full blob and break the slicer's AMS render (#1387 /
+                # #1371). Deep-merge mirrors what bambu_mqtt.py does
+                # internally in `_handle_ams_data`.
+                if isinstance(new_state.get("ams"), dict) and isinstance(prev.get("ams"), dict):
+                    new_state["ams"] = _merge_ams_dict(prev["ams"], new_state["ams"])
+                # Same per-field accumulate rule applied one level deeper for
+                # other top-level dict-shaped fields. Firmware sends partial
+                # `vt_tray` (external spool) updates right after a slicer
+                # `ams_filament_setting` pick — typically just `{tray_info_idx,
+                # tray_color}`, dropping the ~18 other fields (`tray_type`,
+                # `state`, `remain`, `k`, `n`, `cali_idx`, `nozzle_temp_min/max`,
+                # `tray_uuid`, `xcam_info`, ...) the slicer needs to render the
+                # slot. Without overlay the next 1 Hz cached-as-base push
+                # delivered the stripped dict and the slicer rendered the
+                # external slot as "invalid" until a reload triggered a fresh
+                # pushall (#1622 round 5, reported by @shaddowlink). AMS slots
+                # didn't suffer because `_merge_ams_dict` deep-merges per tray.
+                # Same shape covers `device`, `online`, `upgrade_state`, `ipcam`,
+                # `upload`, `net`, ... against future firmware partials too.
+                # `ams` is excluded — already deep-merged above.
+                for key, new_value in list(new_state.items()):
+                    if key == "ams":
                         continue
-                    # Key IS in new_state — but firmware sends partial blobs
-                    # (status-only / tray-targeted) under the same key on
-                    # incremental updates, which would overwrite the cached
-                    # full blob and break the slicer's AMS render (#1387).
-                    # For `ams` specifically the deep-merge mirrors what
-                    # Bambuddy already does internally in `_handle_ams_data`.
-                    if (
-                        sticky_key == "ams"
-                        and isinstance(new_state.get("ams"), dict)
-                        and isinstance(prev.get("ams"), dict)
-                    ):
-                        new_state["ams"] = _merge_ams_dict(prev["ams"], new_state["ams"])
+                    prev_value = prev.get(key)
+                    if isinstance(prev_value, dict) and isinstance(new_value, dict):
+                        merged = dict(prev_value)
+                        merged.update(new_value)
+                        new_state[key] = merged
+            # Apply empty-slot cleanup on the merged AMS so the slicer-facing
+            # cache mirrors what Bambuddy's AMS card shows internally. Without
+            # this the cached units carry stale per-tray filament fields for
+            # slots whose `tray_exist_bits` bit is 0, and BambuStudio's Sync
+            # paints those empty slots as phantom loaded filaments (#1726).
+            # Runs whether or not a prev cache existed — fresh pushalls also
+            # carry tray_exist_bits and benefit from the cleanup.
+            merged_ams_dict = new_state.get("ams")
+            if isinstance(merged_ams_dict, dict):
+                units = merged_ams_dict.get("ams")
+                apply_tray_exist_bits(
+                    units if isinstance(units, list) else [],
+                    merged_ams_dict.get("tray_exist_bits"),
+                    power_on_flag=merged_ams_dict.get("power_on_flag", True),
+                    log_label=self.vp_name,
+                )
             self._latest_print_state = new_state
+            dump_wire(self.vp_name, "in", new_state)
             return
 
         # info.get_version responses → cache the module list so the synthetic
@@ -667,6 +703,13 @@ class MQTTBridge:
         if target_bytes in payload:
             payload = payload.replace(target_bytes, self.vp_serial.encode("ascii"))
         vp_topic = f"device/{self.vp_serial}/{suffix}"
+        # Env-flagged command trace (#1622): every printer-originated response
+        # that gets fanned to the slicer (extrusion_cali_get / ams write acks /
+        # xcam / system / etc.) gets a line in vp_wire/<vp>_cmd.jsonl. Pair
+        # with the slicer-side publishes captured in mqtt_server. Off by
+        # default. Capture AFTER serial rewrite so the dump matches what the
+        # slicer actually sees on the wire.
+        append_event(self.vp_name, "printer_to_slicer", vp_topic, payload)
         try:
             asyncio.run_coroutine_threadsafe(
                 self._mqtt_server.push_raw_to_clients(vp_topic, payload),

+ 41 - 5
backend/app/services/virtual_printer/mqtt_server.py

@@ -15,6 +15,8 @@ from collections.abc import Callable
 from pathlib import Path
 from typing import TYPE_CHECKING
 
+from backend.app.services.virtual_printer._debug import append_event, dump_wire
+
 if TYPE_CHECKING:
     from backend.app.services.virtual_printer.mqtt_bridge import MQTTBridge
 
@@ -45,6 +47,7 @@ MODEL_PRODUCT_NAMES = {
     "BL-P002": "X1",
     "C13": "X1E",
     "N6": "X2D",
+    "N9": "A2L",
     "C11": "P1P",
     "C12": "P1S",
     "N7": "P2S",
@@ -429,7 +432,11 @@ class SimpleMQTTServer:
                             disconnected.append(client_id)
                             continue
                         serial = self._client_serials.get(client_id, self.serial)
-                        await self._send_status_report(writer, serial=serial)
+                        # log_event=False: the 1Hz cached push is already
+                        # captured by ``dump_wire`` snapshot mode (see
+                        # _debug.py); appending it to the cmd.jsonl would
+                        # flood the file ~60 lines/min per VP.
+                        await self._send_status_report(writer, serial=serial, log_event=False)
                         push_counts[client_id] = push_counts.get(client_id, 0) + 1
                     except OSError as e:
                         logger.debug("Failed to push status to %s: %s", client_id, e)
@@ -843,7 +850,9 @@ class SimpleMQTTServer:
         except (IndexError, ValueError, OSError) as e:
             logger.debug("MQTT SUBSCRIBE error: %s", e)
 
-    async def _send_status_report(self, writer: asyncio.StreamWriter, serial: str | None = None) -> None:
+    async def _send_status_report(
+        self, writer: asyncio.StreamWriter, serial: str | None = None, log_event: bool = True
+    ) -> None:
         """Send a status report to the slicer after connection.
 
         When a bridge is active and has cached the real printer's latest
@@ -911,7 +920,8 @@ class SimpleMQTTServer:
                 print_block["total_layer_num"] = 0
                 print_block["print_error"] = 0
                 status = {"print": print_block}
-                await self._publish_to_report(writer, status, serial or self.serial)
+                dump_wire(self.vp_name, "out", status)
+                await self._publish_to_report(writer, status, serial or self.serial, log_event=log_event)
                 return
 
             # No bridge / no cache yet — fall back to the synthetic stub.
@@ -988,7 +998,7 @@ class SimpleMQTTServer:
                 }
             }
 
-            await self._publish_to_report(writer, status, serial or self.serial)
+            await self._publish_to_report(writer, status, serial or self.serial, log_event=log_event)
 
         except OSError as e:
             logger.error("Failed to send status report: %s", e)
@@ -1084,13 +1094,24 @@ class SimpleMQTTServer:
         self._current_file = filename
         self._prepare_percent = prepare_percent
 
-    async def _publish_to_report(self, writer: asyncio.StreamWriter, payload: dict, serial: str = "") -> None:
+    async def _publish_to_report(
+        self, writer: asyncio.StreamWriter, payload: dict, serial: str = "", log_event: bool = True
+    ) -> None:
         """Publish a message on the device report topic.
 
         Real Bambu printers wire-format push_status JSON with 4-space indentation
         (32254 bytes for an idle H2D push vs 14268 bytes compact). BambuStudio's
         Send pre-flight rejects compact JSON — without matching the on-wire
         format the slicer never proceeds to FTP upload.
+
+        ``log_event=True`` records the publish in ``vp_wire/<vp>_cmd.jsonl``
+        under the ``bridge_to_slicer`` direction so #1622-style triages can
+        diff the bridge's own outbound replies (info.get_version answer,
+        project_file ack, on-demand pushall response) against the real
+        printer's ``printer_to_slicer`` forwards. The 1Hz periodic push
+        sets ``log_event=False`` because dump_wire's overwrite-snapshot
+        already covers cache shape and a per-second JSONL line would dwarf
+        the actual command events.
         """
         topic = f"device/{serial or self.serial}/report"
         message = json.dumps(payload, indent=4)
@@ -1112,6 +1133,16 @@ class SimpleMQTTServer:
         packet += topic_bytes
         packet += message_bytes
 
+        if log_event:
+            # Env-flagged command trace (#1622): captures bridge-synthesised
+            # replies (info.get_version, project_file ack, on-demand pushall
+            # response) AFTER the payload is finalised but before it hits
+            # the wire — so the cmd.jsonl reflects exactly what the slicer
+            # parses. Pair with the slicer_to_bridge events from
+            # _handle_publish and the printer_to_slicer fan-outs from
+            # mqtt_bridge.
+            append_event(self.vp_name, "bridge_to_slicer", topic, payload)
+
         writer.write(packet)
         # Timeout the drain to prevent blocking the event loop if the
         # MQTT client stops reading (e.g. slicer busy with FTP upload).
@@ -1208,6 +1239,11 @@ class SimpleMQTTServer:
                 )
                 return
 
+            # Env-flagged command trace (#1622): every slicer-originated publish
+            # gets a line in vp_wire/<vp>_cmd.jsonl alongside the printer-side
+            # responses captured in mqtt_bridge. Off by default.
+            append_event(self.vp_name, "slicer_to_bridge", topic, data)
+
             # The synthetic flow below is the original (pre-bridge) behaviour and is
             # what the proven-working FTP "Send" depends on. Do NOT replace any
             # synthetic response with a forward — only ADD forwarding alongside,

+ 45 - 4
backend/app/utils/printer_models.py

@@ -23,6 +23,7 @@ PRINTER_MODEL_MAP = {
     "Bambu Lab H2C": "H2C",
     "Bambu Lab H2S": "H2S",
     "Bambu Lab X2D": "X2D",
+    "Bambu Lab A2L": "A2L",
 }
 
 # Map from printer_model_id (internal codes in slice_info.config) to short names
@@ -39,11 +40,13 @@ PRINTER_MODEL_ID_MAP = {
     "P2S": "P2S",
     # X2 series
     "N6": "X2D",
+    # A2 series (A2L is single-FDM + integrated cutter/plotter — single nozzle)
+    "N9": "A2L",
     # A1 series
     "A11": "A1",
     "A12": "A1 Mini",
-    "N1": "A1",
-    "N2S": "A1 Mini",
+    "N1": "A1 Mini",
+    "N2S": "A1",
     "A04": "A1 Mini",
     # H2 series (Office/H series)
     "O1D": "H2D",
@@ -91,13 +94,15 @@ LINEAR_RAIL_MODELS = frozenset(
         # Display names (uppercase, no spaces)
         "A1",
         "A1MINI",
+        "A2L",
         "H2D",
         "H2DPRO",
         "H2C",
         "H2S",
         # Internal codes
-        "N1",  # A1
-        "N2S",  # A1 Mini
+        "N1",  # A1 Mini
+        "N2S",  # A1
+        "N9",  # A2L
         "A04",  # A1 Mini (alternate)
         "A11",  # A1
         "A12",  # A1 Mini
@@ -111,6 +116,27 @@ LINEAR_RAIL_MODELS = frozenset(
 )
 
 
+# Models without any external storage (MicroSD / SD card slot).
+# The A1 and A1 Mini ship with internal storage only — there is no
+# firmware-side "Store sent files on external storage" toggle and no
+# slicer-side equivalent surfaces one. The connection diagnostic's
+# external_storage check (printer_diagnostic.py) must skip on these
+# models instead of reporting fail from a 0-valued home_flag bit.
+NO_EXTERNAL_STORAGE_MODELS = frozenset(
+    [
+        # Display names (uppercase, no spaces)
+        "A1",
+        "A1MINI",
+        # Internal codes
+        "N1",  # A1 Mini
+        "N2S",  # A1
+        "A04",  # A1 Mini (alternate)
+        "A11",  # A1
+        "A12",  # A1 Mini
+    ]
+)
+
+
 # Models with an ethernet port.
 # X1, P1P, A1, A1 Mini do NOT have ethernet.
 ETHERNET_MODELS = frozenset(
@@ -173,6 +199,21 @@ def has_ethernet(model: str | None) -> bool:
     return normalized in ETHERNET_MODELS
 
 
+def has_external_storage(model: str | None) -> bool:
+    """Return True if the printer model can have a MicroSD / external storage slot.
+
+    Defaults to True when the model is unknown — the diagnostic only flips
+    its check on for the explicit no-storage list. New models added to the
+    Bambu lineup without a slot must be added to ``NO_EXTERNAL_STORAGE_MODELS``
+    or the diagnostic will continue to evaluate ``store_to_sdcard`` against
+    a hardware feature the printer doesn't have.
+    """
+    if not model:
+        return True
+    normalized = model.strip().upper().replace(" ", "").replace("-", "")
+    return normalized not in NO_EXTERNAL_STORAGE_MODELS
+
+
 def is_dual_nozzle_model(model: str | None) -> bool:
     """Return True if the printer model has two nozzles (H2D family / X2D)."""
     if not model:

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

@@ -294,6 +294,64 @@ class TestArchivesAPI:
 
         assert response.status_code == 404
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_archive_blocked_when_related_queue_item_printing(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """#1734: archive delete must 409 when a related queue item is currently
+        mid-print — deleting the archive would strip the dispatcher's metadata
+        trail (filament / plate / ams_mapping) out from under the running print.
+        Both soft and hard delete are gated by the same precondition.
+        """
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id)
+        db_session.add(PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="printing", position=1))
+        await db_session.commit()
+
+        soft = await async_client.delete(f"/api/v1/archives/{archive.id}")
+        assert soft.status_code == 409
+        assert "printing" in soft.json()["detail"].lower()
+
+        hard = await async_client.delete(f"/api/v1/archives/{archive.id}?purge_stats=true")
+        assert hard.status_code == 409
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_archive_delete_impact_reports_counts(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """#1734: the delete-impact pre-flight endpoint reports the total
+        number of related queue items AND how many are currently printing,
+        so the frontend can both warn the user before they confirm AND
+        disable the confirm button when the printing count is non-zero.
+        """
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id)
+        # Build a mixed-status set the way a Send All upload + later in-flight
+        # dispatch looks at the wire (#1733).
+        db_session.add_all(
+            [
+                PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="pending", position=1),
+                PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="pending", position=2),
+                PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="printing", position=3),
+            ]
+        )
+        # An unrelated archive's queue rows must not bleed into the count.
+        other = await archive_factory(printer.id)
+        db_session.add(PrintQueueItem(printer_id=printer.id, archive_id=other.id, status="pending", position=4))
+        await db_session.commit()
+
+        resp = await async_client.get(f"/api/v1/archives/{archive.id}/delete-impact")
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["related_queue_items"] == 3
+        assert body["currently_printing"] == 1
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_soft_delete_preserves_stats_contribution(

+ 127 - 0
backend/tests/integration/test_color_catalog_extras.py

@@ -157,3 +157,130 @@ async def test_create_spool_with_color_extras(async_client: AsyncClient):
     assert patch.status_code == 200
     assert patch.json()["extra_colors"] is None
     assert patch.json()["effect_type"] is None
+
+
+# ---- /colors/by-material — disambiguated lookup (#1718) -------------------
+
+
+async def _seed_black_collision(client: AsyncClient) -> None:
+    """Seed the #000000 ambiguity the endpoint was built to resolve.
+
+    PLA Matte → Charcoal, PLA Basic → Black, both at #000000 — same shape as
+    Bambu's production catalog.
+    """
+    for entry in (
+        {
+            "manufacturer": "Bambu Lab",
+            "color_name": "Charcoal",
+            "hex_color": "#000000",
+            "material": "PLA Matte",
+        },
+        {
+            "manufacturer": "Bambu Lab",
+            "color_name": "Black",
+            "hex_color": "#000000",
+            "material": "PLA Basic",
+        },
+    ):
+        response = await client.post("/api/v1/inventory/colors", json=entry)
+        assert response.status_code == 200, response.text
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_by_material_returns_material_specific_name(async_client: AsyncClient):
+    """Same hex + different material → returns the correctly-paired name."""
+    await _seed_black_collision(async_client)
+
+    matte = await async_client.get(
+        "/api/v1/inventory/colors/by-material", params={"hex": "#000000", "material": "PLA Matte"}
+    )
+    assert matte.status_code == 200, matte.text
+    assert matte.json() == {"color_name": "Charcoal"}
+
+    basic = await async_client.get(
+        "/api/v1/inventory/colors/by-material", params={"hex": "#000000", "material": "PLA Basic"}
+    )
+    assert basic.status_code == 200, basic.text
+    assert basic.json() == {"color_name": "Black"}
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_by_material_falls_back_to_first_when_material_unknown(async_client: AsyncClient):
+    """Unknown / unsupplied material → priority-order fallback, same as
+    ``/colors/map`` so existing flat-map callers don't regress."""
+    await _seed_black_collision(async_client)
+
+    # Unknown material → first Bambu Lab entry wins (matches /map's priority).
+    unknown = await async_client.get(
+        "/api/v1/inventory/colors/by-material", params={"hex": "#000000", "material": "PLA-Nope"}
+    )
+    assert unknown.status_code == 200, unknown.text
+    assert unknown.json()["color_name"] in {"Charcoal", "Black"}
+
+    # No material at all → same fallback.
+    nomat = await async_client.get("/api/v1/inventory/colors/by-material", params={"hex": "#000000"})
+    assert nomat.status_code == 200, nomat.text
+    assert nomat.json()["color_name"] in {"Charcoal", "Black"}
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_by_material_returns_null_when_hex_missing(async_client: AsyncClient):
+    """Hex not present in the catalog → color_name=None (do NOT 404)."""
+    response = await async_client.get(
+        "/api/v1/inventory/colors/by-material",
+        params={"hex": "#abcdef", "material": "PLA Matte"},
+    )
+    assert response.status_code == 200, response.text
+    assert response.json() == {"color_name": None}
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_by_material_case_insensitive_on_both_inputs(async_client: AsyncClient):
+    """Lookup must tolerate mixed-case hex (legacy imports stored ``#B39B84``-
+    style upper-case) and material (frontend derives material from sub-brand
+    names whose casing isn't pinned). The endpoint uses ``func.lower`` on
+    ``hex_color`` and lower-cases ``material`` before equality, so both
+    directions of the case mismatch must round-trip."""
+    # Seed an upper-case stored hex to exercise the lower-cased comparison.
+    seed = await async_client.post(
+        "/api/v1/inventory/colors",
+        json={
+            "manufacturer": "Bambu Lab",
+            "color_name": "Iridium Gold Metallic",
+            "hex_color": "#B39B84",  # stored upper-case
+            "material": "PLA Metal",
+        },
+    )
+    assert seed.status_code == 200, seed.text
+
+    # Query the upper-case hex with lower-case input — must still match.
+    lower_query = await async_client.get(
+        "/api/v1/inventory/colors/by-material",
+        params={"hex": "#b39b84", "material": "PLA Metal"},
+    )
+    assert lower_query.status_code == 200
+    assert lower_query.json() == {"color_name": "Iridium Gold Metallic"}
+
+    # Material is matched case-insensitively too.
+    await _seed_black_collision(async_client)
+    mixed_mat = await async_client.get(
+        "/api/v1/inventory/colors/by-material",
+        params={"hex": "#000000", "material": "pla matte"},
+    )
+    assert mixed_mat.json() == {"color_name": "Charcoal"}
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_by_material_rejects_short_hex(async_client: AsyncClient):
+    """Invalid hex (< 6 chars after stripping '#') → color_name=None, no crash."""
+    response = await async_client.get(
+        "/api/v1/inventory/colors/by-material",
+        params={"hex": "#abc", "material": "PLA Matte"},
+    )
+    assert response.status_code == 200
+    assert response.json() == {"color_name": None}

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

@@ -1274,6 +1274,40 @@ class TestPrintFileUploadValidation:
         assert response.status_code == 200
         assert b"G28" in response.content
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_library_get_gcode_recovers_legacy_gcode_type_for_3mf(self, async_client: AsyncClient, db_session):
+        """#1709 regression guard. Before the fix, ``slice_and_persist``
+        wrote a `.gcode.3mf` ZIP container to disk but stored the row with
+        ``file_type='gcode'`` — the preview endpoint then streamed the
+        ZIP body as ``text/plain`` and the embedded G-code viewer saw
+        ``PK\\x03\\x04...`` instead of the toolpath. New sliced rows now
+        store ``file_type='gcode.3mf'``; rows already written under the
+        bug self-heal because the endpoint also detects the ZIP via the
+        ``.gcode.3mf`` filename suffix when the column is still legacy."""
+        from backend.app.models.library import LibraryFile
+
+        with tempfile.NamedTemporaryFile(suffix=".gcode.3mf", delete=False) as tmp:
+            tmp.write(self._valid_3mf_bytes(name="Metadata/plate_1.gcode"))
+            tmp_path = tmp.name
+
+        lib_file = LibraryFile(
+            filename="legacy-sliced.gcode.3mf",
+            file_path=tmp_path,
+            file_type="gcode",
+            file_size=Path(tmp_path).stat().st_size,
+        )
+        db_session.add(lib_file)
+        await db_session.commit()
+        await db_session.refresh(lib_file)
+
+        response = await async_client.get(f"/api/v1/library/files/{lib_file.id}/gcode")
+        assert response.status_code == 200
+        assert response.headers["content-type"].startswith("text/plain")
+        assert b"G28" in response.content
+        # The whole point of #1709: must NOT be ZIP bytes shoved at the viewer.
+        assert not response.content.startswith(b"PK")
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_library_still_accepts_non_print_extensions(self, async_client: AsyncClient, db_session):

+ 14 - 258
backend/tests/integration/test_library_slice_api.py

@@ -535,234 +535,6 @@ class TestSliceLibraryFile:
         assert "3D/3dmodel.model" in names
 
 
-class TestSliceWithBundle:
-    """Bundle dispatch path: when SliceRequest.bundle is set, the dispatch
-    forwards bundle id + per-category preset names to the sidecar instead
-    of resolving cloud/local/standard PresetRefs. Same fallback semantics
-    apply for 3MF inputs whose CLI run fails."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_bundle_dispatch_forwards_form_fields(self, async_client: AsyncClient, slice_test_setup):
-        captured: dict = {}
-
-        def handler(request: httpx.Request) -> httpx.Response:
-            captured["body"] = request.content
-            return httpx.Response(
-                status_code=200,
-                content=b"PK\x03\x04 fake-3mf",
-                headers={
-                    "x-print-time-seconds": "200",
-                    "x-filament-used-g": "1.5",
-                    "x-filament-used-mm": "150",
-                },
-            )
-
-        _install_mock_sidecar(handler)
-        response = await async_client.post(
-            f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
-            json={
-                "bundle": {
-                    "bundle_id": "abc123def456abcd",
-                    "printer_name": "# Bambu Lab H2D 0.4 nozzle",
-                    "process_name": "# 0.20mm Standard @BBL H2D",
-                    "filament_names": [
-                        "# Bambu PLA Basic @BBL H2D",
-                        "# Bambu PETG HF @BBL H2D 0.4 nozzle",
-                    ],
-                },
-            },
-        )
-        assert response.status_code == 202, response.text
-        final = await _wait_for_job(async_client, response.json()["job_id"])
-        assert final["status"] == "completed", final
-
-        # Multipart form body should carry the bundle selectors instead of
-        # the JSON profile attachments. Quick string-level check is enough
-        # to confirm the dispatch picked the bundle branch.
-        body = captured["body"]
-        assert b'name="bundle"' in body
-        assert b"abc123def456abcd" in body
-        assert b'name="printerName"' in body
-        assert b'name="processName"' in body
-        assert b'name="filamentNames"' in body
-        # Multi-color filament list joined with ';' on the wire.
-        assert b"# Bambu PLA Basic @BBL H2D;# Bambu PETG HF @BBL H2D 0.4 nozzle" in body
-        # Profile attachments must NOT be present — bundle dispatch skips
-        # PresetRef resolution entirely.
-        assert b'name="printerProfile"' not in body
-        assert b'name="presetProfile"' not in body
-        assert b'name="filamentProfile"' not in body
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_bundle_dispatch_forwards_bed_type_when_set(self, async_client: AsyncClient, slice_test_setup):
-        """#1337 follow-up: bed-type override flows through the bundle path
-        as a `bedType` form field so the sidecar can pass
-        `--curr_bed_type` to the CLI. Bambuddy can't patch the bundle's
-        process JSON locally — the sidecar materialises it from the stored
-        .bbscfg — so the form field is the only handle."""
-        captured: dict = {}
-
-        def handler(request: httpx.Request) -> httpx.Response:
-            captured["body"] = bytes(request.content)
-            return httpx.Response(
-                status_code=200,
-                content=b"PK\x03\x04 fake",
-                headers={
-                    "x-print-time-seconds": "10",
-                    "x-filament-used-g": "0.1",
-                    "x-filament-used-mm": "1.0",
-                },
-            )
-
-        _install_mock_sidecar(handler)
-        response = await async_client.post(
-            f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
-            json={
-                "bundle": {
-                    "bundle_id": "abc",
-                    "printer_name": "# X1C",
-                    "process_name": "# 0.20mm",
-                    "filament_names": ["# Bambu PLA"],
-                },
-                "bed_type": "Engineering Plate",
-            },
-        )
-        assert response.status_code == 202
-        final = await _wait_for_job(async_client, response.json()["job_id"])
-        assert final["status"] == "completed", final
-        body = captured["body"]
-        assert b'name="bedType"' in body
-        assert b"Engineering Plate" in body
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_bundle_dispatch_omits_bed_type_when_unset(self, async_client: AsyncClient, slice_test_setup):
-        """Companion test: no bed_type ⇒ no bedType form field, so the
-        bundle's own curr_bed_type is preserved end-to-end."""
-        captured: dict = {}
-
-        def handler(request: httpx.Request) -> httpx.Response:
-            captured["body"] = bytes(request.content)
-            return httpx.Response(
-                status_code=200,
-                content=b"PK\x03\x04 fake",
-                headers={
-                    "x-print-time-seconds": "10",
-                    "x-filament-used-g": "0.1",
-                    "x-filament-used-mm": "1.0",
-                },
-            )
-
-        _install_mock_sidecar(handler)
-        response = await async_client.post(
-            f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
-            json={
-                "bundle": {
-                    "bundle_id": "abc",
-                    "printer_name": "# X1C",
-                    "process_name": "# 0.20mm",
-                    "filament_names": ["# Bambu PLA"],
-                },
-            },
-        )
-        assert response.status_code == 202
-        final = await _wait_for_job(async_client, response.json()["job_id"])
-        assert final["status"] == "completed", final
-        assert b'name="bedType"' not in captured["body"]
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_bundle_dispatch_3mf_falls_back_to_embedded_on_5xx(
-        self, async_client: AsyncClient, db_session, slice_test_setup
-    ):
-        # Same fallback as the preset-based path: if the resolved bundle
-        # triplet crashes the CLI on a 3MF, retry with embedded settings
-        # so the user gets *something* rather than a hard failure.
-        src_3mf_path = slice_test_setup["tmp_path"] / "library" / "files" / "complex_bundle.3mf"
-        src_3mf_path.write_bytes(_make_3mf_with_settings({"prime_tower_brim_width": "-1"}))
-        threemf = LibraryFile(
-            filename="complex_bundle.3mf",
-            file_path=str(src_3mf_path.relative_to(slice_test_setup["tmp_path"])),
-            file_type="3mf",
-            file_size=src_3mf_path.stat().st_size,
-        )
-        db_session.add(threemf)
-        await db_session.commit()
-        await db_session.refresh(threemf)
-
-        call_count = {"n": 0}
-
-        def handler(request: httpx.Request) -> httpx.Response:
-            call_count["n"] += 1
-            # First call: bundle path → simulate CLI 5xx
-            if call_count["n"] == 1:
-                return httpx.Response(
-                    status_code=500,
-                    json={"message": "Failed to slice the model"},
-                )
-            # Retry: no profiles / no bundle → succeed with embedded settings
-            return httpx.Response(
-                status_code=200,
-                content=b"PK\x03\x04 fake-3mf",
-                headers={
-                    "x-print-time-seconds": "100",
-                    "x-filament-used-g": "1.0",
-                    "x-filament-used-mm": "100",
-                },
-            )
-
-        _install_mock_sidecar(handler)
-        response = await async_client.post(
-            f"/api/v1/library/files/{threemf.id}/slice",
-            json={
-                "bundle": {
-                    "bundle_id": "abc",
-                    "printer_name": "P",
-                    "process_name": "Q",
-                    "filament_names": ["F"],
-                },
-            },
-        )
-        assert response.status_code == 202
-
-        final = await _wait_for_job(async_client, response.json()["job_id"])
-        assert final["status"] == "completed", final
-        assert final["result"]["used_embedded_settings"] is True
-        assert call_count["n"] == 2  # bundle attempt + embedded fallback
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_bundle_dispatch_404_surfaces_as_400(self, async_client: AsyncClient, slice_test_setup):
-        # Sidecar returns 404 when the bundle / preset name isn't found —
-        # the slicer client classifies this as user-correctable input
-        # error so the dispatch returns 400 to the caller, not 502.
-        def handler(_: httpx.Request) -> httpx.Response:
-            return httpx.Response(
-                status_code=404,
-                json={"message": 'process preset "Imaginary" not found in bundle "abc"'},
-            )
-
-        _install_mock_sidecar(handler)
-        response = await async_client.post(
-            f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
-            json={
-                "bundle": {
-                    "bundle_id": "abc",
-                    "printer_name": "P",
-                    "process_name": "Imaginary",
-                    "filament_names": ["F"],
-                },
-            },
-        )
-        assert response.status_code == 202
-        final = await _wait_for_job(async_client, response.json()["job_id"])
-        assert final["status"] == "failed"
-        assert final["error_status"] == 400
-        assert "imaginary" in (final["error_detail"] or "").lower()
-
-
 # ---------------------------------------------------------------------------
 # GET /slice-jobs/{id}
 # ---------------------------------------------------------------------------
@@ -1603,24 +1375,19 @@ class TestCanonicalPrinterModel:
 
 class TestNozzleClassGuard:
     """guard_nozzle_class_reslice is now a no-op (#1493). Cross-class re-slicing
-    is handled by the two-pass conversion in _run_slicer_with_fallback for
-    both preset and bundle dispatch — so the guard never blocks. The function
-    is kept (and these tests with it) so external forks / pinned versions
-    that call it still link, and so a future regression that re-introduces a
-    raise inside the helper gets caught here."""
+    is handled by the two-pass conversion in _run_slicer_with_fallback — so the
+    guard never blocks. The function is kept (and these tests with it) so
+    external forks / pinned versions that call it still link, and so a future
+    regression that re-introduces a raise inside the helper gets caught here."""
 
     @staticmethod
-    def _bundle_request() -> object:
-        return type("_Req", (), {"bundle": object()})()
-
-    @staticmethod
-    def _preset_request() -> object:
-        return type("_Req", (), {"bundle": None})()
+    def _request() -> object:
+        return type("_Req", (), {})()
 
     @pytest.mark.asyncio
-    async def test_single_to_dual_bundle_is_allowed(self, monkeypatch):
-        """Bundle-mode cross-class: handled by the two-pass converter via
-        slice_with_bundle on the cube, so the guard does NOT raise."""
+    async def test_single_to_dual_is_allowed(self, monkeypatch):
+        """Cross-class re-slice: handled by the two-pass converter, so the
+        guard does NOT raise."""
         import backend.app.api.routes.library as lib
 
         async def _target(_db, _user, _request):
@@ -1628,28 +1395,17 @@ class TestNozzleClassGuard:
 
         monkeypatch.setattr(lib, "_resolve_target_printer_model", _target)
         # No raise — the converter handles this case now.
-        await guard_nozzle_class_reslice(None, None, self._bundle_request(), "X1C")
+        await guard_nozzle_class_reslice(None, None, self._request(), "X1C")
 
     @pytest.mark.asyncio
-    async def test_dual_to_single_bundle_is_allowed(self, monkeypatch):
+    async def test_dual_to_single_is_allowed(self, monkeypatch):
         import backend.app.api.routes.library as lib
 
         async def _target(_db, _user, _request):
             return "X1C"
 
         monkeypatch.setattr(lib, "_resolve_target_printer_model", _target)
-        await guard_nozzle_class_reslice(None, None, self._bundle_request(), "H2D")
-
-    @pytest.mark.asyncio
-    async def test_preset_path_is_not_blocked(self, monkeypatch):
-        """Preset path cross-class is also handled by the two-pass converter."""
-        import backend.app.api.routes.library as lib
-
-        async def _target(_db, _user, _request):
-            return "H2D"
-
-        monkeypatch.setattr(lib, "_resolve_target_printer_model", _target)
-        await guard_nozzle_class_reslice(None, None, self._preset_request(), "X1C")
+        await guard_nozzle_class_reslice(None, None, self._request(), "H2D")
 
     @pytest.mark.asyncio
     async def test_same_nozzle_class_is_allowed(self, monkeypatch):
@@ -1659,7 +1415,7 @@ class TestNozzleClassGuard:
             return "P1S"
 
         monkeypatch.setattr(lib, "_resolve_target_printer_model", _target)
-        await guard_nozzle_class_reslice(None, None, self._bundle_request(), "X1C")
+        await guard_nozzle_class_reslice(None, None, self._request(), "X1C")
 
     @pytest.mark.asyncio
     async def test_no_source_model_is_a_noop(self, monkeypatch):
@@ -1669,7 +1425,7 @@ class TestNozzleClassGuard:
             return "H2D"
 
         monkeypatch.setattr(lib, "_resolve_target_printer_model", _target)
-        await guard_nozzle_class_reslice(None, None, self._bundle_request(), None)
+        await guard_nozzle_class_reslice(None, None, self._request(), None)
 
     @pytest.mark.asyncio
     async def test_null_request_is_a_noop(self):

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

@@ -744,3 +744,283 @@ class TestUserItemsCountAndDeletion(TestOwnershipPermissionsSetup):
             headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
         )
         assert archive_response.status_code == 404
+
+
+class TestReadIDORClosure(TestOwnershipPermissionsSetup):
+    """Regression tests pinning maziggy/bambuddy-security #2 — IDOR on
+    archives / library / queue read paths.
+
+    Before the fix, ARCHIVES_READ / LIBRARY_READ / QUEUE_READ were flat
+    "see everything" permissions even though the write side was split into
+    OWN/ALL. An operator with only ARCHIVES_READ could read, download, and
+    queue any user's archive via direct id reference. These tests pin the
+    bambuddy_archive_idor.py and bambuddy_archive_viewer_idor.py PoC paths
+    so the IDOR can't regress silently.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_get_others_archive_returns_404_not_200(
+        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
+    ):
+        """PoC #2 read path. operator1 GET /archives/{id} where id is admin's
+        archive must NOT leak the row. 404 (not 403) so the operator can't
+        enumerate which ids exist — same shape as a nonexistent id."""
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            print_name="Admin Archive",
+            created_by_id=auth_setup["admin_user"]["id"],
+        )
+        response = await async_client.get(
+            f"/api/v1/archives/{archive.id}",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_download_others_archive_returns_404(
+        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
+    ):
+        """Viewer-IDOR PoC path: GET /archives/{id}/download on admin's archive.
+        Before the fix this streamed the 3MF body straight to a viewer-tier
+        token."""
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            print_name="Admin Archive 2",
+            created_by_id=auth_setup["admin_user"]["id"],
+        )
+        response = await async_client.get(
+            f"/api/v1/archives/{archive.id}/download",
+            headers={"Authorization": f"Bearer {auth_setup['viewer_token']}"},
+        )
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_list_archives_excludes_others(
+        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
+    ):
+        """GET /archives/ must filter to own archives only for OWN-level callers."""
+        printer = await printer_factory()
+        own = await archive_factory(
+            printer.id, print_name="Operator's Own", created_by_id=auth_setup["operator_user"]["id"]
+        )
+        others = await archive_factory(printer.id, print_name="Admin's", created_by_id=auth_setup["admin_user"]["id"])
+        response = await async_client.get(
+            "/api/v1/archives/",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+        assert response.status_code == 200
+        returned_ids = {a["id"] for a in response.json()}
+        assert own.id in returned_ids
+        assert others.id not in returned_ids
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_admin_list_archives_includes_all(
+        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
+    ):
+        """ARCHIVES_READ_ALL → admin sees own + every user's archives."""
+        printer = await printer_factory()
+        admin_archive = await archive_factory(
+            printer.id, print_name="Admin's", created_by_id=auth_setup["admin_user"]["id"]
+        )
+        operator_archive = await archive_factory(
+            printer.id, print_name="Operator's", created_by_id=auth_setup["operator_user"]["id"]
+        )
+        response = await async_client.get(
+            "/api/v1/archives/",
+            headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
+        )
+        assert response.status_code == 200
+        returned_ids = {a["id"] for a in response.json()}
+        assert admin_archive.id in returned_ids
+        assert operator_archive.id in returned_ids
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_queue_others_archive(
+        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
+    ):
+        """PoC #2 queue path. POST /queue/ with admin's archive_id as
+        operator1 must return 404, not create a queue item. Before the fix
+        this returned 201 and queued the admin archive (Landon's CONFIRMED
+        line in the PoC)."""
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            print_name="Admin Archive (queue-target)",
+            created_by_id=auth_setup["admin_user"]["id"],
+        )
+        response = await async_client.post(
+            "/api/v1/queue/",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            json={"archive_id": archive.id, "printer_id": printer.id, "quantity": 1},
+        )
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_admin_can_queue_others_archive(
+        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
+    ):
+        """Belt-and-suspenders for the ALL path: admin (ARCHIVES_READ_ALL) can
+        queue a user's archive on their behalf — common workshop pattern."""
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            print_name="Operator's archive (queue by admin)",
+            created_by_id=auth_setup["operator_user"]["id"],
+        )
+        response = await async_client.post(
+            "/api/v1/queue/",
+            headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
+            json={"archive_id": archive.id, "printer_id": printer.id, "quantity": 1},
+        )
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_get_others_library_file_returns_404(
+        self, async_client: AsyncClient, auth_setup, db_session
+    ):
+        """Library IDOR closure (same shape as archives — closed in the same PR
+        per maziggy/bambuddy-security #2)."""
+        from backend.app.models.library import LibraryFile
+
+        admin_file = LibraryFile(
+            filename="admin_secret.3mf",
+            file_path="library/admin_secret.3mf",
+            file_type="3mf",
+            file_size=2048,
+            created_by_id=auth_setup["admin_user"]["id"],
+        )
+        db_session.add(admin_file)
+        await db_session.commit()
+        await db_session.refresh(admin_file)
+
+        response = await async_client.get(
+            f"/api/v1/library/files/{admin_file.id}",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_list_library_files_excludes_others(self, async_client: AsyncClient, auth_setup, db_session):
+        from backend.app.models.library import LibraryFile
+
+        own = LibraryFile(
+            filename="my_file.3mf",
+            file_path="library/my_file.3mf",
+            file_type="3mf",
+            file_size=1024,
+            created_by_id=auth_setup["operator_user"]["id"],
+        )
+        others = LibraryFile(
+            filename="admin_file.3mf",
+            file_path="library/admin_file.3mf",
+            file_type="3mf",
+            file_size=1024,
+            created_by_id=auth_setup["admin_user"]["id"],
+        )
+        db_session.add_all([own, others])
+        await db_session.commit()
+        await db_session.refresh(own)
+        await db_session.refresh(others)
+
+        response = await async_client.get(
+            "/api/v1/library/files",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+        assert response.status_code == 200
+        returned_ids = {f["id"] for f in response.json()}
+        assert own.id in returned_ids
+        assert others.id not in returned_ids
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_queue_list_excludes_others_items(
+        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
+    ):
+        """GET /queue/ must filter to own queue items only for OWN callers —
+        same shape as the archive list."""
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, print_name="A", created_by_id=auth_setup["operator_user"]["id"])
+        own_item = PrintQueueItem(
+            archive_id=archive.id,
+            printer_id=printer.id,
+            status="pending",
+            position=1,
+            created_by_id=auth_setup["operator_user"]["id"],
+        )
+        admin_item = PrintQueueItem(
+            archive_id=archive.id,
+            printer_id=printer.id,
+            status="pending",
+            position=2,
+            created_by_id=auth_setup["admin_user"]["id"],
+        )
+        db_session.add_all([own_item, admin_item])
+        await db_session.commit()
+        await db_session.refresh(own_item)
+        await db_session.refresh(admin_item)
+
+        response = await async_client.get(
+            "/api/v1/queue/",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+        assert response.status_code == 200
+        returned_ids = {q["id"] for q in response.json()}
+        assert own_item.id in returned_ids
+        assert admin_item.id not in returned_ids
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_get_others_queue_item_returns_404(
+        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
+    ):
+        """Direct-id queue item access — same enumeration risk as archive get."""
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, print_name="A", created_by_id=auth_setup["admin_user"]["id"])
+        admin_item = PrintQueueItem(
+            archive_id=archive.id,
+            printer_id=printer.id,
+            status="pending",
+            position=1,
+            created_by_id=auth_setup["admin_user"]["id"],
+        )
+        db_session.add(admin_item)
+        await db_session.commit()
+        await db_session.refresh(admin_item)
+
+        response = await async_client.get(
+            f"/api/v1/queue/{admin_item.id}",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_auth_disabled_preserves_single_tenant_read_all(
+        self, async_client: AsyncClient, archive_factory, printer_factory
+    ):
+        """With auth disabled, ARCHIVES_READ resolves to read-all (can_modify_all=True
+        in require_ownership_permission's auth-disabled branch). Existing
+        single-user installs see no behavior change."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, print_name="Anonymous", created_by_id=None)
+        # No Authorization header — auth-disabled mode.
+        response = await async_client.get(f"/api/v1/archives/{archive.id}")
+        # Either 200 (auth disabled in this test session) or 401 (auth enabled
+        # from a prior test) — both are acceptable; the IDOR closure does not
+        # change auth-enable/disable behavior. Pin not-404 to avoid masking a
+        # regression where auth-disabled callers would lose access.
+        assert response.status_code in (200, 401)

+ 29 - 11
backend/tests/integration/test_print_queue_api.py

@@ -2008,13 +2008,25 @@ class TestAbortedStatusNormalisation:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_soft_delete_archive_cancels_pending_queue_items(
+    async def test_soft_delete_archive_deletes_all_related_queue_items(
         self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory, db_session
     ):
-        """Soft-deleting an archive cancels its pending queue items with a
-        clear reason. The 3MF is gone from disk so the item can never
-        dispatch — leaving it in 'pending' would 404-storm the queue page
-        and confuse the user about why nothing prints."""
+        """Soft-deleting an archive removes every related queue item, regardless
+        of status (#1734). Pre-#1734 only ``pending`` rows were flipped to
+        ``cancelled`` and stayed in the DB, surprising users who expected the
+        queue lines to disappear with the archive — especially on multi-plate
+        Send All uploads (#1733), where ONE archive backed N queue items and
+        soft-deleting the archive left N "cancelled" rows behind. The change
+        keeps the printing guard (a row with ``status='printing'`` blocks the
+        delete one layer up at the API route), so we never delete the row of
+        an actively-running print here.
+
+        Print history lives in ``PrintLogEntry`` (FK ``ON DELETE SET NULL``) —
+        the audit trail survives independently of the queue rows.
+        """
+        from sqlalchemy import select
+
+        from backend.app.models.print_queue import PrintQueueItem
         from backend.app.services.archive import ArchiveService
 
         printer = await printer_factory()
@@ -2025,12 +2037,18 @@ class TestAbortedStatusNormalisation:
         service = ArchiveService(db_session)
         assert await service.soft_delete_archive(archive.id) is True
 
-        await db_session.refresh(pending)
-        await db_session.refresh(completed)
-        assert pending.status == "cancelled"
-        assert pending.waiting_reason == "Source archive deleted"
-        # Historical rows untouched — they're audit-trail.
-        assert completed.status == "completed"
+        # Every queue row that referenced this archive is gone — both the
+        # pending and the completed rows. Print history (PrintLogEntry) is
+        # the authoritative record and is preserved by the FK SET NULL.
+        remaining = (
+            (await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id.in_([pending.id, completed.id]))))
+            .scalars()
+            .all()
+        )
+        assert remaining == [], (
+            "Soft-deleting the archive must delete every related queue row, "
+            f"got {[(r.id, r.status) for r in remaining]} still present"
+        )
 
     @pytest.mark.asyncio
     @pytest.mark.integration

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

@@ -0,0 +1,236 @@
+"""Migration tests for maziggy/bambuddy-security #2 — read permission OWN/ALL backfill.
+
+Pre-fix, ARCHIVES_READ / LIBRARY_READ / QUEUE_READ were flat "read all" flags.
+Post-fix they split into OWN/ALL. The migration in seed_default_groups must:
+
+  1. Rename legacy `archives:read` etc to `archives:read_all` on Administrators
+     and to `archives:read_own` on every other role (fail-closed default).
+  2. Backfill `_own` AND `_all` variants for the Administrators group on upgrade
+     so an upgraded install matches a fresh install's permission set.
+  3. Backfill `_own` variants for Operators and Viewers so they keep read access
+     even if their stored row didn't carry the legacy flag.
+
+These regressions are the failure shape Maziggy hit on a live upgrade — the
+admin role ended up missing queue:read_own AND queue:read after migration.
+"""
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+
+from backend.app.core import database as _database_module
+from backend.app.core.database import seed_default_groups
+from backend.app.models.group import Group
+
+_READ_FLAGS = frozenset(
+    {
+        "archives:read",
+        "archives:read_own",
+        "archives:read_all",
+        "library:read",
+        "library:read_own",
+        "library:read_all",
+        "queue:read",
+        "queue:read_own",
+        "queue:read_all",
+    }
+)
+
+
+async def _strip_and_set(group_name: str, extra: list[str] | None = None) -> None:
+    """Strip every read flag from ``group_name`` then add ``extra`` flags.
+
+    Simulates a pre-migration state where the group either had only the
+    legacy flat permission (set ``extra=['archives:read']``) or no read
+    permission at all (set ``extra=None``).
+    """
+    async with _database_module.async_session() as session:
+        grp = (await session.execute(select(Group).where(Group.name == group_name))).scalar_one_or_none()
+        assert grp is not None, f"group {group_name} not pre-seeded"
+        stripped = [p for p in (grp.permissions or []) if p not in _READ_FLAGS]
+        stripped.extend(extra or [])
+        grp.permissions = stripped
+        await session.commit()
+
+
+async def _get_perms(group_name: str) -> set[str]:
+    async with _database_module.async_session() as session:
+        grp = (await session.execute(select(Group).where(Group.name == group_name))).scalar_one_or_none()
+        assert grp is not None
+        return set(grp.permissions or [])
+
+
+# Note: ``async_client`` is depended upon (even though unused) so pytest-asyncio
+# uses the same event loop the conftest fixture uses for async_session(). Without
+# it, calling ``async_session()`` twice in one test trips an asyncpg
+# "got Future attached to a different loop" RuntimeError.
+
+
+class TestReadPermissionMigration:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_legacy_archives_read_renamed_to_all_for_administrators(self, async_client: AsyncClient):
+        """Existing Administrators group with legacy `archives:read` → gets
+        `archives:read_all` after seed_default_groups runs, and gets the
+        `_own` companion backfilled too."""
+        await seed_default_groups()
+        await _strip_and_set("Administrators", extra=["archives:read"])
+
+        await seed_default_groups()
+
+        perms = await _get_perms("Administrators")
+        # Rename happened: legacy renamed to _all
+        assert "archives:read_all" in perms
+        # Backfill also added _own so fresh install and upgraded install match
+        assert "archives:read_own" in perms
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_administrators_backfill_adds_all_six_read_flags(self, async_client: AsyncClient):
+        """Even with NO legacy flags present, Administrators ends up with both
+        OWN and ALL variants for archives / library / queue after the backfill
+        pass. This is the case Maziggy hit — admin missing `queue:read_own`
+        after upgrade."""
+        await seed_default_groups()
+        await _strip_and_set("Administrators")
+
+        await seed_default_groups()
+
+        perms = await _get_perms("Administrators")
+        for needed in (
+            "archives:read_own",
+            "archives:read_all",
+            "library:read_own",
+            "library:read_all",
+            "queue:read_own",
+            "queue:read_all",
+        ):
+            assert needed in perms, f"{needed} must be backfilled for Administrators"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operators_backfill_adds_own_read_flags(self, async_client: AsyncClient):
+        """Operators with no read flags get the _OWN variants backfilled
+        (fail-closed — no _ALL)."""
+        await seed_default_groups()
+        await _strip_and_set("Operators")
+
+        await seed_default_groups()
+
+        perms = await _get_perms("Operators")
+        assert "archives:read_own" in perms
+        assert "library:read_own" in perms
+        assert "queue:read_own" in perms
+        assert "archives:read_all" not in perms
+        assert "library:read_all" not in perms
+        assert "queue:read_all" not in perms
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operators_legacy_archives_read_renamed_to_own(self, async_client: AsyncClient):
+        """Pre-PR Operators with legacy `archives:read` get the _OWN rename
+        (fail-closed — close the IDOR, the operator can re-request _ALL via
+        admin if cross-user visibility is genuinely needed)."""
+        await seed_default_groups()
+        await _strip_and_set("Operators", extra=["archives:read"])
+
+        await seed_default_groups()
+
+        perms = await _get_perms("Operators")
+        assert "archives:read_own" in perms
+        assert "archives:read_all" not in perms
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_administrators_legacy_archives_read_retained(self, async_client: AsyncClient):
+        """Admin keeps the LEGACY `archives:read` flag — the frontend gates
+        download / preview UI on it (ArchivesPage / FileManagerPage), and
+        removing it on rename was leaving admin with no visible download
+        buttons after upgrade. The new API gates use the _ALL variant which
+        the backfill also ensures is present."""
+        await seed_default_groups()
+        await _strip_and_set("Administrators", extra=["archives:read"])
+
+        await seed_default_groups()
+
+        perms = await _get_perms("Administrators")
+        # Both the legacy flag (for the UI) and the _all variant (for the API)
+        # must coexist on admin.
+        assert "archives:read" in perms
+        assert "archives:read_all" in perms
+        assert "archives:read_own" in perms
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_administrators_backfill_adds_legacy_read_flags(self, async_client: AsyncClient):
+        """Admin with NO read flags at all (hand-edited or stripped role) ends
+        up with the legacy `archives:read` / `queue:read` / `library:read`
+        backfilled — so the UI gates work — alongside the OWN/ALL split."""
+        await seed_default_groups()
+        await _strip_and_set("Administrators")
+
+        await seed_default_groups()
+
+        perms = await _get_perms("Administrators")
+        for needed in (
+            "archives:read",
+            "library:read",
+            "queue:read",
+            "archives:read_own",
+            "archives:read_all",
+            "library:read_own",
+            "library:read_all",
+            "queue:read_own",
+            "queue:read_all",
+        ):
+            assert needed in perms, f"{needed} must be backfilled for Administrators"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_administrators_orca_cloud_auth_backfilled(self, async_client: AsyncClient):
+        """Admin without `orca_cloud:auth` (older custom edit) gets it
+        backfilled — matches the fresh-install default."""
+        await seed_default_groups()
+        async with _database_module.async_session() as session:
+            grp = (await session.execute(select(Group).where(Group.name == "Administrators"))).scalar_one()
+            grp.permissions = [p for p in (grp.permissions or []) if p != "orca_cloud:auth"]
+            await session.commit()
+
+        await seed_default_groups()
+
+        perms = await _get_perms("Administrators")
+        assert "orca_cloud:auth" in perms
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operators_orca_cloud_auth_backfilled(self, async_client: AsyncClient):
+        """Operators on upgraded installs get `orca_cloud:auth` backfilled
+        (the new default — needed for the Slice modal's Orca Cloud preset
+        picker)."""
+        await seed_default_groups()
+        async with _database_module.async_session() as session:
+            grp = (await session.execute(select(Group).where(Group.name == "Operators"))).scalar_one()
+            grp.permissions = [p for p in (grp.permissions or []) if p != "orca_cloud:auth"]
+            await session.commit()
+
+        await seed_default_groups()
+
+        perms = await _get_perms("Operators")
+        assert "orca_cloud:auth" in perms
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_viewers_do_not_get_orca_cloud_auth(self, async_client: AsyncClient):
+        """Viewers stay read-only — orca_cloud:auth is not added by the
+        backfill (matches the fresh-install Viewers bootstrap, which
+        intentionally excludes cloud-auth permissions)."""
+        await seed_default_groups()
+        async with _database_module.async_session() as session:
+            grp = (await session.execute(select(Group).where(Group.name == "Viewers"))).scalar_one()
+            grp.permissions = [p for p in (grp.permissions or []) if p != "orca_cloud:auth"]
+            await session.commit()
+
+        await seed_default_groups()
+
+        perms = await _get_perms("Viewers")
+        assert "orca_cloud:auth" not in perms

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

@@ -6,6 +6,7 @@ Covers:
   - MQTT failure does NOT roll back the slot assignment
 """
 
+import json
 from unittest.mock import AsyncMock, MagicMock, patch
 
 import pytest
@@ -868,3 +869,171 @@ class TestAssignSpoolmanSlotKProfileRealignment:
         # extruder mismatch was hard-skipped pre-fix; now used as fallback
         cs_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
         assert cs_kwargs["cali_idx"] == 42
+
+
+# ---- #1713: slicer_filament resolved into tray_info_idx + setting_id --------
+#
+# Before this fix the Spoolman-mode assign route ignored the spool's stored
+# slicer_filament (the user's configured Bambu Studio / Orca filament profile)
+# and only filled tray_info_idx from the generic-material fallback. The user
+# saw ams_filament_setting publish with tray_info_idx=GFL99 / setting_id=""
+# even though they had assigned a real profile to the spool, and had to
+# manually re-configure each slot through the printer card. The internal-mode
+# route did the resolution correctly via _apply_spool_to_slot_inner; the
+# Spoolman route was never ported.
+#
+# These tests pin the parity: an assign of a Spoolman spool whose
+# bambu_slicer_filament extra-field points at a real preset must publish that
+# preset's tray_info_idx + setting_id, not the generic-material bucket.
+
+
+class TestSlicerFilamentResolutionParity:
+    """#1713: Spoolman-mode assign honours the spool's configured slicer
+    filament profile, matching internal-mode behaviour."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_gf_prefix_slicer_filament_resolves_to_tray_info_idx(
+        self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
+    ):
+        """GF-prefix Bambu official preset (e.g. ``GFA01``) routes straight
+        through ``normalize_slicer_filament`` — the simplest path and the
+        most common shape for users who picked their preset in the slicer."""
+        mock_spoolman_client.get_spool = AsyncMock(
+            return_value={**SAMPLE_SPOOL, "extra": {"bambu_slicer_filament": '"GFA01"'}}
+        )
+
+        mqtt_mock = MagicMock()
+        mqtt_mock.ams_set_filament_setting = MagicMock()
+        mqtt_mock.extrusion_cali_sel = MagicMock()
+        mqtt_mock.printer_state = None
+
+        with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
+            pm_mock.get_client = MagicMock(return_value=mqtt_mock)
+            pm_mock.get_status = MagicMock(return_value=None)
+
+            response = await async_client.post(
+                "/api/v1/spoolman/inventory/slot-assignments",
+                json={
+                    "spoolman_spool_id": 10,
+                    "printer_id": test_printer.id,
+                    "ams_id": 0,
+                    "tray_id": 0,
+                },
+            )
+
+        assert response.status_code == 200
+        call_kwargs = mqtt_mock.ams_set_filament_setting.call_args[1]
+        assert call_kwargs["tray_info_idx"] == "GFA01", (
+            "Pre-fix: dropped slicer_filament and published GFL99 generic-PLA bucket. "
+            "Post-fix: must publish the actual preset id."
+        )
+        assert call_kwargs["setting_id"].startswith("GFSA01"), (
+            "setting_id must be derived from the resolved filament_id, not left empty as the pre-fix path did."
+        )
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_local_preset_int_id_resolves_to_filament_id_from_json(
+        self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
+    ):
+        """#1713 regression: shaddowlink's exact case. Spool's slicer_filament
+        is the integer id of a LocalPreset whose setting JSON carries the
+        printer-side ``filament_id`` (e.g. ``P20bd830``). The publish must
+        carry that filament_id + its derived setting_id — not the generic
+        material bucket.
+
+        From his support bundle:
+          11:33:01 — assign_spoolman_slot published tray_info_idx=GFL99 (BUG)
+          11:33:13 — user manually fired /printers/.../configure with
+                     tray_info_idx=P20bd830, setting_id=PFUS3822acb73c88cc
+        """
+        from backend.app.models.local_preset import LocalPreset
+
+        lp = LocalPreset(
+            name="AMOLEN PLA Silk @0.4 nozzle",
+            preset_type="filament",
+            filament_type="PLA",
+            setting=json.dumps({"filament_id": "P20bd830"}),
+        )
+        db_session.add(lp)
+        await db_session.commit()
+        await db_session.refresh(lp)
+
+        # Spoolman spool whose bambu_slicer_filament points at this LocalPreset
+        # by integer id (the shape the inventory UI persists when the user
+        # picks a local preset in the filament dropdown).
+        mock_spoolman_client.get_spool = AsyncMock(
+            return_value={
+                **SAMPLE_SPOOL,
+                "extra": {"bambu_slicer_filament": json.dumps(str(lp.id))},
+            }
+        )
+
+        mqtt_mock = MagicMock()
+        mqtt_mock.ams_set_filament_setting = MagicMock()
+        mqtt_mock.extrusion_cali_sel = MagicMock()
+        mqtt_mock.printer_state = None
+
+        with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
+            pm_mock.get_client = MagicMock(return_value=mqtt_mock)
+            pm_mock.get_status = MagicMock(return_value=None)
+
+            response = await async_client.post(
+                "/api/v1/spoolman/inventory/slot-assignments",
+                json={
+                    "spoolman_spool_id": 10,
+                    "printer_id": test_printer.id,
+                    "ams_id": 255,
+                    "tray_id": 0,
+                },
+            )
+
+        assert response.status_code == 200
+        call_kwargs = mqtt_mock.ams_set_filament_setting.call_args[1]
+        # Pre-fix the publish here was tray_info_idx="GFL99", setting_id="".
+        assert call_kwargs["tray_info_idx"] == "P20bd830"
+        assert call_kwargs["setting_id"], "setting_id must not be empty post-fix"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_empty_slicer_filament_still_falls_back_to_generic_material(
+        self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
+    ):
+        """Spools without a configured slicer_filament must still get the
+        generic-material fallback so the slot is at least minimally
+        configured. Guards against the resolver path swallowing the empty
+        case and leaving tray_info_idx empty."""
+        # extra dict has no bambu_slicer_filament key
+        mock_spoolman_client.get_spool = AsyncMock(return_value={**SAMPLE_SPOOL, "extra": {}})
+
+        mqtt_mock = MagicMock()
+        mqtt_mock.ams_set_filament_setting = MagicMock()
+        mqtt_mock.extrusion_cali_sel = MagicMock()
+        mqtt_mock.printer_state = None
+
+        with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
+            pm_mock.get_client = MagicMock(return_value=mqtt_mock)
+            pm_mock.get_status = MagicMock(return_value=None)
+
+            response = await async_client.post(
+                "/api/v1/spoolman/inventory/slot-assignments",
+                json={
+                    "spoolman_spool_id": 10,
+                    "printer_id": test_printer.id,
+                    "ams_id": 0,
+                    "tray_id": 0,
+                },
+            )
+
+        assert response.status_code == 200
+        call_kwargs = mqtt_mock.ams_set_filament_setting.call_args[1]
+        # PLA → GFL99 (the generic-PLA bucket from GENERIC_FILAMENT_IDS).
+        assert call_kwargs["tray_info_idx"] == "GFL99"
+        # The generic-fallback path must STILL produce a non-empty setting_id
+        # (matches the internal-mode tail). Pre-fix this was "".
+        assert call_kwargs["setting_id"], (
+            "Even on the generic-material fallback, setting_id must be "
+            "filament_id_to_setting_id-derived so the slot detail modal "
+            "doesn't render with empty fields."
+        )

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

@@ -336,6 +336,39 @@ class TestSystemAPI:
         result = response.json()
         assert result["system"]["boot_time"].startswith("2023-11-14T")  # 1700000000 UTC
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_boot_time_isoformat_carries_utc_marker(self, async_client: AsyncClient):
+        """#1690 follow-up: the boot_time string must include a UTC tz marker.
+
+        Without it the frontend's parseUTCDate(...) appends 'Z' to a naive-
+        local-time string, treats it as UTC, and converts to local — applying
+        the local offset twice. The reporter (UTC+3) saw boot_time +3h ahead
+        even though uptime was correct (uptime is computed backend-side from
+        two naive-local values whose delta is right). The fix is to make both
+        ends tz-aware UTC and emit an explicit offset.
+        """
+        with patch("backend.app.api.routes.system.psutil") as mock_psutil:
+            mock_psutil.disk_usage.return_value = MagicMock(
+                total=500000000000, used=250000000000, free=250000000000, percent=50.0
+            )
+            mock_psutil.virtual_memory.return_value = MagicMock(
+                total=16000000000, available=8000000000, used=8000000000, percent=50.0
+            )
+            mock_psutil.boot_time.return_value = 1700000000.0
+            mock_psutil.Process.return_value.create_time.return_value = 1700345600.0
+            mock_psutil.cpu_count.return_value = 4
+            mock_psutil.cpu_percent.return_value = 25.0
+
+            response = await async_client.get("/api/v1/system/info")
+
+        assert response.status_code == 200
+        boot_time = response.json()["system"]["boot_time"]
+        assert boot_time.endswith("+00:00") or boot_time.endswith("Z"), (
+            f"boot_time {boot_time!r} must carry a UTC tz marker; without one the "
+            "frontend double-converts via parseUTCDate"
+        )
+
 
 class TestSystemHelperFunctions:
     """Tests for system info helper functions."""

+ 68 - 0
backend/tests/integration/test_updates_api.py

@@ -569,3 +569,71 @@ class TestUpdatesAPI:
         # at the captured cwd. If this fails the cwd is wrong even if it isn't
         # base_dir — useful diagnostic if someone refactors path handling.
         assert (Path(pip_cwd) / "requirements.txt").exists()
+
+    @pytest.mark.asyncio
+    async def test_perform_update_runs_git_in_app_dir_when_data_dir_on_separate_mount(self, tmp_path):
+        """Regression for #1715: when DATA_DIR is on a path separate from the
+        install (e.g. WorkingDirectory=/opt/bambuddy + DATA_DIR=/srv/bambuddy/data),
+        ``base_dir`` and the repo working tree are on different mounts. Pre-fix,
+        every git subprocess (`remote get-url`, `remote set-url`, `fetch`,
+        `reset --hard`) used ``cwd=base_dir`` — and git could no longer walk up
+        to find ``.git`` because the data dir is not a subdir of the repo.
+        Every update failed with "not a git repository". The fix routes every
+        git step (and the embedded ``safe.directory`` config) through
+        ``app_dir`` instead. This test pins the cwd of all four git steps so a
+        future refactor that re-introduces ``base_dir`` for any of them surfaces
+        loudly here instead of silently re-breaking native installs."""
+        from backend.app.api.routes import updates as updates_module
+
+        # Separate-mount layout: app_dir and data_dir are SIBLINGS, not parent/
+        # child. base_dir is not under app_dir, so git cannot walk up.
+        app_dir = tmp_path / "opt" / "bambuddy"
+        data_dir = tmp_path / "srv" / "bambuddy" / "data"
+        app_dir.mkdir(parents=True)
+        data_dir.mkdir(parents=True)
+        (app_dir / "requirements.txt").write_text("fastapi\n")
+
+        calls: list[dict] = []
+
+        async def fake_create_subprocess_exec(*args, **kwargs):
+            calls.append({"args": args, "cwd": kwargs.get("cwd")})
+            proc = MagicMock()
+            if "get-url" in args and "origin" in args:
+                proc.communicate = AsyncMock(return_value=(b"git@github.com:maziggy/bambuddy.git\n", b""))
+            else:
+                proc.communicate = AsyncMock(return_value=(b"", b""))
+            proc.returncode = 0
+            return proc
+
+        with (
+            patch.object(updates_module.settings, "base_dir", data_dir),
+            patch.object(updates_module.settings, "app_dir", app_dir),
+            patch.object(updates_module, "_find_executable", return_value="/usr/bin/git"),
+            patch.object(
+                updates_module.asyncio,
+                "create_subprocess_exec",
+                side_effect=fake_create_subprocess_exec,
+            ),
+        ):
+            await updates_module._perform_update("v0.2.4b1")
+
+        # Every git subprocess must run in app_dir (the working tree). A
+        # regression to base_dir would silently break #1715-class installs.
+        git_calls = [c for c in calls if c["args"] and c["args"][0] == "/usr/bin/git"]
+        assert git_calls, "no git subprocess was invoked; setup is wrong"
+        wrong_cwd = [c for c in git_calls if c["cwd"] != str(app_dir)]
+        assert not wrong_cwd, (
+            "git subprocess ran with cwd != app_dir; #1715 would resurface. "
+            f"Offending calls: {[(c['args'][1:5], c['cwd']) for c in wrong_cwd]}"
+        )
+
+        # ``safe.directory`` must equal app_dir (the repo root git discovers),
+        # not the data dir — otherwise git refuses with "dubious ownership"
+        # even when the cwd is technically correct.
+        safe_dir_configs = [
+            arg for c in git_calls for arg in c["args"] if isinstance(arg, str) and arg.startswith("safe.directory=")
+        ]
+        assert safe_dir_configs, "safe.directory config was never set on git calls"
+        assert all(s == f"safe.directory={app_dir}" for s in safe_dir_configs), (
+            f"safe.directory must point at app_dir ({app_dir}); got {safe_dir_configs}"
+        )

+ 407 - 0
backend/tests/integration/test_users_groups_privilege_escalation.py

@@ -0,0 +1,407 @@
+"""Privilege-escalation regression suite for the users/groups admin boundary.
+
+The intent declared in ``permissions.py`` is that USERS_* / GROUPS_* are
+admin-level capabilities — the comments literally say "(admin-level)".
+The original implementation enforced ONLY the permission, not admin role.
+Any user holding USERS_UPDATE (or USERS_CREATE / GROUPS_UPDATE /
+GROUPS_CREATE) could grant themselves admin via the management routes.
+
+This suite reproduces every attack vector from the disclosure and pins
+the fail-closed behaviour. Each negative test grants the operator the
+minimum permission needed to *reach* the route gate, then asserts the
+admin gate blocks them. A companion positive test verifies the same
+operation succeeds with an admin token (so the admin gate doesn't
+over-block real flows).
+
+Default-install operators do NOT have USERS_* / GROUPS_* (see
+``DEFAULT_GROUPS``), so default deployments were never vulnerable
+unless an admin had explicitly granted the permission to a custom
+group — but anyone in that position would expect the boundary the
+comments described.
+"""
+
+import secrets
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+
+from backend.app.models.group import Group
+
+
+def _make_fixture_password() -> str:
+    """Build a per-run test credential at import time.
+
+    Tests in this module exercise the admin authorization gate, not
+    password handling — the value is irrelevant as long as the same
+    string is used at setup/create and at login. Generating the random
+    body with :mod:`secrets` keeps any literal out of the source so
+    secret scanners don't flag the file. The four-char prefix satisfies
+    the password-complexity validator in :mod:`backend.app.schemas.auth`
+    (upper + lower + digit + symbol).
+    """
+    return "Aa1!" + secrets.token_urlsafe(12)
+
+
+_FIXTURE_PW = _make_fixture_password()  # pragma: allowlist secret
+
+
+async def _setup_admin(async_client: AsyncClient, username: str = "secadmin") -> str:
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={"auth_enabled": True, "admin_username": username, "admin_password": _FIXTURE_PW},
+    )
+    login = await async_client.post(
+        "/api/v1/auth/login",
+        json={"username": username, "password": _FIXTURE_PW},
+    )
+    return login.json()["access_token"]
+
+
+async def _create_operator_with_perms(
+    async_client: AsyncClient,
+    admin_token: str,
+    db_session,
+    *,
+    username: str,
+    permissions: list[str],
+) -> tuple[str, int]:
+    """Create a non-admin user, drop them in a custom group with exactly
+    the requested permissions, return (token, user_id).
+
+    The operator is intentionally NOT an admin and NOT in the Administrators
+    group — they hold ONLY the listed permission strings. Mirrors the exact
+    deployment shape the security engineer described: an operator gifted
+    one admin-level permission via a custom group ends up able to escalate
+    to full admin without the gate.
+    """
+    headers = {"Authorization": f"Bearer {admin_token}"}
+
+    # Create a custom group carrying just the requested permissions.
+    grp_resp = await async_client.post(
+        "/api/v1/groups/",
+        headers=headers,
+        json={"name": f"escalation_test_{username}", "permissions": permissions},
+    )
+    assert grp_resp.status_code == 201, grp_resp.text
+    gid = grp_resp.json()["id"]
+
+    # Create a regular (role="user") user.
+    user_resp = await async_client.post(
+        "/api/v1/users/",
+        headers=headers,
+        json={"username": username, "password": _FIXTURE_PW, "role": "user", "group_ids": [gid]},
+    )
+    assert user_resp.status_code == 201, user_resp.text
+    uid = user_resp.json()["id"]
+
+    # Confirm the operator is NOT admin in the response shape.
+    assert user_resp.json()["is_admin"] is False
+
+    login = await async_client.post(
+        "/api/v1/auth/login",
+        json={"username": username, "password": _FIXTURE_PW},
+    )
+    assert login.status_code == 200
+    return login.json()["access_token"], uid
+
+
+async def _admin_group_id(db_session) -> int:
+    result = await db_session.execute(select(Group).where(Group.name == "Administrators"))
+    return result.scalar_one().id
+
+
+# ---------------------------------------------------------------------------
+# 1. PATCH /users/{id} {role: "admin"} — USERS_UPDATE holder cannot
+# self-promote
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_users_update_holder_cannot_set_role_to_admin(async_client: AsyncClient, db_session):
+    admin_token = await _setup_admin(async_client)
+    op_token, op_id = await _create_operator_with_perms(
+        async_client, admin_token, db_session, username="op1", permissions=["users:update"]
+    )
+
+    resp = await async_client.patch(
+        f"/api/v1/users/{op_id}",
+        headers={"Authorization": f"Bearer {op_token}"},
+        json={"role": "admin"},
+    )
+    assert resp.status_code == 403
+
+    # And the operator is not admin in the DB after the attempted patch.
+    from backend.app.models.user import User
+
+    result = await db_session.execute(select(User).where(User.id == op_id))
+    user = result.scalar_one()
+    assert user.role == "user"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_users_update_holder_cannot_target_other_user(async_client: AsyncClient, db_session):
+    admin_token = await _setup_admin(async_client)
+    op_token, _ = await _create_operator_with_perms(
+        async_client, admin_token, db_session, username="op2", permissions=["users:update"]
+    )
+    # Create a separate target user.
+    headers = {"Authorization": f"Bearer {admin_token}"}
+    target = await async_client.post(
+        "/api/v1/users/",
+        headers=headers,
+        json={"username": "target", "password": _FIXTURE_PW, "role": "user"},
+    )
+    target_id = target.json()["id"]
+
+    # Operator attempts to elevate target to admin.
+    resp = await async_client.patch(
+        f"/api/v1/users/{target_id}",
+        headers={"Authorization": f"Bearer {op_token}"},
+        json={"role": "admin"},
+    )
+    assert resp.status_code == 403
+
+
+# ---------------------------------------------------------------------------
+# 2. POST /users/ {role: "admin"} — USERS_CREATE holder cannot create admin
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_users_create_holder_cannot_create_admin(async_client: AsyncClient, db_session):
+    admin_token = await _setup_admin(async_client)
+    op_token, _ = await _create_operator_with_perms(
+        async_client, admin_token, db_session, username="op3", permissions=["users:create"]
+    )
+
+    resp = await async_client.post(
+        "/api/v1/users/",
+        headers={"Authorization": f"Bearer {op_token}"},
+        json={"username": "newadmin", "password": _FIXTURE_PW, "role": "admin"},
+    )
+    assert resp.status_code == 403
+
+
+# ---------------------------------------------------------------------------
+# 3. PATCH /groups/{id} {permissions: [...]} — GROUPS_UPDATE holder cannot
+# rewrite a group to admin-equivalent
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_groups_update_holder_cannot_rewrite_permissions(async_client: AsyncClient, db_session):
+    admin_token = await _setup_admin(async_client)
+    op_token, _ = await _create_operator_with_perms(
+        async_client, admin_token, db_session, username="op4", permissions=["groups:update"]
+    )
+
+    # Admin creates a target group; operator tries to grant it everything.
+    headers = {"Authorization": f"Bearer {admin_token}"}
+    create = await async_client.post(
+        "/api/v1/groups/",
+        headers=headers,
+        json={"name": "innocent", "permissions": ["printers:read"]},
+    )
+    gid = create.json()["id"]
+
+    from backend.app.core.permissions import ALL_PERMISSIONS
+
+    resp = await async_client.patch(
+        f"/api/v1/groups/{gid}",
+        headers={"Authorization": f"Bearer {op_token}"},
+        json={"permissions": ALL_PERMISSIONS},
+    )
+    assert resp.status_code == 403
+
+    # And the group still has its original (narrow) permissions.
+    result = await db_session.execute(select(Group).where(Group.id == gid))
+    assert result.scalar_one().permissions == ["printers:read"]
+
+
+# ---------------------------------------------------------------------------
+# 4. POST /groups/ {permissions: [...]} — GROUPS_CREATE holder cannot create
+# an admin-equivalent group
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_groups_create_holder_cannot_create_admin_equivalent(async_client: AsyncClient, db_session):
+    admin_token = await _setup_admin(async_client)
+    op_token, _ = await _create_operator_with_perms(
+        async_client, admin_token, db_session, username="op5", permissions=["groups:create"]
+    )
+    from backend.app.core.permissions import ALL_PERMISSIONS
+
+    resp = await async_client.post(
+        "/api/v1/groups/",
+        headers={"Authorization": f"Bearer {op_token}"},
+        json={"name": "shadowadmins", "permissions": ALL_PERMISSIONS},
+    )
+    assert resp.status_code == 403
+
+
+# ---------------------------------------------------------------------------
+# 5. POST /groups/{admin_gid}/users/{my_id} — GROUPS_UPDATE holder cannot
+# self-add to Administrators
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_groups_update_holder_cannot_self_add_to_administrators(async_client: AsyncClient, db_session):
+    admin_token = await _setup_admin(async_client)
+    op_token, op_id = await _create_operator_with_perms(
+        async_client, admin_token, db_session, username="op6", permissions=["groups:update"]
+    )
+    admin_gid = await _admin_group_id(db_session)
+
+    resp = await async_client.post(
+        f"/api/v1/groups/{admin_gid}/users/{op_id}",
+        headers={"Authorization": f"Bearer {op_token}"},
+    )
+    assert resp.status_code == 403
+
+
+# ---------------------------------------------------------------------------
+# 6. PATCH /groups/{system_gid} — even an admin must not be able to strip
+# the Administrators group's permissions (DoS guard).
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_admin_cannot_strip_administrators_group_permissions(async_client: AsyncClient, db_session):
+    admin_token = await _setup_admin(async_client)
+    headers = {"Authorization": f"Bearer {admin_token}"}
+    admin_gid = await _admin_group_id(db_session)
+
+    resp = await async_client.patch(
+        f"/api/v1/groups/{admin_gid}",
+        headers=headers,
+        json={"permissions": []},
+    )
+    assert resp.status_code == 400
+    assert "system groups" in resp.json()["detail"].lower()
+
+    # Untouched in DB.
+    result = await db_session.execute(select(Group).where(Group.id == admin_gid))
+    grp = result.scalar_one()
+    assert len(grp.permissions or []) > 0
+
+
+# ---------------------------------------------------------------------------
+# Positive companions — admin should succeed on each route (the admin gate
+# must not over-block normal admin flows).
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_admin_can_still_perform_user_role_change(async_client: AsyncClient, db_session):
+    admin_token = await _setup_admin(async_client)
+    headers = {"Authorization": f"Bearer {admin_token}"}
+    target = await async_client.post(
+        "/api/v1/users/",
+        headers=headers,
+        json={"username": "promoteme", "password": _FIXTURE_PW, "role": "user"},
+    )
+    tid = target.json()["id"]
+
+    resp = await async_client.patch(
+        f"/api/v1/users/{tid}",
+        headers=headers,
+        json={"role": "admin"},
+    )
+    assert resp.status_code == 200
+    assert resp.json()["role"] == "admin"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_administrators_group_member_passes_admin_gate(async_client: AsyncClient, db_session):
+    """A user whose admin status comes from Administrators-group membership
+    rather than the legacy ``role`` column must pass the admin gate. The
+    canonical signal is ``User.is_admin``, not ``role == 'admin'``.
+
+    Uses a write endpoint (PATCH /users/{id} {role}) since the admin gate
+    lives on writes only — reads stay at ``USERS_READ`` so operator UIs
+    (Stats filter-by-user, Archives Print Log, File Manager username
+    autocomplete) keep working for non-admin operators who hold the
+    read permission via a custom group."""
+    admin_token = await _setup_admin(async_client)
+    headers = {"Authorization": f"Bearer {admin_token}"}
+    admin_gid = await _admin_group_id(db_session)
+
+    # Create a regular user, then add them to Administrators.
+    user_resp = await async_client.post(
+        "/api/v1/users/",
+        headers=headers,
+        json={"username": "groupadmin", "password": _FIXTURE_PW, "role": "user"},
+    )
+    uid = user_resp.json()["id"]
+    add = await async_client.post(f"/api/v1/groups/{admin_gid}/users/{uid}", headers=headers)
+    assert add.status_code == 204
+
+    # Also create a separate target user to mutate (cleaner than self-modify).
+    target_resp = await async_client.post(
+        "/api/v1/users/",
+        headers=headers,
+        json={"username": "target_member", "password": _FIXTURE_PW, "role": "user"},
+    )
+    target_id = target_resp.json()["id"]
+
+    login = await async_client.post("/api/v1/auth/login", json={"username": "groupadmin", "password": _FIXTURE_PW})
+    group_admin_token = login.json()["access_token"]
+
+    # Through an admin-gated write route — must succeed.
+    resp = await async_client.patch(
+        f"/api/v1/users/{target_id}",
+        headers={"Authorization": f"Bearer {group_admin_token}"},
+        json={"is_active": False},
+    )
+    assert resp.status_code == 200
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_users_read_remains_delegable_to_non_admin(async_client: AsyncClient, db_session):
+    """Operator-visible UIs (Stats filter-by-user, Archives Print Log
+    username column, File Manager username autocomplete) reach
+    ``GET /users/`` for non-admin operators when a deployment granted
+    them ``users:read`` via a custom group. The admin gate must NOT
+    apply to read endpoints — only to writes."""
+    admin_token = await _setup_admin(async_client)
+    op_token, _ = await _create_operator_with_perms(
+        async_client, admin_token, db_session, username="reader", permissions=["users:read"]
+    )
+
+    resp = await async_client.get("/api/v1/users/", headers={"Authorization": f"Bearer {op_token}"})
+    assert resp.status_code == 200
+    # Operator is in the list with is_admin=False — confirms the read is
+    # working AND the operator hasn't escalated.
+    me = next(u for u in resp.json() if u["username"] == "reader")
+    assert me["is_admin"] is False
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_groups_read_remains_delegable_to_non_admin(async_client: AsyncClient, db_session):
+    """Companion to ``users:read``. ``GET /groups/`` + ``GET /groups/
+    permissions`` stay reachable to non-admin operators with the read
+    permission. Used by setup wizards / informational lookups."""
+    admin_token = await _setup_admin(async_client)
+    op_token, _ = await _create_operator_with_perms(
+        async_client, admin_token, db_session, username="greader", permissions=["groups:read"]
+    )
+
+    headers = {"Authorization": f"Bearer {op_token}"}
+    list_resp = await async_client.get("/api/v1/groups/", headers=headers)
+    assert list_resp.status_code == 200
+    perms_resp = await async_client.get("/api/v1/groups/permissions", headers=headers)
+    assert perms_resp.status_code == 200

+ 5 - 5
backend/tests/unit/services/test_background_dispatch.py

@@ -231,11 +231,11 @@ def test_dispatch_option_defaults_align_with_request_schema_defaults():
     from backend.app.schemas.library import FilePrintRequest
     from backend.app.services import background_dispatch as bd
 
-    # `timelapse` deliberately excluded — the dispatcher now resolves it via
-    # ``_resolve_effective_timelapse`` so the value passed to ``start_print``
-    # depends on the ``capture_finish_photo`` setting + ``bambuddy_forced_timelapse``
-    # column (#1397). The original literal `job.options.get("timelapse", False)`
-    # pattern no longer appears.
+    # `timelapse` deliberately excluded — the dispatcher wraps it in
+    # ``bool(...)`` (``effective_timelapse = bool(job.options.get("timelapse",
+    # False))``) so the bare-pattern needle in the loop below would miss it.
+    # The wrap exists to coerce None / non-bool option payloads to a bool
+    # boundary the printer firmware accepts (#1721 follow-up).
     fields = ("bed_levelling", "flow_cali", "vibration_cali", "layer_inspect", "use_ams")
     reprint_defaults = {f: getattr(ReprintRequest(), f) for f in fields}
     libprint_defaults = {f: getattr(FilePrintRequest(), f) for f in fields}

+ 0 - 16
backend/tests/unit/services/test_background_dispatch_watchdog.py

@@ -606,14 +606,6 @@ class TestReprintArchiveDispatchWiring:
                 new_callable=AsyncMock,
             ),
             patch("backend.app.main.register_expected_print"),
-            # #1397: _resolve_effective_timelapse touches DB + setting layer
-            # that this watchdog-focused test isn't equipped to mock. Stub
-            # the whole helper so the dispatch flow proceeds unaffected.
-            patch.object(
-                BackgroundDispatchService,
-                "_resolve_effective_timelapse",
-                new=AsyncMock(return_value=False),
-            ),
             pytest.raises(RuntimeError, match="did not acknowledge print command"),
         ):
             await service._run_reprint_archive(job)
@@ -682,14 +674,6 @@ class TestReprintArchiveDispatchWiring:
                 new_callable=AsyncMock,
             ),
             patch("backend.app.main.register_expected_print"),
-            # #1397: _resolve_effective_timelapse touches DB + setting layer
-            # that this watchdog-focused test isn't equipped to mock. Stub
-            # the whole helper so the dispatch flow proceeds unaffected.
-            patch.object(
-                BackgroundDispatchService,
-                "_resolve_effective_timelapse",
-                new=AsyncMock(return_value=False),
-            ),
         ):
             await service._run_reprint_archive(job)  # must not raise
 

+ 27 - 6
backend/tests/unit/services/test_bambu_ftp.py

@@ -575,26 +575,32 @@ class TestDelete:
 
     def test_delete_success(self, ftp_client_factory, ftp_server):
         """Successful file deletion."""
+        from backend.app.services.bambu_ftp import DeleteResult
+
         ftp_server.add_file("cache/to_delete.bin", b"delete me")
         client = ftp_client_factory()
         client.connect()
         result = client.delete_file("/cache/to_delete.bin")
-        assert result is True
+        assert result == DeleteResult.DELETED
         assert not ftp_server.file_exists("cache/to_delete.bin")
         client.disconnect()
 
     def test_delete_not_found(self, ftp_client_factory):
-        """Deleting a nonexistent file returns False."""
+        """Deleting a nonexistent file returns NOT_FOUND (550, #1721)."""
+        from backend.app.services.bambu_ftp import DeleteResult
+
         client = ftp_client_factory()
         client.connect()
         result = client.delete_file("/cache/no_such_file.bin")
-        assert result is False
+        assert result == DeleteResult.NOT_FOUND
         client.disconnect()
 
     def test_delete_not_connected(self):
-        """Delete when not connected returns False."""
+        """Delete when not connected returns FAILED."""
+        from backend.app.services.bambu_ftp import DeleteResult
+
         client = BambuFTPClient("127.0.0.1", "12345678")
-        assert client.delete_file("/cache/test.bin") is False
+        assert client.delete_file("/cache/test.bin") == DeleteResult.FAILED
 
 
 # ---------------------------------------------------------------------------
@@ -1053,6 +1059,8 @@ class TestAsyncWrappers:
     @pytest.mark.asyncio
     async def test_delete_file_async_success(self, patch_ftp_port):
         """delete_file_async deletes a file."""
+        from backend.app.services.bambu_ftp import DeleteResult
+
         server = patch_ftp_port
         server.add_file("cache/to_async_del.bin", b"delete me")
         result = await delete_file_async(
@@ -1061,9 +1069,22 @@ class TestAsyncWrappers:
             "/cache/to_async_del.bin",
             printer_model="X1C",
         )
-        assert result is True
+        assert result == DeleteResult.DELETED
         assert not server.file_exists("cache/to_async_del.bin")
 
+    @pytest.mark.asyncio
+    async def test_delete_file_async_not_found(self, patch_ftp_port):
+        """delete_file_async distinguishes 550 from real failure (#1721)."""
+        from backend.app.services.bambu_ftp import DeleteResult
+
+        result = await delete_file_async(
+            "127.0.0.1",
+            "12345678",
+            "/cache/never_existed.bin",
+            printer_model="X1C",
+        )
+        assert result == DeleteResult.NOT_FOUND
+
 
 # ---------------------------------------------------------------------------
 # TestFailureScenarios

+ 216 - 15
backend/tests/unit/services/test_bambu_mqtt.py

@@ -1197,6 +1197,140 @@ class TestAMSTrayStateClearning:
         assert tray0["remain"] == 75
 
 
+class TestApplyTrayExistBitsHelper:
+    """Direct contract pinning for the shared ``apply_tray_exist_bits`` helper.
+
+    The same logic is exercised end-to-end via ``_handle_ams_data`` in the
+    internal-state suite and via ``_on_printer_raw`` in the bridge suite,
+    but those go through the merge / cache layers — the helper itself
+    deserves direct coverage so future refactors don't silently change
+    the contract both callers depend on (#1726).
+    """
+
+    def test_returns_zero_on_missing_bits(self):
+        from backend.app.services.bambu_mqtt import apply_tray_exist_bits
+
+        units = [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}]}]
+        assert apply_tray_exist_bits(units, None) == 0
+        assert apply_tray_exist_bits(units, "") == 0
+        # Untouched.
+        assert units[0]["tray"][0]["tray_type"] == "PLA"
+
+    def test_returns_zero_on_unparseable_bits(self):
+        from backend.app.services.bambu_mqtt import apply_tray_exist_bits
+
+        units = [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}]}]
+        assert apply_tray_exist_bits(units, "garbage") == 0
+        assert units[0]["tray"][0]["tray_type"] == "PLA"
+
+    def test_shutdown_guard_zero_bits_with_power_off_skips(self):
+        from backend.app.services.bambu_mqtt import apply_tray_exist_bits
+
+        units = [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF"}]}]
+        cleared = apply_tray_exist_bits(units, "0", power_on_flag=False)
+        assert cleared == 0
+        # Slot preserved — wiping here would propagate phantom empties on
+        # every printer-off push.
+        assert units[0]["tray"][0]["tray_type"] == "PLA"
+
+    def test_zero_bits_with_power_on_still_clears(self):
+        from backend.app.services.bambu_mqtt import apply_tray_exist_bits
+
+        units = [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF"}]}]
+        cleared = apply_tray_exist_bits(units, "0", power_on_flag=True)
+        # Slot is genuinely empty per the printer's report.
+        assert cleared == 1
+        assert units[0]["tray"][0]["state"] == 9
+        assert units[0]["tray"][0]["tray_type"] == ""
+
+    def test_nonzero_bits_with_power_off_still_clears_removed_slot(self):
+        """#1365: X1C reports power_on_flag=False between prints while the
+        AMS keeps reporting its actual slot inventory. The guard must skip
+        ONLY the all-zero + power-off combination, not nonzero + power-off.
+        """
+        from backend.app.services.bambu_mqtt import apply_tray_exist_bits
+
+        units = [
+            {
+                "id": 0,
+                "tray": [
+                    {"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF"},
+                    {"id": 1, "tray_type": "PETG", "tray_color": "00FF00FF"},
+                ],
+            }
+        ]
+        # 0x1 = slot 0 loaded, slot 1 empty. Power off (steady-state idle).
+        cleared = apply_tray_exist_bits(units, "1", power_on_flag=False)
+        assert cleared == 1
+        assert units[0]["tray"][0]["tray_type"] == "PLA"
+        assert units[0]["tray"][1]["tray_type"] == ""
+
+    def test_promotes_state_to_int_nine(self):
+        """Downstream `tray_state in {9, 10}` uses `==` — int 9, not "9"."""
+        from backend.app.services.bambu_mqtt import apply_tray_exist_bits
+
+        units = [{"id": 0, "tray": [{"id": 0, "state": "11"}]}]
+        apply_tray_exist_bits(units, "0", power_on_flag=True)
+        assert units[0]["tray"][0]["state"] == 9
+        assert isinstance(units[0]["tray"][0]["state"], int)
+
+    def test_ams_ht_unit_skipped(self):
+        """AMS-HT (id >= 128) uses a different addressing scheme."""
+        from backend.app.services.bambu_mqtt import apply_tray_exist_bits
+
+        units = [{"id": 128, "tray": [{"id": 0, "tray_type": "PLA"}]}]
+        cleared = apply_tray_exist_bits(units, "0", power_on_flag=True)
+        assert cleared == 0
+        assert units[0]["tray"][0]["tray_type"] == "PLA"
+
+    def test_string_ids_handled(self):
+        """Bridge cache stores ids as strings (JSON wire format)."""
+        from backend.app.services.bambu_mqtt import apply_tray_exist_bits
+
+        units = [
+            {
+                "id": "0",
+                "tray": [
+                    {"id": "0", "tray_type": "PLA"},
+                    {"id": "1", "tray_type": "PETG"},
+                ],
+            }
+        ]
+        # 0x1 = bit 0 set (slot 0), bit 1 clear (slot 1 empty).
+        cleared = apply_tray_exist_bits(units, "1", power_on_flag=True)
+        assert cleared == 1
+        assert units[0]["tray"][0]["tray_type"] == "PLA"
+        assert units[0]["tray"][1]["tray_type"] == ""
+
+    def test_multi_ams_global_bit_math(self):
+        """global_bit = ams_id * 4 + tray_id. Verify AMS 1 slots use
+        bits 4-7 of the mask, not bits 0-3."""
+        from backend.app.services.bambu_mqtt import apply_tray_exist_bits
+
+        units = [
+            {"id": 0, "tray": [{"id": i, "tray_type": "PLA"} for i in range(4)]},
+            {"id": 1, "tray": [{"id": i, "tray_type": "PETG"} for i in range(4)]},
+        ]
+        # 0x0f: all slots of AMS 0 loaded, all slots of AMS 1 empty.
+        cleared = apply_tray_exist_bits(units, "f", power_on_flag=True)
+        assert cleared == 4
+        for i in range(4):
+            assert units[0]["tray"][i]["tray_type"] == "PLA"
+            assert units[1]["tray"][i]["tray_type"] == ""
+
+    def test_state_promoted_even_when_no_stale_data(self):
+        """Slot without `tray_type` still gets state=9 — the bitmask is
+        authoritative, the field wipe just avoids extra log lines.
+        """
+        from backend.app.services.bambu_mqtt import apply_tray_exist_bits
+
+        units = [{"id": 0, "tray": [{"id": 0, "state": "11"}]}]
+        cleared = apply_tray_exist_bits(units, "0", power_on_flag=True)
+        # No tray_type to clear → cleared counter stays 0 but state is set.
+        assert cleared == 0
+        assert units[0]["tray"][0]["state"] == 9
+
+
 class TestNozzleRackData:
     """Tests for nozzle rack data parsing from H2 series device.nozzle.info."""
 
@@ -2752,6 +2886,67 @@ class TestTrayNowDualNozzleH2DActiveExtruder(_H2DFixtureMixin):
         assert h2d_client.state.tray_now == 128
 
 
+# ---------------------------------------------------------------------------
+# 8. Device identification probe (#1684 enabler)
+# ---------------------------------------------------------------------------
+
+
+class TestDeviceIdentificationProbe:
+    """One-shot INFO log of any device.* identification fields the firmware
+    sends. Lets a new-model support bundle self-disclose the internal model
+    code (e.g. dev_model_name='N2L') without a separate debug build.
+    """
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST_PROBE",
+            access_code="12345678",
+        )
+
+    def _device_payload(self, device):
+        return {"print": {"device": device}}
+
+    def test_logs_known_id_fields_once(self, mqtt_client, caplog):
+        import logging
+
+        caplog.set_level(logging.INFO, logger="backend.app.services.bambu_mqtt")
+        mqtt_client._process_message(
+            self._device_payload({"dev_model_name": "N2S", "dev_product_name": "Bambu Lab A1"})
+        )
+        matches = [r for r in caplog.records if "Device identification" in r.getMessage()]
+        assert len(matches) == 1
+        msg = matches[0].getMessage()
+        assert "dev_model_name" in msg and "N2S" in msg
+        assert "dev_product_name" in msg
+
+    def test_one_shot_does_not_repeat(self, mqtt_client, caplog):
+        import logging
+
+        caplog.set_level(logging.INFO, logger="backend.app.services.bambu_mqtt")
+        payload = self._device_payload({"dev_model_name": "N2S"})
+        mqtt_client._process_message(payload)
+        mqtt_client._process_message(payload)
+        mqtt_client._process_message(payload)
+        matches = [r for r in caplog.records if "Device identification" in r.getMessage()]
+        assert len(matches) == 1
+
+    def test_fallback_dumps_keys_when_no_known_fields(self, mqtt_client, caplog):
+        """Future Bambu rename (e.g. model_name without dev_ prefix) still surfaces."""
+        import logging
+
+        caplog.set_level(logging.INFO, logger="backend.app.services.bambu_mqtt")
+        mqtt_client._process_message(self._device_payload({"model_name": "MysteryModel", "extruder": {"state": 0}}))
+        matches = [r for r in caplog.records if "Device identification" in r.getMessage()]
+        assert len(matches) == 1
+        msg = matches[0].getMessage()
+        assert "no known id fields" in msg
+        assert "model_name" in msg and "extruder" in msg
+
+
 # ---------------------------------------------------------------------------
 # 8. H2D Full multi-message sequences
 # ---------------------------------------------------------------------------
@@ -3765,8 +3960,12 @@ class TestStartPrintAmsMapping:
         cmd = self._get_published_command(mqtt_client)
         assert cmd["timelapse"] is True
         assert cmd["flow_cali"] is False
-        # flow_cali off → extrude_cali_flag=2 (skip, reuse stored PA value).
-        assert cmd["extrude_cali_flag"] == 2
+        # 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.
+        assert cmd["extrude_cali_flag"] == 0
 
     def test_h2s_single_external_spool_uses_main_id(self, mqtt_client):
         """H2S is single-nozzle (#1386): external spool (254) → ams_id=255.
@@ -3827,18 +4026,18 @@ class TestStartPrintAmsMapping:
         assert cmd["extrude_cali_flag"] == 1
 
     def test_nozzle_offset_cali_default_is_skip(self, mqtt_client):
-        """Default `nozzle_offset_cali=False` → wire value `2` (skip).
+        """Default `nozzle_offset_cali=False` → wire value `0` (skip).
 
-        Matches the legacy behavior on every model: BambuStudio sends `2`
-        unless the user enabled the toggle for a dual-nozzle machine. The
-        legacy hardcoded value before #1682 was `2` for everyone — this
-        test pins that default so we don't regress.
+        #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.
         """
         mqtt_client.model = "P1S"
         mqtt_client.start_print("test.3mf")
 
         cmd = self._get_published_command(mqtt_client)
-        assert cmd["nozzle_offset_cali"] == 2
+        assert cmd["nozzle_offset_cali"] == 0
 
     def test_nozzle_offset_cali_ignored_on_single_nozzle(self, mqtt_client):
         """Single-nozzle printer: `nozzle_offset_cali=True` is silently dropped.
@@ -3848,20 +4047,21 @@ class TestStartPrintAmsMapping:
         behind `nozzle_count==2`. Even if a stale queue item from when the
         printer was misidentified as dual carries the flag, the MQTT layer
         must downgrade it so firmware never tries to calibrate a head it
-        doesn't have (#1682).
+        doesn't have (#1682). `0` is the actually-honoured skip value
+        post-#1721; old `2` left the stage in the queue.
         """
         mqtt_client.model = "P1S"
         mqtt_client.start_print("test.3mf", nozzle_offset_cali=True)
 
         cmd = self._get_published_command(mqtt_client)
-        assert cmd["nozzle_offset_cali"] == 2
+        assert cmd["nozzle_offset_cali"] == 0
 
     def test_nozzle_offset_cali_honored_on_dual_nozzle(self, mqtt_client):
         """Dual-nozzle printer (H2D): `nozzle_offset_cali=True` → wire value `1`.
 
         H2D is in `DUAL_NOZZLE_MODELS`. The toggle controls whether the
         printer runs the nozzle-offset calibration pass before the print
-        starts. `1`=run, `2`=skip — matches BambuStudio's encoding (#1682).
+        starts. `1`=run (#1682).
         """
         mqtt_client.model = "H2D"
         mqtt_client.start_print("test.3mf", nozzle_offset_cali=True)
@@ -3870,16 +4070,17 @@ class TestStartPrintAmsMapping:
         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` → `2` (skip).
+        """Dual-nozzle printer (H2D Pro): `nozzle_offset_cali=False` → `0` (skip).
 
-        Same wire encoding as legacy. Critical for users like #1682 who run
-        diamond nozzles and need to keep the calibration off.
+        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.
         """
         mqtt_client.model = "H2D Pro"
         mqtt_client.start_print("test.3mf", nozzle_offset_cali=False)
 
         cmd = self._get_published_command(mqtt_client)
-        assert cmd["nozzle_offset_cali"] == 2
+        assert cmd["nozzle_offset_cali"] == 0
 
 
 class TestStartPrintUniqueIdentityFields:

+ 0 - 156
backend/tests/unit/services/test_dispatch_force_timelapse.py

@@ -1,156 +0,0 @@
-"""Tests for _resolve_effective_timelapse (#1397).
-
-Bambuddy forces timelapse recording on at dispatch time when the
-capture_finish_photo setting is enabled and the user did not opt in
-to timelapse for the specific print. The forced bit is recorded on
-the archive so the post-extraction cleanup path can delete the file.
-
-These tests exercise the four decision shapes the helper has to handle:
-
-  1. capture_finish_photo OFF → no override regardless of user choice
-  2. capture_finish_photo ON, user chose timelapse → no override (the
-     user's choice already covers the photo path)
-  3. capture_finish_photo ON, user chose NO timelapse → override to ON,
-     mark archive.bambuddy_forced_timelapse=True
-  4. capture_finish_photo unset (None / missing) → defaults to ON, so
-     the same override applies as case 3
-"""
-
-from types import SimpleNamespace
-from unittest.mock import AsyncMock, patch
-
-import pytest
-
-from backend.app.services.background_dispatch import (
-    BackgroundDispatchService,
-    PrintDispatchJob,
-)
-
-
-def _make_job(timelapse: bool | None) -> PrintDispatchJob:
-    """Mint a job with the smallest valid shape — the only field
-    _resolve_effective_timelapse reads from job is `options`."""
-    return PrintDispatchJob(
-        id=1,
-        kind="print_library_file",
-        source_id=42,
-        source_name="test.gcode.3mf",
-        printer_id=10,
-        printer_name="Printer A",
-        options={"timelapse": timelapse} if timelapse is not None else {},
-    )
-
-
-def _make_archive() -> SimpleNamespace:
-    """Stand-in archive object; the helper only touches .id and
-    .bambuddy_forced_timelapse."""
-    return SimpleNamespace(id=99, bambuddy_forced_timelapse=False)
-
-
-def _make_db() -> AsyncMock:
-    """Fake db with a no-op .commit()."""
-    db = AsyncMock()
-    return db
-
-
-@pytest.mark.asyncio
-async def test_capture_finish_photo_off_means_no_override():
-    """Master toggle off → user's timelapse=False stays False, no flag set."""
-    service = BackgroundDispatchService()
-    archive = _make_archive()
-    db = _make_db()
-    job = _make_job(timelapse=False)
-
-    with patch(
-        "backend.app.api.routes.settings.get_setting",
-        new=AsyncMock(return_value="false"),
-    ):
-        effective = await service._resolve_effective_timelapse(db, archive, job)
-
-    assert effective is False
-    assert archive.bambuddy_forced_timelapse is False
-    db.commit.assert_not_awaited()
-
-
-@pytest.mark.asyncio
-async def test_user_opted_in_passes_through_unchanged():
-    """User asked for a timelapse → no override needed (their normal flow
-    already records one). bambuddy_forced_timelapse stays False so cleanup
-    leaves the file alone."""
-    service = BackgroundDispatchService()
-    archive = _make_archive()
-    db = _make_db()
-    job = _make_job(timelapse=True)
-
-    # get_setting shouldn't even be consulted — but if it is, no override
-    # should still fire.
-    with patch(
-        "backend.app.api.routes.settings.get_setting",
-        new=AsyncMock(return_value="true"),
-    ):
-        effective = await service._resolve_effective_timelapse(db, archive, job)
-
-    assert effective is True
-    assert archive.bambuddy_forced_timelapse is False
-    db.commit.assert_not_awaited()
-
-
-@pytest.mark.asyncio
-async def test_capture_on_user_off_forces_timelapse_and_marks_flag():
-    """The whole point of the fix: capture_finish_photo=on + user-timelapse=off
-    flips the MQTT command to timelapse=True and marks the archive for
-    post-extraction cleanup."""
-    service = BackgroundDispatchService()
-    archive = _make_archive()
-    db = _make_db()
-    job = _make_job(timelapse=False)
-
-    with patch(
-        "backend.app.api.routes.settings.get_setting",
-        new=AsyncMock(return_value="true"),
-    ):
-        effective = await service._resolve_effective_timelapse(db, archive, job)
-
-    assert effective is True
-    assert archive.bambuddy_forced_timelapse is True
-    db.commit.assert_awaited_once()
-
-
-@pytest.mark.asyncio
-async def test_capture_finish_photo_unset_defaults_to_enabled():
-    """Setting absent from DB → default is True (per the Field default in the
-    schema), so the override fires just like when explicitly enabled."""
-    service = BackgroundDispatchService()
-    archive = _make_archive()
-    db = _make_db()
-    job = _make_job(timelapse=False)
-
-    with patch(
-        "backend.app.api.routes.settings.get_setting",
-        new=AsyncMock(return_value=None),
-    ):
-        effective = await service._resolve_effective_timelapse(db, archive, job)
-
-    assert effective is True
-    assert archive.bambuddy_forced_timelapse is True
-    db.commit.assert_awaited_once()
-
-
-@pytest.mark.asyncio
-async def test_user_missing_timelapse_treated_as_false():
-    """Some queue paths pass options without a timelapse key. Treat absent
-    as False (matches existing job.options.get('timelapse', False) default
-    that the caller previously used)."""
-    service = BackgroundDispatchService()
-    archive = _make_archive()
-    db = _make_db()
-    job = _make_job(timelapse=None)  # falls through to {}
-
-    with patch(
-        "backend.app.api.routes.settings.get_setting",
-        new=AsyncMock(return_value="true"),
-    ):
-        effective = await service._resolve_effective_timelapse(db, archive, job)
-
-    assert effective is True
-    assert archive.bambuddy_forced_timelapse is True

+ 128 - 2
backend/tests/unit/services/test_preset_resolver.py

@@ -157,7 +157,16 @@ async def test_cloud_unwraps_setting_envelope():
     ):
         out = await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="PFU123"), slot="printer")
     payload = json.loads(out)
-    assert payload == {"name": "X1C Custom", "nozzle_diameter": [0.4]}
+    # Resolver rewrites the `type` field to the CLI-expected value AND pins
+    # `from: "system"` (#1712 follow-up: Bambu Cloud labels printers as
+    # "printer" and filaments routinely ship with empty `from`; the CLI
+    # rejects either with the same -5 "input preset invalid" surface).
+    assert payload == {
+        "name": "X1C Custom",
+        "nozzle_diameter": [0.4],
+        "type": "machine",
+        "from": "system",
+    }
     cloud_mock.close.assert_awaited_once()
 
 
@@ -186,6 +195,115 @@ async def test_cloud_falls_back_to_top_level_when_no_envelope():
     assert "name" in payload
 
 
+@pytest.mark.parametrize(
+    "slot, source_type, expected_type",
+    [
+        # Bambu Cloud's wire shape: `printer` / `print` / `filament`. The CLI
+        # only accepts `machine` / `process` / `filament`. Without rewrite
+        # the CLI exits -5 with `operator(): unknown config type` and the
+        # sidecar surfaces "The input preset file is invalid and can not be
+        # parsed" (#1712 follow-up, reported by maziggy on Mecha Mewtwo).
+        ("printer", "printer", "machine"),
+        ("process", "print", "process"),
+        ("filament", "filament", "filament"),
+        # Cloud-side already CLI-shaped: still gets overwritten to the
+        # canonical value — idempotent, no harm.
+        ("printer", "machine", "machine"),
+        ("process", "process", "process"),
+        # Missing type field on the source payload: synthesise it.
+        ("printer", None, "machine"),
+        ("process", None, "process"),
+    ],
+)
+@pytest.mark.asyncio
+async def test_cloud_rewrites_type_field_for_cli(slot, source_type, expected_type):
+    db = MagicMock()
+    user = MagicMock()
+    user.has_permission = MagicMock(return_value=True)
+    setting: dict = {"name": "P"}
+    if source_type is not None:
+        setting["type"] = source_type
+    cloud_mock = MagicMock()
+    cloud_mock.set_token = MagicMock()
+    cloud_mock.get_setting_detail = AsyncMock(return_value={"setting": setting})
+    cloud_mock.close = AsyncMock()
+    with (
+        patch.object(
+            preset_resolver,
+            "get_stored_token",
+            AsyncMock(return_value=("tok", None, "global")),
+        ),
+        patch.object(preset_resolver, "BambuCloudService", return_value=cloud_mock),
+    ):
+        out = await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="X"), slot=slot)
+    assert json.loads(out)["type"] == expected_type
+
+
+@pytest.mark.parametrize(
+    "source_from",
+    [
+        # The actual failing case (#1712 follow-up): Bambu Cloud's filament
+        # detail endpoint routinely returns presets with no `from` field or
+        # `from: ""`. The CLI rejects either with
+        # `operator(): ... from  unsupported` (note the double space — that's
+        # the literal stderr from the sidecar log on the Mecha Mewtwo slice).
+        "",
+        # Cloud-side already CLI-friendly: still gets pinned to "system" —
+        # idempotent, no harm, matches the standard-tier convention.
+        "system",
+        # GUI-exported values that the sidecar's normalizeFromField also
+        # maps to "system" for the same reason — we beat it to the punch.
+        "User",
+        "System",
+    ],
+)
+@pytest.mark.asyncio
+async def test_cloud_pins_from_field_to_system(source_from):
+    db = MagicMock()
+    user = MagicMock()
+    user.has_permission = MagicMock(return_value=True)
+    setting: dict = {"name": "F", "type": "filament", "from": source_from}
+    cloud_mock = MagicMock()
+    cloud_mock.set_token = MagicMock()
+    cloud_mock.get_setting_detail = AsyncMock(return_value={"setting": setting})
+    cloud_mock.close = AsyncMock()
+    with (
+        patch.object(
+            preset_resolver,
+            "get_stored_token",
+            AsyncMock(return_value=("tok", None, "global")),
+        ),
+        patch.object(preset_resolver, "BambuCloudService", return_value=cloud_mock),
+    ):
+        out = await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="X"), slot="filament")
+    assert json.loads(out)["from"] == "system"
+
+
+@pytest.mark.asyncio
+async def test_cloud_synthesises_from_field_when_missing():
+    """The original failing payload had no `from` field at all (sidecar
+    error: `from  unsupported` — double space = empty value). The resolver
+    must still emit a usable `from` instead of forwarding the gap."""
+    db = MagicMock()
+    user = MagicMock()
+    user.has_permission = MagicMock(return_value=True)
+    setting = {"name": "F", "type": "filament"}  # NB: no `from`
+    cloud_mock = MagicMock()
+    cloud_mock.set_token = MagicMock()
+    cloud_mock.get_setting_detail = AsyncMock(return_value={"setting": setting})
+    cloud_mock.close = AsyncMock()
+    with (
+        patch.object(
+            preset_resolver,
+            "get_stored_token",
+            AsyncMock(return_value=("tok", None, "global")),
+        ),
+        patch.object(preset_resolver, "BambuCloudService", return_value=cloud_mock),
+    ):
+        out = await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="X"), slot="filament")
+    assert json.loads(out)["from"] == "system"
+
+
 @pytest.mark.asyncio
 async def test_cloud_auth_error_returns_401():
     db = MagicMock()
@@ -246,7 +364,15 @@ async def test_orca_cloud_unwraps_content():
             db, user, PresetRef(source="orca_cloud", id="abc"), slot="printer"
         )
     payload = json.loads(out)
-    assert payload == {"name": "X1C Custom", "nozzle_diameter": [0.4]}
+    # Resolver rewrites `type` to the CLI-expected value AND pins
+    # `from: "system"` (#1712 follow-up). Orca natively uses "machine" but
+    # Bambu-sourced syncs can carry "printer" and either empty/missing `from`.
+    assert payload == {
+        "name": "X1C Custom",
+        "nozzle_diameter": [0.4],
+        "type": "machine",
+        "from": "system",
+    }
     svc_mock.close.assert_awaited_once()
 
 

+ 26 - 2
backend/tests/unit/services/test_printer_diagnostic.py

@@ -85,8 +85,8 @@ class _Env:
         return False
 
 
-def _printer(ip="192.168.1.50"):
-    return types.SimpleNamespace(id=1, ip_address=ip)
+def _printer(ip="192.168.1.50", model=None):
+    return types.SimpleNamespace(id=1, ip_address=ip, model=model)
 
 
 class TestSameSubnet:
@@ -295,3 +295,27 @@ class TestExternalStorageCheck:
         with _Env(state=bare):
             result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
         assert _statuses(result)["external_storage"] == "skip"
+
+    async def test_skips_on_a1_no_external_storage_slot(self):
+        # Regression for #1703: A1 and A1 Mini ship without a MicroSD slot
+        # at all, so home_flag bit 11 is never set and a naive read would
+        # report `fail` for every A1-series user. The model-aware skip
+        # branch suppresses that — and the overall result must NOT escalate
+        # to "problems" purely because of this check.
+        with _Env(state=_state(store_to_sdcard=False)):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer(model="A1"))
+        assert _statuses(result)["external_storage"] == "skip"
+        assert result.overall == "ok"
+
+    async def test_skips_on_a1_mini_no_external_storage_slot(self):
+        with _Env(state=_state(store_to_sdcard=False)):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer(model="A1 Mini"))
+        assert _statuses(result)["external_storage"] == "skip"
+
+    async def test_still_fails_on_x1c_when_toggle_off(self):
+        # Sanity: the model-aware skip MUST NOT silently let X1C-class
+        # printers off the hook. The store_to_sdcard=False path is the
+        # one real bit of value this check provides for those models.
+        with _Env(state=_state(store_to_sdcard=False)):
+            result = await run_connection_diagnostic("192.168.1.50", printer=_printer(model="X1C"))
+        assert _statuses(result)["external_storage"] == "fail"

+ 0 - 182
backend/tests/unit/services/test_slice_preview.py

@@ -82,17 +82,6 @@ class _StubService:
             filament_used_mm=0.0,
         )
 
-    async def slice_with_bundle(self, **kw):
-        self.calls.append({"method": "slice_with_bundle", **kw})
-        if self.raise_exc is not None:
-            raise self.raise_exc
-        return SliceResult(
-            content=self.response_bytes or b"",
-            print_time_seconds=0,
-            filament_used_g=0.0,
-            filament_used_mm=0.0,
-        )
-
 
 # ---------------------------------------------------------------------------
 # _parse_filaments_from_sliced_3mf — pure-function parsing tests.
@@ -265,174 +254,3 @@ class TestGetPreviewFilaments:
         assert len(slice_preview._preview_cache) == _PREVIEW_CACHE_MAX
         # Lock dict is also pruned (no leak): same size as cache.
         assert len(slice_preview._preview_locks) == _PREVIEW_CACHE_MAX
-
-
-# ---------------------------------------------------------------------------
-# Bundle-aware preview path — when bundle context is supplied, the preview
-# routes through `slice_with_bundle` so its gram numbers reflect the same
-# triplet the real print will use. Cache must distinguish between bundle
-# picks so a fresh selection doesn't re-serve a prior preview's output.
-# ---------------------------------------------------------------------------
-
-
-class TestBundleAwarePreview:
-    @pytest.mark.asyncio
-    async def test_full_bundle_context_uses_slice_with_bundle(self):
-        body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
-        stub = _StubService(response_bytes=body)
-        with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
-            result = await get_preview_filaments(
-                kind="library_file",
-                source_id=42,
-                plate_id=1,
-                file_bytes=b"abc",
-                file_name="x.3mf",
-                api_url="http://sidecar",
-                bundle_id="abc123",
-                printer_name="# Bambu Lab H2D 0.4 nozzle",
-                process_name="# 0.20mm Standard @BBL H2D",
-                filament_names=["# Bambu PLA Basic @BBL H2D"],
-            )
-        assert result is not None
-        assert result[0]["slot_id"] == 1
-        # The bundle path engaged — slice_with_bundle was called, not the
-        # embedded-settings fallback.
-        assert len(stub.calls) == 1
-        assert stub.calls[0]["method"] == "slice_with_bundle"
-        assert stub.calls[0]["bundle_id"] == "abc123"
-        assert stub.calls[0]["filament_names"] == ["# Bambu PLA Basic @BBL H2D"]
-
-    @pytest.mark.asyncio
-    async def test_partial_bundle_context_falls_back_to_embedded(self):
-        # Modal-in-progress case: user picked a bundle id but hasn't yet
-        # picked the filament. Falling back to embedded settings keeps
-        # the preview's slot mapping fresh while gram numbers will firm
-        # up once the selection completes.
-        body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
-        stub = _StubService(response_bytes=body)
-        with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
-            await get_preview_filaments(
-                kind="library_file",
-                source_id=42,
-                plate_id=1,
-                file_bytes=b"abc",
-                file_name="x.3mf",
-                api_url="http://sidecar",
-                bundle_id="abc123",
-                printer_name="# Bambu Lab H2D 0.4 nozzle",
-                process_name="# 0.20mm Standard @BBL H2D",
-                # filament_names missing
-            )
-        assert len(stub.calls) == 1
-        assert stub.calls[0]["method"] == "slice_without_profiles"
-
-    @pytest.mark.asyncio
-    async def test_empty_filament_names_list_falls_back(self):
-        # Empty list (vs None) is treated as "incomplete context" since
-        # passing `[]` to slice_with_bundle would yield no
-        # --load-filaments arg and confuse the CLI.
-        body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
-        stub = _StubService(response_bytes=body)
-        with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
-            await get_preview_filaments(
-                kind="library_file",
-                source_id=42,
-                plate_id=1,
-                file_bytes=b"abc",
-                file_name="x.3mf",
-                api_url="http://sidecar",
-                bundle_id="abc123",
-                printer_name="P",
-                process_name="Q",
-                filament_names=[],
-            )
-        assert stub.calls[0]["method"] == "slice_without_profiles"
-
-    @pytest.mark.asyncio
-    async def test_cache_separates_bundle_picks(self):
-        # Same file/plate, two different bundle picks → two distinct cache
-        # entries → two slices run. Without the bundle-fingerprint cache key,
-        # the second call would erroneously serve the first's output.
-        body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
-        stub = _StubService(response_bytes=body)
-        with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
-            await get_preview_filaments(
-                kind="library_file",
-                source_id=42,
-                plate_id=1,
-                file_bytes=b"abc",
-                file_name="x.3mf",
-                api_url="http://sidecar",
-                bundle_id="bundleA",
-                printer_name="P",
-                process_name="Q",
-                filament_names=["F"],
-            )
-            await get_preview_filaments(
-                kind="library_file",
-                source_id=42,
-                plate_id=1,
-                file_bytes=b"abc",
-                file_name="x.3mf",
-                api_url="http://sidecar",
-                bundle_id="bundleB",
-                printer_name="P",
-                process_name="Q",
-                filament_names=["F"],
-            )
-        assert len(stub.calls) == 2
-        assert stub.calls[0]["bundle_id"] == "bundleA"
-        assert stub.calls[1]["bundle_id"] == "bundleB"
-
-    @pytest.mark.asyncio
-    async def test_cache_separates_bundle_vs_embedded(self):
-        # Same file/plate, one call without bundle and one with bundle →
-        # both must run. The embedded-settings cache entry must NOT be
-        # served as the bundle-picked result (gram numbers would be wrong).
-        body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
-        stub = _StubService(response_bytes=body)
-        with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
-            await get_preview_filaments(
-                kind="library_file",
-                source_id=42,
-                plate_id=1,
-                file_bytes=b"abc",
-                file_name="x.3mf",
-                api_url="http://sidecar",
-            )
-            await get_preview_filaments(
-                kind="library_file",
-                source_id=42,
-                plate_id=1,
-                file_bytes=b"abc",
-                file_name="x.3mf",
-                api_url="http://sidecar",
-                bundle_id="bundleA",
-                printer_name="P",
-                process_name="Q",
-                filament_names=["F"],
-            )
-        methods = [c["method"] for c in stub.calls]
-        assert methods == ["slice_without_profiles", "slice_with_bundle"]
-
-    @pytest.mark.asyncio
-    async def test_bundle_repeat_call_hits_cache(self):
-        # Sanity check that the new cache key is otherwise stable: same
-        # bundle pick on the same file → cache hit on second call.
-        body = _make_sliced_3mf(plate_id=1, filaments=[{"id": "1", "type": "PLA", "color": "#000"}])
-        stub = _StubService(response_bytes=body)
-        with patch.object(slice_preview, "SlicerApiService", lambda **kw: stub):
-            for _ in range(2):
-                await get_preview_filaments(
-                    kind="library_file",
-                    source_id=42,
-                    plate_id=1,
-                    file_bytes=b"abc",
-                    file_name="x.3mf",
-                    api_url="http://sidecar",
-                    bundle_id="bundleA",
-                    printer_name="P",
-                    process_name="Q",
-                    filament_names=["F"],
-                )
-        assert len(stub.calls) == 1

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

@@ -8,8 +8,6 @@ import httpx
 import pytest
 
 from backend.app.services.slicer_api import (
-    BundleNotFoundError,
-    BundleSummary,
     SlicerApiServerError,
     SlicerApiService,
     SlicerApiUnavailableError,
@@ -537,274 +535,3 @@ class TestSliceWithProfilesProgress:
         assert result is not None
         # Sustained 404 → no snapshots ever forwarded.
         assert snapshots == []
-
-
-# ── BundleSummary parsing + bundle CRUD client methods ─────────────────────
-
-
-class TestBundleClientMethods:
-    """Coverage for import_bundle / list_bundles / get_bundle / delete_bundle.
-
-    Mirrors the existing SlicerApiService tests' mock-transport pattern. The
-    bundle endpoints are simple JSON CRUD on the sidecar, but the response
-    parsing has to remain forgiving (newer sidecars may add fields, older
-    ones may omit some) and the failure modes have to map cleanly to our
-    typed exceptions so route handlers can pick the right HTTP status.
-    """
-
-    SAMPLE_SUMMARY = {
-        "id": "2bd8722dd20a837e",
-        "printer_preset_name": "# Bambu Lab H2D 0.4 nozzle",
-        "printer": ["# Bambu Lab H2D 0.4 nozzle"],
-        "process": ["# 0.20mm Standard @BBL H2D"],
-        "filament": ["# Bambu PLA Basic @BBL H2D"],
-        "version": "02.06.00.50",
-    }
-
-    @pytest.mark.asyncio
-    async def test_import_bundle_happy_path(self):
-        captured: dict = {}
-
-        def handler(request: httpx.Request) -> httpx.Response:
-            captured["url"] = str(request.url)
-            captured["method"] = request.method
-            captured["content_type"] = request.headers.get("content-type", "")
-            return httpx.Response(status_code=201, json=self.SAMPLE_SUMMARY)
-
-        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
-        summary = await service.import_bundle(b"PK\x03\x04zip-bytes", filename="H2D.bbscfg")
-
-        assert isinstance(summary, BundleSummary)
-        assert summary.id == "2bd8722dd20a837e"
-        assert summary.printer == ["# Bambu Lab H2D 0.4 nozzle"]
-        assert summary.process == ["# 0.20mm Standard @BBL H2D"]
-        assert summary.filament == ["# Bambu PLA Basic @BBL H2D"]
-        assert captured["method"] == "POST"
-        assert captured["url"].endswith("/profiles/bundle")
-        assert captured["content_type"].startswith("multipart/form-data")
-
-    @pytest.mark.asyncio
-    async def test_import_bundle_400_raises_input_error(self):
-        # Non-.bbscfg uploads, corrupt zips, malicious entry names — all
-        # rejected by the sidecar with 4xx so the user can fix and retry.
-        def handler(request: httpx.Request) -> httpx.Response:
-            return httpx.Response(
-                status_code=400,
-                json={"message": "Bundle is missing bundle_structure.json"},
-            )
-
-        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
-        with pytest.raises(SlicerInputError) as exc_info:
-            await service.import_bundle(b"not a zip")
-        assert "missing bundle_structure" in str(exc_info.value)
-
-    @pytest.mark.asyncio
-    async def test_import_bundle_5xx_raises_server_error(self):
-        # Disk-write failure on DATA_PATH — rare but observable when /data
-        # is a tmpfs that filled up.
-        def handler(request: httpx.Request) -> httpx.Response:
-            return httpx.Response(status_code=500, json={"message": "ENOSPC"})
-
-        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
-        with pytest.raises(SlicerApiServerError):
-            await service.import_bundle(b"x")
-
-    @pytest.mark.asyncio
-    async def test_import_bundle_connection_error(self):
-        def handler(request: httpx.Request) -> httpx.Response:
-            raise httpx.ConnectError("connection refused")
-
-        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
-        with pytest.raises(SlicerApiUnavailableError):
-            await service.import_bundle(b"x")
-
-    @pytest.mark.asyncio
-    async def test_list_bundles_returns_summaries(self):
-        def handler(request: httpx.Request) -> httpx.Response:
-            assert request.url.path == "/profiles/bundles"
-            return httpx.Response(status_code=200, json=[self.SAMPLE_SUMMARY])
-
-        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
-        bundles = await service.list_bundles()
-        assert len(bundles) == 1
-        assert bundles[0].id == self.SAMPLE_SUMMARY["id"]
-
-    @pytest.mark.asyncio
-    async def test_list_bundles_empty_array(self):
-        # Sidecar returns [] when no bundles imported yet — must not raise.
-        def handler(request: httpx.Request) -> httpx.Response:
-            return httpx.Response(status_code=200, json=[])
-
-        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
-        assert await service.list_bundles() == []
-
-    @pytest.mark.asyncio
-    async def test_list_bundles_non_array_raises(self):
-        # Older / mis-configured sidecar returning {} instead of []. Surface
-        # the bug with a clear server error rather than silently treating
-        # malformed payload as empty.
-        def handler(request: httpx.Request) -> httpx.Response:
-            return httpx.Response(status_code=200, json={"unexpected": "shape"})
-
-        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
-        with pytest.raises(SlicerApiServerError):
-            await service.list_bundles()
-
-    @pytest.mark.asyncio
-    async def test_get_bundle_404_raises_not_found(self):
-        def handler(request: httpx.Request) -> httpx.Response:
-            return httpx.Response(status_code=404, json={"message": "not found"})
-
-        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
-        with pytest.raises(BundleNotFoundError):
-            await service.get_bundle("deadbeef00000000")
-
-    @pytest.mark.asyncio
-    async def test_get_bundle_happy_path(self):
-        def handler(request: httpx.Request) -> httpx.Response:
-            assert request.url.path == "/profiles/bundles/2bd8722dd20a837e"
-            return httpx.Response(status_code=200, json=self.SAMPLE_SUMMARY)
-
-        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
-        summary = await service.get_bundle("2bd8722dd20a837e")
-        assert summary.id == "2bd8722dd20a837e"
-
-    @pytest.mark.asyncio
-    async def test_delete_bundle_204_succeeds_silently(self):
-        def handler(request: httpx.Request) -> httpx.Response:
-            assert request.method == "DELETE"
-            return httpx.Response(status_code=204)
-
-        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
-        # Should not raise.
-        await service.delete_bundle("2bd8722dd20a837e")
-
-    @pytest.mark.asyncio
-    async def test_delete_bundle_404_raises_not_found(self):
-        def handler(request: httpx.Request) -> httpx.Response:
-            return httpx.Response(status_code=404, json={"message": "not found"})
-
-        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
-        with pytest.raises(BundleNotFoundError):
-            await service.delete_bundle("missing")
-
-
-class TestSliceWithBundle:
-    """The bundle slice path takes the same model upload but replaces the
-    profile-attachment fields with bundle-id + preset-name form fields.
-    Coverage for the form shape, the multi-filament join, and the same
-    4xx/5xx mapping as slice_with_profiles."""
-
-    @pytest.mark.asyncio
-    async def test_form_fields_and_filament_join(self):
-        captured: dict = {}
-
-        def handler(request: httpx.Request) -> httpx.Response:
-            captured["url"] = str(request.url)
-            captured["body"] = request.content
-            captured["content_type"] = request.headers.get("content-type", "")
-            return httpx.Response(
-                status_code=200,
-                content=b"; G-CODE",
-                headers={
-                    "x-print-time-seconds": "60",
-                    "x-filament-used-g": "1.0",
-                    "x-filament-used-mm": "100.0",
-                },
-            )
-
-        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
-        result = await service.slice_with_bundle(
-            model_bytes=b"solid Cube\n",
-            model_filename="Cube.stl",
-            bundle_id="2bd8722dd20a837e",
-            printer_name="# Bambu Lab H2D 0.4 nozzle",
-            process_name="# 0.20mm Standard @BBL H2D",
-            filament_names=["# Bambu PLA Basic @BBL H2D", "# Bambu PETG HF @BBL H2D"],
-        )
-
-        assert isinstance(result, SliceResult)
-        assert result.print_time_seconds == 60
-        assert captured["url"].endswith("/slice")
-        assert captured["content_type"].startswith("multipart/form-data")
-        # Multi-filament joined with ';' — the sidecar's parser splits on
-        # both ';' and ',' so the wire format is the more-explicit ';'.
-        body = captured["body"]
-        assert b"# Bambu PLA Basic @BBL H2D;# Bambu PETG HF @BBL H2D" in body
-        # Each form field appears in the multipart body.
-        assert b'name="bundle"' in body
-        assert b'name="printerName"' in body
-        assert b'name="processName"' in body
-        assert b'name="filamentNames"' in body
-        # Bundle id round-trips on the wire.
-        assert b"2bd8722dd20a837e" in body
-
-    @pytest.mark.asyncio
-    async def test_arrange_true_emits_form_field(self):
-        """#1493: bundle dispatch also forwards arrange=True so cross-class
-        slices via .bbscfg bundles get the same BS auto-arrange behaviour
-        as the preset path."""
-        captured: dict = {}
-
-        def handler(request: httpx.Request) -> httpx.Response:
-            captured["body"] = request.content
-            return httpx.Response(
-                status_code=200,
-                content=b"3MF",
-                headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
-            )
-
-        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
-        await service.slice_with_bundle(
-            model_bytes=b"x",
-            model_filename="Cube.3mf",
-            bundle_id="abc",
-            printer_name="p",
-            process_name="pr",
-            filament_names=["f"],
-            arrange=True,
-        )
-
-        assert b'name="arrange"' in captured["body"]
-
-    @pytest.mark.asyncio
-    async def test_404_unknown_preset_maps_to_input_error(self):
-        # Sidecar returns 404 when bundle exists but preset name doesn't.
-        # The slice route classifies this as user-correctable input error,
-        # not server failure.
-        def handler(request: httpx.Request) -> httpx.Response:
-            return httpx.Response(
-                status_code=404,
-                json={"message": 'process preset "Imaginary" not found in bundle "abc"'},
-            )
-
-        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
-        with pytest.raises(SlicerInputError):
-            await service.slice_with_bundle(
-                model_bytes=b"x",
-                model_filename="Cube.stl",
-                bundle_id="abc",
-                printer_name="p",
-                process_name="Imaginary",
-                filament_names=["f"],
-            )
-
-    @pytest.mark.asyncio
-    async def test_5xx_maps_to_server_error(self):
-        # CLI segfault on the resolved triplet — same handling as slice_with_profiles.
-        def handler(request: httpx.Request) -> httpx.Response:
-            return httpx.Response(
-                status_code=500,
-                json={"message": "Slicer process failed (signal SIGSEGV)"},
-            )
-
-        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
-        with pytest.raises(SlicerApiServerError):
-            await service.slice_with_bundle(
-                model_bytes=b"x",
-                model_filename="Cube.3mf",
-                bundle_id="abc",
-                printer_name="p",
-                process_name="pr",
-                filament_names=["f"],
-            )

+ 201 - 0
backend/tests/unit/services/test_slot_preset_writer.py

@@ -0,0 +1,201 @@
+"""Tests for the shared slot_preset_mappings writer.
+
+The writer keeps PrintersPage's slot-card preset_name in sync with the
+currently-assigned spool from three different inventory paths:
+
+* internal manual assign (inventory.apply_spool_to_slot_via_mqtt)
+* internal RFID auto-assign (spool_tag_matcher.auto_assign_spool)
+* Spoolman RFID sync (main.auto_sync_spoolman_ams_trays)
+
+Internal-mode regression coverage lives in test_spool_tag_matcher.py
+(which exercises the call site end-to-end). This file focuses on the
+helper-level contracts — local-preset id formatting, soft-fail on
+no preset_id, and the Spoolman derivation path.
+"""
+
+import pytest
+from sqlalchemy import select
+
+from backend.app.models.slot_preset import SlotPresetMapping
+from backend.app.services.slot_preset_writer import (
+    upsert_slot_preset,
+    upsert_slot_preset_for_spoolman_spool,
+)
+
+
+@pytest.mark.asyncio
+async def test_upsert_no_op_when_preset_id_empty(db_session, printer_factory):
+    """An empty preset_id is not a useful key — the model's column is NOT
+    NULL and an empty string would overwrite the user's last good preset
+    with garbage. Skip without raising."""
+    printer = await printer_factory()
+    await upsert_slot_preset(
+        db=db_session,
+        printer_id=printer.id,
+        ams_id=0,
+        tray_id=0,
+        preset_id="",
+        preset_name="ignored",
+    )
+    result = await db_session.execute(select(SlotPresetMapping).where(SlotPresetMapping.printer_id == printer.id))
+    assert result.scalar_one_or_none() is None
+
+
+@pytest.mark.asyncio
+async def test_upsert_inserts_then_updates(db_session, printer_factory):
+    """First call inserts, second call on same (printer, ams, tray) updates
+    in place rather than violating the unique constraint."""
+    printer = await printer_factory()
+    await upsert_slot_preset(
+        db=db_session,
+        printer_id=printer.id,
+        ams_id=0,
+        tray_id=1,
+        preset_id="GFSA50",
+        preset_name="Bambu PLA-CF",
+        preset_source="cloud",
+    )
+    await upsert_slot_preset(
+        db=db_session,
+        printer_id=printer.id,
+        ams_id=0,
+        tray_id=1,
+        preset_id="GFSA00",
+        preset_name="Bambu PLA Basic",
+        preset_source="cloud",
+    )
+
+    rows = (
+        (await db_session.execute(select(SlotPresetMapping).where(SlotPresetMapping.printer_id == printer.id)))
+        .scalars()
+        .all()
+    )
+    assert len(rows) == 1
+    assert rows[0].preset_id == "GFSA00"
+    assert rows[0].preset_name == "Bambu PLA Basic"
+
+
+# -- Spoolman derivation ----------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_spoolman_helper_uses_filament_name_and_tray_info_idx(db_session, printer_factory):
+    """A Spoolman spool with a typical filament shape — name + material —
+    should land in the row with the AMS-reported tray_info_idx as
+    preset_id (normalised to setting_id form) and the filament name as
+    preset_name."""
+    printer = await printer_factory()
+    spoolman_spool = {
+        "id": 42,
+        "filament": {
+            "id": 7,
+            "name": "Bambu PLA-CF Burgundy Red",
+            "material": "PLA-CF",
+        },
+    }
+    await upsert_slot_preset_for_spoolman_spool(
+        db=db_session,
+        spoolman_spool=spoolman_spool,
+        tray_info_idx="GFA50",
+        tray_sub_brands="PLA-CF",
+        tray_type="PLA",
+        printer_id=printer.id,
+        ams_id=1,
+        tray_id=2,
+    )
+    mapping = (
+        await db_session.execute(
+            select(SlotPresetMapping).where(
+                SlotPresetMapping.printer_id == printer.id,
+                SlotPresetMapping.ams_id == 1,
+                SlotPresetMapping.tray_id == 2,
+            )
+        )
+    ).scalar_one()
+    assert mapping.preset_id == "GFSA50"
+    assert mapping.preset_name == "Bambu PLA-CF Burgundy Red"
+    assert mapping.preset_source == "cloud"
+
+
+@pytest.mark.asyncio
+async def test_spoolman_helper_falls_back_to_material_when_name_missing(db_session, printer_factory):
+    """Some Spoolman setups have unnamed filaments — fall back through
+    material → tray_sub_brands → tray_type so we never write an empty
+    preset_name."""
+    printer = await printer_factory()
+    spoolman_spool = {"id": 99, "filament": {"material": "PETG"}}
+    await upsert_slot_preset_for_spoolman_spool(
+        db=db_session,
+        spoolman_spool=spoolman_spool,
+        tray_info_idx="GFG00",
+        tray_sub_brands="PETG Basic",
+        tray_type="PETG",
+        printer_id=printer.id,
+        ams_id=0,
+        tray_id=0,
+    )
+    mapping = (
+        await db_session.execute(select(SlotPresetMapping).where(SlotPresetMapping.printer_id == printer.id))
+    ).scalar_one()
+    assert mapping.preset_name == "PETG"
+
+
+@pytest.mark.asyncio
+async def test_spoolman_helper_overwrites_stale_internal_row(db_session, printer_factory):
+    """Mirror of the internal-mode regression: pre-seed a stale row with a
+    previous spool's name, run the Spoolman helper, verify the row now
+    reflects the freshly-synced Spoolman spool. This is the bug shape
+    that would surface on a Spoolman user with a manually-set preset."""
+    printer = await printer_factory()
+    db_session.add(
+        SlotPresetMapping(
+            printer_id=printer.id,
+            ams_id=1,
+            tray_id=2,
+            preset_id="GFSA06_09",
+            preset_name="Bambu PLA Silk+",
+            preset_source="cloud",
+        )
+    )
+    await db_session.commit()
+
+    await upsert_slot_preset_for_spoolman_spool(
+        db=db_session,
+        spoolman_spool={"id": 42, "filament": {"name": "Bambu PLA-CF"}},
+        tray_info_idx="GFA50",
+        tray_sub_brands="PLA-CF",
+        tray_type="PLA",
+        printer_id=printer.id,
+        ams_id=1,
+        tray_id=2,
+    )
+    mapping = (
+        await db_session.execute(
+            select(SlotPresetMapping).where(
+                SlotPresetMapping.printer_id == printer.id,
+                SlotPresetMapping.ams_id == 1,
+                SlotPresetMapping.tray_id == 2,
+            )
+        )
+    ).scalar_one()
+    assert mapping.preset_name == "Bambu PLA-CF"
+    assert mapping.preset_id == "GFSA50"
+
+
+@pytest.mark.asyncio
+async def test_spoolman_helper_skips_when_tray_info_idx_unknown(db_session, printer_factory):
+    """No tray_info_idx → no preset_id → upsert skips. Caller must not
+    write an empty-string preset_id (would clobber any existing row)."""
+    printer = await printer_factory()
+    await upsert_slot_preset_for_spoolman_spool(
+        db=db_session,
+        spoolman_spool={"id": 1, "filament": {"name": "Random"}},
+        tray_info_idx="",
+        tray_sub_brands="",
+        tray_type="PLA",
+        printer_id=printer.id,
+        ams_id=0,
+        tray_id=0,
+    )
+    result = await db_session.execute(select(SlotPresetMapping).where(SlotPresetMapping.printer_id == printer.id))
+    assert result.scalar_one_or_none() is None

+ 182 - 0
backend/tests/unit/services/test_spool_tag_matcher.py

@@ -1365,3 +1365,185 @@ async def test_auto_assign_kprofile_takes_priority_over_live_cali_idx(db_session
     mqtt_mock.extrusion_cali_sel.assert_called_once()
     call_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
     assert call_kwargs["cali_idx"] == 7  # stored profile, not 99
+
+
+# -- slot_preset_mappings reconciliation on RFID auto-assign ----------------
+#
+# The slot card on PrintersPage shows slot_preset_mappings.preset_name first
+# in its fallback chain (it's the user-configured override for a slot). When a
+# new spool gets auto-assigned via RFID the manual-assign path used to be the
+# only one that kept this row in sync, so the slot card kept showing the
+# previous spool's preset name until the user opened Configure Slot manually.
+
+
+@pytest.mark.asyncio
+async def test_auto_assign_overwrites_stale_slot_preset_mapping(db_session, printer_factory):
+    """Pre-seed a slot_preset_mappings row from a previous spool, run RFID
+    auto-assign with a different filament, and verify the row reflects the
+    NEW spool's preset (not the stale one). The bug being pinned: the user's
+    AMS-B3 (PLA-CF) kept showing 'Bambu PLA Silk+' because the row was last
+    written when the PLA Silk+ spool was loaded back in March.
+    """
+    from unittest.mock import MagicMock
+
+    from sqlalchemy import select as sa_select
+
+    from backend.app.models.slot_preset import SlotPresetMapping
+
+    printer = await printer_factory()
+    db_session.add(
+        SlotPresetMapping(
+            printer_id=printer.id,
+            ams_id=1,
+            tray_id=2,
+            preset_id="GFSA06_09",
+            preset_name="Bambu PLA Silk+",
+            preset_source="cloud",
+        )
+    )
+    await db_session.commit()
+
+    spool = Spool(
+        material="PLA-CF",
+        subtype="CF",
+        brand="Bambu Lab",
+        label_weight=1000,
+        core_weight=250,
+        slicer_filament="GFA50",
+        slicer_filament_name="Bambu PLA-CF",
+        rgba="951E23FF",
+    )
+    spool.k_profiles = []
+    spool.assignments = []
+    db_session.add(spool)
+    await db_session.flush()
+
+    mock_pm = MagicMock()
+    mock_pm.get_status.return_value = None
+    mock_pm.get_client.return_value = None
+
+    await auto_assign_spool(
+        printer_id=printer.id,
+        ams_id=1,
+        tray_id=2,
+        spool=spool,
+        printer_manager=mock_pm,
+        db=db_session,
+        tray_info_idx="GFA50",
+    )
+    await db_session.commit()
+
+    result = await db_session.execute(
+        sa_select(SlotPresetMapping).where(
+            SlotPresetMapping.printer_id == printer.id,
+            SlotPresetMapping.ams_id == 1,
+            SlotPresetMapping.tray_id == 2,
+        )
+    )
+    mapping = result.scalar_one()
+    assert mapping.preset_name == "Bambu PLA-CF"
+    assert mapping.preset_id == "GFSA50"
+    assert mapping.preset_source == "cloud"
+
+
+@pytest.mark.asyncio
+async def test_auto_assign_inserts_slot_preset_when_absent(db_session, printer_factory):
+    """No pre-existing row → auto-assign inserts one. Pairs with the upsert
+    case to keep both branches of the helper covered from this path."""
+    from unittest.mock import MagicMock
+
+    from sqlalchemy import select as sa_select
+
+    from backend.app.models.slot_preset import SlotPresetMapping
+
+    printer = await printer_factory()
+    spool = Spool(
+        material="PLA-CF",
+        brand="Bambu Lab",
+        label_weight=1000,
+        core_weight=250,
+        slicer_filament="GFA50",
+        slicer_filament_name="Bambu PLA-CF",
+    )
+    spool.k_profiles = []
+    spool.assignments = []
+    db_session.add(spool)
+    await db_session.flush()
+
+    mock_pm = MagicMock()
+    mock_pm.get_status.return_value = None
+    mock_pm.get_client.return_value = None
+
+    await auto_assign_spool(
+        printer_id=printer.id,
+        ams_id=0,
+        tray_id=3,
+        spool=spool,
+        printer_manager=mock_pm,
+        db=db_session,
+        tray_info_idx="GFA50",
+    )
+    await db_session.commit()
+
+    result = await db_session.execute(
+        sa_select(SlotPresetMapping).where(
+            SlotPresetMapping.printer_id == printer.id,
+            SlotPresetMapping.ams_id == 0,
+            SlotPresetMapping.tray_id == 3,
+        )
+    )
+    mapping = result.scalar_one()
+    assert mapping.preset_id == "GFSA50"
+    assert mapping.preset_name == "Bambu PLA-CF"
+
+
+@pytest.mark.asyncio
+async def test_auto_assign_local_preset_uses_local_prefix(db_session, printer_factory):
+    """Spools whose slicer_filament is a numeric local-preset id get saved
+    with a `local_{n}` preset_id (matches the manual-assign path's shape).
+    """
+    from unittest.mock import MagicMock
+
+    from sqlalchemy import select as sa_select
+
+    from backend.app.models.slot_preset import SlotPresetMapping
+
+    printer = await printer_factory()
+    spool = Spool(
+        material="PLA",
+        brand="Bambu Lab",
+        label_weight=1000,
+        core_weight=250,
+        slicer_filament="50",  # numeric → local preset
+        slicer_filament_name="My Custom PLA",
+    )
+    spool.k_profiles = []
+    spool.assignments = []
+    db_session.add(spool)
+    await db_session.flush()
+
+    mock_pm = MagicMock()
+    mock_pm.get_status.return_value = None
+    mock_pm.get_client.return_value = None
+
+    await auto_assign_spool(
+        printer_id=printer.id,
+        ams_id=0,
+        tray_id=0,
+        spool=spool,
+        printer_manager=mock_pm,
+        db=db_session,
+    )
+    await db_session.commit()
+
+    result = await db_session.execute(
+        sa_select(SlotPresetMapping).where(
+            SlotPresetMapping.printer_id == printer.id,
+            SlotPresetMapping.ams_id == 0,
+            SlotPresetMapping.tray_id == 0,
+        )
+    )
+    mapping = result.scalar_one()
+    assert mapping.preset_id == "local_50"
+    assert mapping.preset_source == "local"
+    assert mapping.preset_name == "My Custom PLA"

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

@@ -1342,6 +1342,140 @@ class TestVirtualPrinterInstance:
         # Position = max(7) + 1 = 8 — NOT the legacy hardcoded 1.
         assert queue_item.position == 8
 
+    @pytest.mark.asyncio
+    async def test_add_to_print_queue_multi_plate_send_all_enqueues_one_per_plate(self, tmp_path):
+        """#1733: BambuStudio / OrcaSlicer "Send All" of a multi-plate project
+        uploads ONE 3MF containing every plate. Pre-fix only the first plate
+        index was extracted and one queue item was created; plates 2..N were
+        silently dropped. Post-fix every `<plate>` block in `slice_info.config`
+        produces its own PrintQueueItem with the correct ``plate_id``, sharing
+        the same backing archive, with consecutive positions for plate-order
+        execution.
+        """
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        added_items: list = []
+
+        class _RecordingDb:
+            def __init__(self):
+                # Capture inserted items as they're added; assign a fake .id
+                # on flush so the manager's logger doesn't see None.
+                self._next_id = 1000
+
+                def _add(item):
+                    added_items.append(item)
+
+                self.add = _add
+                self.commit = AsyncMock()
+
+            async def execute(self, query):  # noqa: ARG002
+                """Return MAX(position) = 0 so plate items land at 1, 2, 3."""
+                result = MagicMock()
+                result.scalar = MagicMock(return_value=0)
+                return result
+
+            async def flush(self):
+                # Mimic the FK populate so queue_item.id is available after add().
+                for item in added_items:
+                    if getattr(item, "id", None) is None:
+                        item.id = self._next_id
+                        self._next_id += 1
+
+        mock_db = _RecordingDb()
+        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=44,
+            name="MultiPlateSendAll",
+            mode="queue",
+            model="O1D",  # H2D — matches the live VP H2D-1 Proxy in #1733
+            access_code="12345678",
+            serial_suffix="391800044",
+            target_printer_id=1,
+            auto_dispatch=False,  # manual_start, mirrors the live VP
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+        )
+
+        # Build a 3MF with three plates baked into slice_info.config —
+        # mirrors what BambuStudio / OrcaSlicer's "Send All" puts on the wire.
+        file_path = tmp_path / "Cube.gcode.3mf"
+        _write_3mf_with_filaments(
+            file_path, [{"id": 1, "type": "PLA", "color": "#000000", "used_g": "15.61"}], plate_index=1
+        )
+        # Append plate 2 and 3 blocks to slice_info.config to mimic Send All.
+        with zipfile.ZipFile(file_path, "r") as zf:
+            existing = zf.read("Metadata/slice_info.config").decode()
+        # Inject two additional <plate> blocks (indices 2 and 3) inside <config>.
+        multi_plate_config = existing.replace(
+            "</config>",
+            (
+                '<plate><metadata key="index" value="2"/>'
+                '<filament id="2" type="PETG" color="#FB0207" used_g="14.45"/>'
+                "</plate>"
+                '<plate><metadata key="index" value="3"/>'
+                '<filament id="3" type="PLA" color="#FFFFFF" used_g="12.10"/>'
+                "</plate>"
+                "</config>"
+            ),
+        )
+        # Repack the zip with the expanded slice_info.config.
+        import io as _io
+
+        buf = _io.BytesIO()
+        with zipfile.ZipFile(file_path, "r") as src, zipfile.ZipFile(buf, "w") as dst:
+            for name in src.namelist():
+                if name == "Metadata/slice_info.config":
+                    dst.writestr(name, multi_plate_config)
+                else:
+                    dst.writestr(name, src.read(name))
+            # Plate-2 and plate-3 gcode payloads so `extract_filament_requirements`
+            # has something to read for each — contents irrelevant, presence matters.
+            dst.writestr("Metadata/plate_2.gcode", "; plate 2 gcode\n")
+            dst.writestr("Metadata/plate_3.gcode", "; plate 3 gcode\n")
+        file_path.write_bytes(buf.getvalue())
+
+        mock_archive = MagicMock()
+        mock_archive.id = 999
+        mock_archive.printer_id = None
+        mock_archive.filename = "Cube.gcode.3mf"
+        mock_archive.print_name = "Cube"
+        mock_archive.status = "archived"
+
+        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,
+            ),
+            patch(
+                "backend.app.core.websocket.ws_manager.send_archive_created",
+                new_callable=AsyncMock,
+            ),
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+        # Three queue items, one per plate, with the correct plate_id and
+        # consecutive positions starting at MAX(position)+1 = 1.
+        assert len(added_items) == 3, f"Expected 3 queue items for 3-plate Send All, got {len(added_items)}"
+        plate_ids = [q.plate_id for q in added_items]
+        assert plate_ids == [1, 2, 3], f"plate_ids should preserve slice_info order, got {plate_ids}"
+        positions = [q.position for q in added_items]
+        assert positions == [1, 2, 3], f"positions should be consecutive, got {positions}"
+        archive_ids = {q.archive_id for q in added_items}
+        assert archive_ids == {999}, f"All queue items must share the single backing archive, got {archive_ids}"
+        # auto_dispatch=False on the VP → every item is manual_start.
+        assert all(q.manual_start for q in added_items)
+
 
 class TestVirtualPrinterManager:
     """Tests for VirtualPrinterManager orchestrator."""

+ 0 - 205
backend/tests/unit/test_cleanup_forced_timelapse.py

@@ -1,205 +0,0 @@
-"""Tests for _cleanup_forced_timelapse (#1397).
-
-When Bambuddy forced timelapse on for the finish-photo path, this helper
-runs after the extractor (success OR failure — we never leave debris).
-It deletes:
-  - the locally-attached file (clears archive.timelapse_path)
-  - the printer-side file via FTP DELE, walking the four scanner dirs
-
-These tests pin the four branches:
-
-  1. archive doesn't exist → no-op
-  2. archive exists but bambuddy_forced_timelapse=False → no-op (user wanted
-     the timelapse)
-  3. archive exists, forced=True, local file present → delete local + DB
-     update + FTP DELE on the first directory that succeeds
-  4. archive exists, forced=True, but FTP DELE fails on every dir → local
-     side still cleaned up; warn log emitted (best-effort)
-"""
-
-from pathlib import Path
-from types import SimpleNamespace
-from unittest.mock import AsyncMock, patch
-
-import pytest
-
-from backend.app import main as main_module
-from backend.app.main import _cleanup_forced_timelapse
-
-
-def _fake_session_factory(rows: dict):
-    """Return an async_session() replacement that yields the given rows.
-
-    `rows` is a mapping of model -> object that the test wants returned
-    from `db.execute(select(...)).scalar_one_or_none()`. The select
-    target is detected by walking the column descriptions — for these
-    tests we just look at the model class name.
-    """
-    from contextlib import asynccontextmanager
-
-    @asynccontextmanager
-    async def fake_session():
-        async def execute(stmt):
-            # The select(...) statement carries the target entity in
-            # `stmt.column_descriptions[0]["entity"]`. Match by class name.
-            target_name = stmt.column_descriptions[0]["entity"].__name__
-            row = rows.get(target_name)
-            return SimpleNamespace(scalar_one_or_none=lambda: row)
-
-        commits: list[None] = []
-
-        async def commit():
-            commits.append(None)
-
-        yield SimpleNamespace(execute=execute, commit=commit, _commits=commits)
-
-    return fake_session
-
-
-@pytest.fixture(autouse=True)
-def patch_app_settings(monkeypatch, tmp_path):
-    """Point base_dir at a tmp_path so the helper can resolve relative
-    timelapse paths against a real fs we control."""
-    monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path)
-    return tmp_path
-
-
-@pytest.mark.asyncio
-async def test_no_archive_is_noop(monkeypatch):
-    """Archive deleted between print start and cleanup? Don't crash."""
-    monkeypatch.setattr(main_module, "async_session", _fake_session_factory({"PrintArchive": None, "Printer": None}))
-    delete_mock = AsyncMock()
-    with patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock):
-        await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
-    delete_mock.assert_not_awaited()
-
-
-@pytest.mark.asyncio
-async def test_not_forced_is_noop(monkeypatch, tmp_path):
-    """User wanted a timelapse → don't delete anything."""
-    archive = SimpleNamespace(
-        bambuddy_forced_timelapse=False,
-        timelapse_path="archive/1/timelapse.mp4",
-    )
-    monkeypatch.setattr(
-        main_module,
-        "async_session",
-        _fake_session_factory({"PrintArchive": archive, "Printer": None}),
-    )
-
-    # Lay down a real file so we'd detect a stray delete.
-    video_path = tmp_path / archive.timelapse_path
-    video_path.parent.mkdir(parents=True, exist_ok=True)
-    video_path.write_bytes(b"x" * 100)
-
-    delete_mock = AsyncMock(return_value=True)
-    with patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock):
-        await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
-
-    delete_mock.assert_not_awaited()
-    assert video_path.exists()
-    # archive.timelapse_path is untouched — we still have the user's video
-    # tracked correctly.
-    assert archive.timelapse_path == "archive/1/timelapse.mp4"
-
-
-@pytest.mark.asyncio
-async def test_forced_deletes_local_and_remote(monkeypatch, tmp_path):
-    """Happy path: forced=True → local file unlinked, DB row cleared, FTP
-    DELE called against /timelapse/<filename> (the first dir to succeed)."""
-    archive = SimpleNamespace(
-        bambuddy_forced_timelapse=True,
-        timelapse_path="archive/1/myprint.mp4",
-    )
-    printer = SimpleNamespace(ip_address="10.0.0.5", access_code="12345678", model="O1C")
-    monkeypatch.setattr(
-        main_module,
-        "async_session",
-        _fake_session_factory({"PrintArchive": archive, "Printer": printer}),
-    )
-
-    video_path = tmp_path / archive.timelapse_path
-    video_path.parent.mkdir(parents=True, exist_ok=True)
-    video_path.write_bytes(b"x" * 100)
-
-    # FTP DELE succeeds on the first directory we try.
-    delete_mock = AsyncMock(return_value=True)
-    with patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock):
-        await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
-
-    # Local side: file gone, DB cleared.
-    assert not video_path.exists()
-    assert archive.timelapse_path is None
-    # Remote side: DELE'd against /timelapse/myprint.mp4 — that's the
-    # first dir the cleanup tries.
-    delete_mock.assert_awaited()
-    call = delete_mock.await_args
-    assert call.args[0] == "10.0.0.5"
-    assert call.args[1] == "12345678"
-    assert call.args[2] == "/timelapse/myprint.mp4"
-
-
-@pytest.mark.asyncio
-async def test_forced_walks_alternate_dirs_when_first_fails(monkeypatch, tmp_path):
-    """If /timelapse/ DELE returns False (file not there), try the other
-    scanner dirs in order."""
-    archive = SimpleNamespace(
-        bambuddy_forced_timelapse=True,
-        timelapse_path="archive/1/myprint.mp4",
-    )
-    printer = SimpleNamespace(ip_address="10.0.0.5", access_code="12345678", model="O1C")
-    monkeypatch.setattr(
-        main_module,
-        "async_session",
-        _fake_session_factory({"PrintArchive": archive, "Printer": printer}),
-    )
-
-    video_path = tmp_path / archive.timelapse_path
-    video_path.parent.mkdir(parents=True, exist_ok=True)
-    video_path.write_bytes(b"x" * 100)
-
-    # First two attempts fail (False), third succeeds (True). Cleanup
-    # should stop after the third.
-    delete_mock = AsyncMock(side_effect=[False, False, True])
-    with patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock):
-        await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
-
-    assert delete_mock.await_count == 3
-    paths_tried = [call.args[2] for call in delete_mock.await_args_list]
-    assert paths_tried == [
-        "/timelapse/myprint.mp4",
-        "/timelapse/video/myprint.mp4",
-        "/record/myprint.mp4",
-    ]
-
-
-@pytest.mark.asyncio
-async def test_forced_local_cleanup_runs_even_if_ftp_unreachable(monkeypatch, tmp_path):
-    """FTP completely failing must not block local cleanup — the user's
-    archive UI should reflect that the timelapse is gone immediately,
-    even if the printer-side file lingers."""
-    archive = SimpleNamespace(
-        bambuddy_forced_timelapse=True,
-        timelapse_path="archive/1/myprint.mp4",
-    )
-    printer = SimpleNamespace(ip_address="10.0.0.5", access_code="12345678", model="O1C")
-    monkeypatch.setattr(
-        main_module,
-        "async_session",
-        _fake_session_factory({"PrintArchive": archive, "Printer": printer}),
-    )
-
-    video_path = tmp_path / archive.timelapse_path
-    video_path.parent.mkdir(parents=True, exist_ok=True)
-    video_path.write_bytes(b"x" * 100)
-
-    # Every FTP attempt throws.
-    delete_mock = AsyncMock(side_effect=OSError("connection refused"))
-    with patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock):
-        await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
-
-    # Local side cleaned up even though all FTP attempts threw.
-    assert not video_path.exists()
-    assert archive.timelapse_path is None
-    # All four dirs were attempted before giving up.
-    assert delete_mock.await_count == 4

+ 19 - 0
backend/tests/unit/test_local_backup.py

@@ -137,6 +137,25 @@ class TestCalculateNextRun:
             result = service._calculate_next_run("daily", "21:00")
         assert result == datetime(2026, 6, 15, 21, 0, 0, tzinfo=timezone.utc)
 
+    def test_zoneinfo_completely_unavailable_falls_back_to_stdlib_utc(self, monkeypatch):
+        """Windows installer ships an embedded Python without the IANA tz DB
+        (no system tzdata, no ``tzdata`` PyPI package). Even ``ZoneInfo("UTC")``
+        raises ``ZoneInfoNotFoundError`` then, and /api/local-backup/status
+        500s. The fallback must catch that and return ``datetime.timezone.utc``
+        so scheduling still works without the DB.
+        """
+        from zoneinfo import ZoneInfoNotFoundError
+
+        from backend.app.services import local_backup as lb_module
+
+        monkeypatch.delenv("TZ", raising=False)
+
+        def _always_missing(_key):
+            raise ZoneInfoNotFoundError("no tz database on this platform")
+
+        monkeypatch.setattr(lb_module, "ZoneInfo", _always_missing)
+        assert lb_module._local_zone() is timezone.utc
+
     def test_dst_spring_forward_gap_does_not_crash(self, monkeypatch):
         """Europe/Berlin spring-forward 2026-03-29 jumps 02:00 → 03:00 local;
         02:30 wall-clock does not exist. ``replace(hour=2, minute=30)`` should

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

@@ -5,9 +5,11 @@ import pytest
 from backend.app.services.camera import get_camera_port, supports_rtsp
 from backend.app.utils.printer_models import (
     CARBON_ROD_MODELS,
+    LINEAR_RAIL_MODELS,
     STEEL_ROD_MODELS,
     get_rod_type,
     has_ethernet,
+    has_external_storage,
     is_dual_nozzle_model,
     normalize_printer_model,
     normalize_printer_model_id,
@@ -107,6 +109,84 @@ class TestX2DModel:
         assert "N6" in STEEL_ROD_MODELS
 
 
+class TestA2LModel:
+    """A2L printer support (#1684).
+
+    The A2L is a hybrid 3D printer + cutter/plotter announced June 2026. It
+    uses linear rails like the A1 family, has NO Ethernet (Wi-Fi 2.4 GHz only),
+    a low-rate chamber-image camera on port 6000 (no RTSP), and a single FDM
+    extruder (the second "tool head" in BambuStudio's profile is the cutter,
+    not a second extruder — must NOT be classified as dual-nozzle). Internal
+    SSDP/MQTT model code is "N9"; serial numbers begin with "26A19".
+    """
+
+    def test_a2l_is_linear_rail_display_name(self):
+        assert get_rod_type("A2L") == "linear_rail"
+
+    def test_a2l_is_linear_rail_internal_code(self):
+        assert get_rod_type("N9") == "linear_rail"
+
+    def test_a2l_model_id_map(self):
+        assert normalize_printer_model_id("N9") == "A2L"
+
+    def test_a2l_model_map(self):
+        assert normalize_printer_model("Bambu Lab A2L") == "A2L"
+
+    def test_a2l_has_no_ethernet_display_name(self):
+        """A2L specs (bambulab.com/de-de/a2l/specs) list Ethernet 'Nicht verfügbar'."""
+        assert has_ethernet("A2L") is False
+
+    def test_a2l_has_no_ethernet_internal_code(self):
+        assert has_ethernet("N9") is False
+
+    def test_a2l_does_not_support_rtsp_display_name(self):
+        """A2L uses the low-rate chamber-image protocol on port 6000, not RTSP."""
+        assert supports_rtsp("A2L") is False
+
+    def test_a2l_does_not_support_rtsp_internal_code(self):
+        assert supports_rtsp("N9") is False
+
+    def test_a2l_camera_port_is_chamber_image(self):
+        assert get_camera_port("A2L") == 6000
+        assert get_camera_port("N9") == 6000
+
+    def test_a2l_is_not_dual_nozzle(self):
+        """A2L has a single FDM extruder + a cutter/plotter head. The
+        BambuStudio profile flag ``use_double_extruder_default_texture`` flags
+        the dual TOOL HEADS, not dual filament extrusion — A2L must not land
+        in the dual-nozzle group or AMS routing will target the deputy slot
+        and the firmware will reject the print with 07FF_8012.
+        """
+        assert is_dual_nozzle_model("A2L") is False
+        assert is_dual_nozzle_model("N9") is False
+
+    def test_a2l_in_linear_rail_set(self):
+        assert "A2L" in LINEAR_RAIL_MODELS
+        assert "N9" in LINEAR_RAIL_MODELS
+
+    def test_a2l_not_in_carbon_or_steel_rod_sets(self):
+        assert "A2L" not in CARBON_ROD_MODELS
+        assert "N9" not in CARBON_ROD_MODELS
+        assert "A2L" not in STEEL_ROD_MODELS
+        assert "N9" not in STEEL_ROD_MODELS
+
+
+class TestA1SeriesModelIds:
+    """Regression guard for the A1-family internal-code → display-name map.
+
+    The serial-prefix and firmware-API key tables across the codebase agree
+    that N2S is the A1 (serial prefix 039) and N1 is the A1 Mini (serial
+    prefix 030). PRINTER_MODEL_ID_MAP had these swapped, which silently
+    misclassified A1 as A1 Mini in any path that resolved by internal code.
+    """
+
+    def test_n2s_is_a1(self):
+        assert normalize_printer_model_id("N2S") == "A1"
+
+    def test_n1_is_a1_mini(self):
+        assert normalize_printer_model_id("N1") == "A1 Mini"
+
+
 class TestDualNozzleModel:
     """is_dual_nozzle_model — the single source of truth for nozzle class,
     consumed by start_print, the K-profile routes, and the re-slice guard."""
@@ -130,3 +210,35 @@ class TestDualNozzleModel:
     def test_none_and_empty_are_not_dual(self):
         assert is_dual_nozzle_model(None) is False
         assert is_dual_nozzle_model("") is False
+
+
+class TestHasExternalStorage:
+    """Pins which Bambu models have a MicroSD slot. The connection
+    diagnostic flips its ``external_storage`` check from ``fail`` to
+    ``skip`` based on this — a false add (X1C marked as no-storage) would
+    silently disable a genuine fail signal for X1/P1/P2S/H2 users."""
+
+    @pytest.mark.parametrize("model", ["A1", "A1 Mini", "A1MINI", "A1-Mini", "a1"])
+    def test_a1_series_has_no_external_storage(self, model: str):
+        assert has_external_storage(model) is False
+
+    @pytest.mark.parametrize("model", ["N1", "N2S", "A04", "A11", "A12"])
+    def test_a1_internal_codes_have_no_external_storage(self, model: str):
+        assert has_external_storage(model) is False
+
+    @pytest.mark.parametrize(
+        "model",
+        ["X1C", "X1E", "X1", "P1S", "P1P", "P2S", "H2D", "H2D Pro", "H2C", "H2S", "X2D"],
+    )
+    def test_other_models_have_external_storage(self, model: str):
+        assert has_external_storage(model) is True
+
+    def test_unknown_model_defaults_to_true(self):
+        # Default-true keeps the diagnostic active for new Bambu models;
+        # add them to NO_EXTERNAL_STORAGE_MODELS explicitly when they ship
+        # without a slot.
+        assert has_external_storage("BrandNewModel2027") is True
+
+    def test_none_and_empty_default_to_true(self):
+        assert has_external_storage(None) is True
+        assert has_external_storage("") is True

+ 326 - 0
backend/tests/unit/test_reprint_clears_stale_timelapse.py

@@ -0,0 +1,326 @@
+"""Regression for #1707: Telegram (and any image-bearing) notification on a
+reprint from archive showed the *original* print's finish photo because the
+expected-archive branch never reset ``archive.timelapse_path``.
+
+The source archive row is reused for reprints. With ``timelapse_path`` still
+pointing at the original run's downloaded MP4:
+  - ``_scan_for_timelapse_with_retries`` early-returns ("already has timelapse")
+    and never downloads the reprint's video.
+  - ``_capture_finish_photo_from_timelapse`` reads the stale path, extracts the
+    *original* last frame, and ships it as the reprint's finish photo.
+
+The fix clears ``archive.timelapse_path`` (and unlinks the stale file) at
+expected-archive promotion so the scan + photo path run fresh.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.core.config import settings as app_settings
+from backend.app.main import (
+    _active_prints,
+    _expected_print_creators,
+    _expected_print_registered_at,
+    _expected_prints,
+    _print_ams_mappings,
+    _timelapse_baselines,
+    register_expected_print,
+)
+
+
+@pytest.fixture(autouse=True)
+def _clear_dicts():
+    _expected_prints.clear()
+    _expected_print_registered_at.clear()
+    _expected_print_creators.clear()
+    _print_ams_mappings.clear()
+    _active_prints.clear()
+    _timelapse_baselines.clear()
+    yield
+    _expected_prints.clear()
+    _expected_print_registered_at.clear()
+    _expected_print_creators.clear()
+    _print_ams_mappings.clear()
+    _active_prints.clear()
+    _timelapse_baselines.clear()
+
+
+def _patches():
+    """Common patches for driving on_print_start without side effects."""
+    return (
+        patch("backend.app.main.async_session"),
+        patch("backend.app.main.notification_service"),
+        patch("backend.app.main.smart_plug_manager"),
+        patch("backend.app.main.ws_manager"),
+        patch("backend.app.main.printer_manager"),
+        patch("backend.app.main.mqtt_relay"),
+        patch("backend.app.main._record_energy_start", new_callable=AsyncMock),
+        patch("backend.app.main._load_objects_from_archive"),
+        patch("backend.app.main._store_spoolman_print_data", new_callable=AsyncMock),
+        patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock),
+        patch(
+            "backend.app.main._list_timelapse_videos",
+            new=AsyncMock(return_value=([], "/timelapse")),
+        ),
+    )
+
+
+def _build_mocks(mock_printer, mock_archive):
+    def execute_router(stmt, *args, **kwargs):
+        sql = str(stmt).lower()
+        if "from printers" in sql or "from printer " in sql:
+            return MagicMock(
+                scalar_one_or_none=MagicMock(return_value=mock_printer),
+                scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_printer]))),
+            )
+        if "from print_archives" in sql or "from print_archive" in sql:
+            return MagicMock(
+                scalar_one_or_none=MagicMock(return_value=mock_archive),
+                scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_archive]))),
+            )
+        return MagicMock(
+            scalar_one_or_none=MagicMock(return_value=None),
+            scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
+        )
+
+    mock_session = AsyncMock()
+    mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+    mock_session.__aexit__ = AsyncMock()
+    mock_session.execute = AsyncMock(side_effect=execute_router)
+    mock_session.commit = AsyncMock()
+    return mock_session
+
+
+@pytest.mark.asyncio
+async def test_reprint_clears_timelapse_path_and_unlinks_stale_file(tmp_path):
+    """On reprint promotion, timelapse_path must be reset to None and the old
+    on-disk video unlinked, so the completion-time scanner and finish-photo
+    extractor don't reuse the original run's frame."""
+    mock_printer = MagicMock()
+    mock_printer.id = 1
+    mock_printer.auto_archive = True
+    mock_printer.external_camera_enabled = False
+    mock_printer.external_camera_url = None
+    mock_printer.name = "TestP2S"
+
+    # Lay down a fake stale timelapse under a tmp base_dir so the unlink
+    # actually has a file to remove.
+    relpath = "archives/42/timelapse/original.mp4"
+    stale_file = tmp_path / relpath
+    stale_file.parent.mkdir(parents=True, exist_ok=True)
+    stale_file.write_bytes(b"old timelapse bytes")
+    assert stale_file.exists()
+
+    mock_archive = MagicMock()
+    mock_archive.id = 42
+    mock_archive.filename = "MyModel.3mf"
+    mock_archive.subtask_id = None
+    mock_archive.print_time_seconds = None
+    mock_archive.created_by_id = None
+    mock_archive.printer_id = 1
+    mock_archive.print_name = "MyModel"
+    mock_archive.status = "archived"
+    mock_archive.file_path = "archives/42/MyModel.3mf"
+    mock_archive.energy_start_kwh = None
+    mock_archive.timelapse_path = relpath  # stale from the original run
+
+    register_expected_print(1, "MyModel.3mf", archive_id=42, ams_mapping=None)
+
+    mock_session = _build_mocks(mock_printer, mock_archive)
+
+    (
+        async_session_p,
+        notif_p,
+        plug_p,
+        ws_p,
+        pm_p,
+        relay_p,
+        _energy,
+        _load_obj,
+        _store_spoolman,
+        _send_start,
+        _list_tl,
+    ) = _patches()
+
+    with (
+        async_session_p as mock_session_maker,
+        notif_p as mock_notif,
+        plug_p as mock_plug,
+        ws_p as mock_ws,
+        pm_p as mock_pm,
+        relay_p as mock_relay,
+        _energy,
+        _load_obj,
+        _store_spoolman,
+        _send_start,
+        _list_tl,
+        patch.object(app_settings, "base_dir", tmp_path),
+    ):
+        mock_session_maker.return_value = mock_session
+        mock_notif.on_print_start = AsyncMock()
+        mock_plug.on_print_start = AsyncMock()
+        mock_ws.send_print_start = AsyncMock()
+        mock_ws.send_archive_updated = AsyncMock()
+        mock_relay.on_print_start = AsyncMock()
+        mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
+
+        from backend.app.main import on_print_start
+
+        await on_print_start(1, {"filename": "MyModel.3mf", "subtask_name": "MyModel"})
+
+    assert mock_archive.timelapse_path is None, (
+        "expected-archive branch must clear timelapse_path on reprint so "
+        "_scan_for_timelapse_with_retries doesn't early-return and "
+        "_capture_finish_photo_from_timelapse doesn't extract the original "
+        "run's last frame (#1707)"
+    )
+    assert not stale_file.exists(), (
+        "old timelapse file must be unlinked at reprint promotion to avoid orphans in the archive directory"
+    )
+
+
+@pytest.mark.asyncio
+async def test_reprint_with_no_timelapse_path_is_noop(tmp_path):
+    """When archive has no prior timelapse_path (first print, or already
+    cleared), promotion must still succeed and not raise on the unlink path."""
+    mock_printer = MagicMock()
+    mock_printer.id = 1
+    mock_printer.auto_archive = True
+    mock_printer.external_camera_enabled = False
+    mock_printer.external_camera_url = None
+    mock_printer.name = "TestP2S"
+
+    mock_archive = MagicMock()
+    mock_archive.id = 99
+    mock_archive.filename = "FreshFile.3mf"
+    mock_archive.subtask_id = None
+    mock_archive.print_time_seconds = None
+    mock_archive.created_by_id = None
+    mock_archive.printer_id = 1
+    mock_archive.print_name = "FreshFile"
+    mock_archive.status = "archived"
+    mock_archive.file_path = "archives/99/FreshFile.3mf"
+    mock_archive.energy_start_kwh = None
+    mock_archive.timelapse_path = None  # nothing to clean up
+
+    register_expected_print(1, "FreshFile.3mf", archive_id=99, ams_mapping=None)
+
+    mock_session = _build_mocks(mock_printer, mock_archive)
+
+    (
+        async_session_p,
+        notif_p,
+        plug_p,
+        ws_p,
+        pm_p,
+        relay_p,
+        _energy,
+        _load_obj,
+        _store_spoolman,
+        _send_start,
+        _list_tl,
+    ) = _patches()
+
+    with (
+        async_session_p as mock_session_maker,
+        notif_p as mock_notif,
+        plug_p as mock_plug,
+        ws_p as mock_ws,
+        pm_p as mock_pm,
+        relay_p as mock_relay,
+        _energy,
+        _load_obj,
+        _store_spoolman,
+        _send_start,
+        _list_tl,
+        patch.object(app_settings, "base_dir", tmp_path),
+    ):
+        mock_session_maker.return_value = mock_session
+        mock_notif.on_print_start = AsyncMock()
+        mock_plug.on_print_start = AsyncMock()
+        mock_ws.send_print_start = AsyncMock()
+        mock_ws.send_archive_updated = AsyncMock()
+        mock_relay.on_print_start = AsyncMock()
+        mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
+
+        from backend.app.main import on_print_start
+
+        await on_print_start(1, {"filename": "FreshFile.3mf", "subtask_name": "FreshFile"})
+
+    assert mock_archive.timelapse_path is None
+    assert mock_archive.status == "printing"
+
+
+@pytest.mark.asyncio
+async def test_reprint_with_missing_stale_file_does_not_raise(tmp_path):
+    """If the stale file referenced by timelapse_path no longer exists on
+    disk (user deleted, archive purge, container rebuilt with bind-mount
+    drift), promotion must still clear the field cleanly without raising."""
+    mock_printer = MagicMock()
+    mock_printer.id = 1
+    mock_printer.auto_archive = True
+    mock_printer.external_camera_enabled = False
+    mock_printer.external_camera_url = None
+    mock_printer.name = "TestP2S"
+
+    mock_archive = MagicMock()
+    mock_archive.id = 7
+    mock_archive.filename = "Ghost.3mf"
+    mock_archive.subtask_id = None
+    mock_archive.print_time_seconds = None
+    mock_archive.created_by_id = None
+    mock_archive.printer_id = 1
+    mock_archive.print_name = "Ghost"
+    mock_archive.status = "archived"
+    mock_archive.file_path = "archives/7/Ghost.3mf"
+    mock_archive.energy_start_kwh = None
+    # Path points at a file that doesn't exist under tmp_path.
+    mock_archive.timelapse_path = "archives/7/timelapse/vanished.mp4"
+
+    register_expected_print(1, "Ghost.3mf", archive_id=7, ams_mapping=None)
+
+    mock_session = _build_mocks(mock_printer, mock_archive)
+
+    (
+        async_session_p,
+        notif_p,
+        plug_p,
+        ws_p,
+        pm_p,
+        relay_p,
+        _energy,
+        _load_obj,
+        _store_spoolman,
+        _send_start,
+        _list_tl,
+    ) = _patches()
+
+    with (
+        async_session_p as mock_session_maker,
+        notif_p as mock_notif,
+        plug_p as mock_plug,
+        ws_p as mock_ws,
+        pm_p as mock_pm,
+        relay_p as mock_relay,
+        _energy,
+        _load_obj,
+        _store_spoolman,
+        _send_start,
+        _list_tl,
+        patch.object(app_settings, "base_dir", tmp_path),
+    ):
+        mock_session_maker.return_value = mock_session
+        mock_notif.on_print_start = AsyncMock()
+        mock_plug.on_print_start = AsyncMock()
+        mock_ws.send_print_start = AsyncMock()
+        mock_ws.send_archive_updated = AsyncMock()
+        mock_relay.on_print_start = AsyncMock()
+        mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
+
+        from backend.app.main import on_print_start
+
+        await on_print_start(1, {"filename": "Ghost.3mf", "subtask_name": "Ghost"})
+
+    assert mock_archive.timelapse_path is None
+    assert mock_archive.status == "printing"

+ 0 - 71
backend/tests/unit/test_scheduler_force_timelapse_wiring.py

@@ -1,71 +0,0 @@
-"""Regression test for the print-queue path of the #1397 force-timelapse fix.
-
-The first round of #1397 only wired the override into ``background_dispatch.py``,
-which covers Print Now / Reprint Now flows. The print *queue* uses a separate
-scheduler at ``print_scheduler.py:_start_print`` that calls
-``printer_manager.start_print`` directly — and the first attempt skipped that
-call site, so queued prints' timelapse setting passed through unchanged and
-the finish-photo path had nothing to draw from. Field-test caught this when
-Martin queued two prints (H2D + X1C); neither got a forced timelapse and
-``archive.bambuddy_forced_timelapse`` stayed False on both.
-
-This test pins the wiring at the source level: the helper is imported AND
-its return value is what ``start_print(timelapse=...)`` receives. We can't
-exercise the full ``_start_print`` method without standing up a real DB +
-printer_manager + ams_assignment fixture stack, but the structural assert
-is enough to catch regression at the dispatch hook.
-"""
-
-import ast
-from pathlib import Path
-
-SCHEDULER_PATH = Path(__file__).resolve().parent.parent.parent / "app" / "services" / "print_scheduler.py"
-
-
-def _find_call_to_start_print(tree: ast.AST) -> ast.Call:
-    """Walk the AST and return the printer_manager.start_print(...) Call node
-    inside _start_print. Should be exactly one."""
-    for node in ast.walk(tree):
-        if not isinstance(node, ast.Call):
-            continue
-        func = node.func
-        if not isinstance(func, ast.Attribute):
-            continue
-        if func.attr != "start_print":
-            continue
-        value = func.value
-        if not isinstance(value, ast.Name) or value.id != "printer_manager":
-            continue
-        return node
-    raise AssertionError("Could not find printer_manager.start_print(...) call in print_scheduler.py")
-
-
-def test_start_print_timelapse_kwarg_uses_resolved_value():
-    """``timelapse=`` kwarg passed to start_print must reference
-    ``effective_timelapse`` (the resolved value) — not ``item.timelapse``
-    (the user's raw choice). If a refactor drops the resolver call and
-    restores ``item.timelapse``, this test fails."""
-    source = SCHEDULER_PATH.read_text()
-    tree = ast.parse(source)
-
-    call = _find_call_to_start_print(tree)
-    timelapse_kw = next((kw for kw in call.keywords if kw.arg == "timelapse"), None)
-    assert timelapse_kw is not None, "start_print(timelapse=...) kwarg is missing"
-
-    # The value must be the resolved variable, not item.timelapse.
-    value = timelapse_kw.value
-    assert isinstance(value, ast.Name) and value.id == "effective_timelapse", (
-        f"timelapse= must be the resolver's return value (effective_timelapse), "
-        f"got {ast.dump(value)}. The queue path must apply the same #1397 "
-        f"override as background_dispatch.py — otherwise queued prints' "
-        f"finish-photo extractor has nothing to pull from."
-    )
-
-
-def test_scheduler_imports_resolve_effective_timelapse():
-    """The import must exist somewhere in print_scheduler.py — guards against
-    a future refactor removing it and falling back to item.timelapse."""
-    source = SCHEDULER_PATH.read_text()
-    assert "from backend.app.services.background_dispatch import resolve_effective_timelapse" in source, (
-        "print_scheduler.py must import resolve_effective_timelapse from background_dispatch"
-    )

+ 6 - 74
backend/tests/unit/test_slice_request_schema.py

@@ -6,7 +6,7 @@ normalisation that lets the route handler ignore the difference.
 import pytest
 from pydantic import ValidationError
 
-from backend.app.schemas.slicer import PresetRef, SliceBundleSpec, SliceRequest
+from backend.app.schemas.slicer import PresetRef, SliceRequest
 
 
 class TestLegacyBareIntegerShape:
@@ -144,79 +144,11 @@ class TestFilamentPresetsList:
         assert [r.id for r in req.filament_presets] == ["slot1", "slot2", "slot3"]
 
 
-class TestBundleDispatchShape:
-    """When SliceRequest.bundle is set, the dispatcher picks the JSON
-    triplet from a sidecar-side bundle by name and PresetRef resolution
-    is skipped entirely. Validator must accept "bundle alone" without
-    flagging missing presets."""
+class TestPresetsRequired:
+    """Without preset refs (and no legacy integer ids), the validator must
+    reject the request. Preset selection is mandatory now that bundle mode
+    is gone."""
 
-    def test_bundle_alone_validates(self):
-        req = SliceRequest(
-            bundle=SliceBundleSpec(
-                bundle_id="abc123def456abcd",
-                printer_name="# Bambu Lab H2D 0.4 nozzle",
-                process_name="# 0.20mm Standard @BBL H2D",
-                filament_names=["# Bambu PLA Basic @BBL H2D"],
-            ),
-        )
-        # PresetRef fields are absent; that's fine in bundle mode.
-        assert req.bundle is not None
-        assert req.printer_preset is None
-        assert req.process_preset is None
-        assert req.filament_presets == []
-
-    def test_bundle_with_filament_list_preserves_order(self):
-        req = SliceRequest(
-            bundle=SliceBundleSpec(
-                bundle_id="abc",
-                printer_name="P",
-                process_name="Q",
-                filament_names=["red", "blue", "green"],
-            ),
-        )
-        assert req.bundle.filament_names == ["red", "blue", "green"]
-
-    def test_bundle_rejects_empty_filament_list(self):
-        with pytest.raises(ValidationError):
-            SliceBundleSpec(
-                bundle_id="abc",
-                printer_name="P",
-                process_name="Q",
-                filament_names=[],
-            )
-
-    def test_bundle_rejects_empty_id(self):
-        with pytest.raises(ValidationError):
-            SliceBundleSpec(
-                bundle_id="",
-                printer_name="P",
-                process_name="Q",
-                filament_names=["F"],
-            )
-
-    def test_no_bundle_no_presets_still_rejected(self):
-        # Dropping the bundle escape-hatch must not bypass the existing
-        # presets-required check.
+    def test_empty_request_rejected(self):
         with pytest.raises(ValidationError):
             SliceRequest()
-
-    def test_bundle_with_presets_keeps_both_fields(self):
-        # Sending both is allowed (validator accepts the bundle and skips
-        # preset normalisation) — the dispatch picks bundle on the route
-        # side. Confirms the validator doesn't reject overlapping intent
-        # so a future client that wants to record the legacy presets
-        # alongside doesn't fail validation.
-        req = SliceRequest(
-            printer_preset=PresetRef(source="standard", id="X1C"),
-            process_preset=PresetRef(source="standard", id="0.20"),
-            filament_presets=[PresetRef(source="standard", id="PLA")],
-            bundle=SliceBundleSpec(
-                bundle_id="abc",
-                printer_name="P",
-                process_name="Q",
-                filament_names=["F"],
-            ),
-        )
-        assert req.bundle is not None
-        # Presets stay populated; dispatch ignores them when bundle is set.
-        assert req.printer_preset is not None

+ 104 - 260
backend/tests/unit/test_slicer_presets.py

@@ -1,9 +1,13 @@
 """Tests for the unified slicer-presets endpoint helpers.
 
-The endpoint stitches together three preset sources (cloud / local /
-standard) with name-based dedup. These tests pin the dedup logic, the
-cloud-status mapping, and the per-user / sidecar caches at the
-helper level — full HTTP integration is covered by the routes test.
+The endpoint stitches together four preset sources (local / orca_cloud /
+cloud / standard). It does NOT dedup across tiers — every tier surfaces
+its full list so the user can pick any source. Bambu Cloud filament
+metadata is enriched from same-named entries in the other tiers so it
+can still score in the SliceModal's auto-pick. These tests pin the
+enrich behaviour, the cloud-status mapping, and the per-user / sidecar
+caches at the helper level — full HTTP integration is covered by the
+routes test.
 """
 
 from __future__ import annotations
@@ -28,50 +32,31 @@ def _slot(items: list[tuple[str, str, str]]) -> dict[str, list[UnifiedPreset]]:
     }
 
 
-class TestDedupeByName:
-    """Cloud > local > standard, by ``name``, order preserved within tier."""
+class TestEnrichCloudMetadata:
+    """No cross-tier dedup — every tier's full list comes back; Bambu Cloud
+    filament metadata is enriched from same-named entries in other tiers."""
 
-    def test_cloud_wins_over_local_and_standard(self):
+    def test_same_name_in_all_tiers_appears_in_every_tier(self):
+        """Critical regression guard for #1712: a user who has imported a
+        local profile AND signed in to Orca AND has Bambu Cloud with the
+        same name should see it under EACH source, not just the highest-
+        priority tier. The order is used for auto-pick + group rendering;
+        it is NOT used to hide profiles."""
+        orca = _slot([("oid1", "Bambu PLA Basic", "orca_cloud")])
         cloud = _slot([("cid1", "Bambu PLA Basic", "cloud")])
         local = _slot([("lid1", "Bambu PLA Basic", "local")])
         standard = _slot([("Bambu PLA Basic", "Bambu PLA Basic", "standard")])
 
-        _oc, c, l_, s = sp._dedupe_by_name(_slot([]), cloud, local, standard)
+        oc, c, l_, s = sp._enrich_cloud_metadata(orca, cloud, local, standard)
 
+        assert [p.source for p in l_["printer"]] == ["local"]
+        assert [p.source for p in oc["printer"]] == ["orca_cloud"]
         assert [p.source for p in c["printer"]] == ["cloud"]
-        assert l_["printer"] == []
-        assert s["printer"] == []
-
-    def test_local_filtered_only_when_present_in_cloud(self):
-        cloud = _slot([("cid1", "Custom PLA", "cloud")])
-        local = _slot(
-            [
-                ("lid1", "Custom PLA", "local"),  # filtered (in cloud)
-                ("lid2", "My Workhorse PLA", "local"),  # kept
-            ]
-        )
-        standard = _slot([])
-
-        _oc, _c, l_, _s = sp._dedupe_by_name(_slot([]), cloud, local, standard)
-        assert [p.name for p in l_["printer"]] == ["My Workhorse PLA"]
-
-    def test_standard_filtered_against_both_higher_tiers(self):
-        cloud = _slot([("c1", "A", "cloud")])
-        local = _slot([("l1", "B", "local")])
-        standard = _slot(
-            [
-                ("A", "A", "standard"),  # filtered (in cloud)
-                ("B", "B", "standard"),  # filtered (in local)
-                ("C", "C", "standard"),  # kept
-            ]
-        )
-
-        _oc, _c, _l, s = sp._dedupe_by_name(_slot([]), cloud, local, standard)
-        assert [p.name for p in s["printer"]] == ["C"]
+        assert [p.source for p in s["printer"]] == ["standard"]
 
     def test_preserves_order_within_tier(self):
-        """A tier's input order must be preserved in its output — nothing in
-        the dedupe pass should sort, reverse, or otherwise reorder entries."""
+        """A tier's input order must be preserved — nothing in the enrich
+        pass should sort, reverse, or otherwise reorder entries."""
         cloud = _slot(
             [
                 ("c1", "Z-First", "cloud"),
@@ -79,25 +64,95 @@ class TestDedupeByName:
                 ("c3", "M-Third", "cloud"),
             ]
         )
-        _oc, c, _l, _s = sp._dedupe_by_name(_slot([]), cloud, _slot([]), _slot([]))
+        _oc, c, _l, _s = sp._enrich_cloud_metadata(_slot([]), cloud, _slot([]), _slot([]))
         assert [p.name for p in c["printer"]] == ["Z-First", "A-Second", "M-Third"]
 
-    def test_dedupe_is_per_slot(self):
-        """A name colliding across DIFFERENT slots must NOT cross-filter —
-        a "Custom" filament shouldn't hide a "Custom" printer."""
+    def test_bambu_cloud_filament_metadata_backfilled_from_local(self):
+        """Bambu Cloud's list response omits filament_type/colour for
+        rate-limit reasons. A same-named local entry's metadata fills in
+        so the cloud entry can still score in pickFilamentForSlot."""
+        local = {
+            "printer": [],
+            "process": [],
+            "filament": [
+                UnifiedPreset(
+                    id="lp1",
+                    name="Bambu PLA Basic",
+                    source="local",
+                    filament_type="PLA",
+                    filament_colour="#FF0000",
+                )
+            ],
+        }
         cloud = {
             "printer": [],
             "process": [],
-            "filament": [UnifiedPreset(id="cf1", name="Custom", source="cloud")],
+            "filament": [UnifiedPreset(id="cp1", name="Bambu PLA Basic", source="cloud")],
+        }
+        _oc, c, _l, _s = sp._enrich_cloud_metadata(_slot([]), cloud, local, _slot([]))
+        # Cloud entry now carries the local entry's metadata.
+        assert c["filament"][0].filament_type == "PLA"
+        assert c["filament"][0].filament_colour == "#FF0000"
+        # Local entry is untouched.
+        assert local["filament"][0].filament_type == "PLA"
+
+    def test_bambu_cloud_metadata_falls_back_through_orca_and_standard(self):
+        """When local doesn't carry the name, orca_cloud / standard fill in."""
+        orca = {
+            "printer": [],
+            "process": [],
+            "filament": [
+                UnifiedPreset(
+                    id="o1",
+                    name="Bambu PLA Basic",
+                    source="orca_cloud",
+                    filament_type="PLA",
+                    filament_colour="#00FF00",
+                )
+            ],
         }
+        cloud = {
+            "printer": [],
+            "process": [],
+            "filament": [UnifiedPreset(id="cp1", name="Bambu PLA Basic", source="cloud")],
+        }
+        _oc, c, _l, _s = sp._enrich_cloud_metadata(orca, cloud, _slot([]), _slot([]))
+        assert c["filament"][0].filament_type == "PLA"
+        assert c["filament"][0].filament_colour == "#00FF00"
+
+    def test_bambu_cloud_keeps_its_own_metadata_when_present(self):
+        """If Bambu Cloud already has filament_type / filament_colour the
+        enrich pass must not overwrite them with a different same-named
+        entry's values."""
         local = {
-            "printer": [UnifiedPreset(id="lp1", name="Custom", source="local")],
+            "printer": [],
+            "process": [],
+            "filament": [
+                UnifiedPreset(
+                    id="lp1",
+                    name="Bambu PLA Basic",
+                    source="local",
+                    filament_type="PETG",
+                    filament_colour="#000000",
+                )
+            ],
+        }
+        cloud = {
+            "printer": [],
             "process": [],
-            "filament": [],
+            "filament": [
+                UnifiedPreset(
+                    id="cp1",
+                    name="Bambu PLA Basic",
+                    source="cloud",
+                    filament_type="PLA",
+                    filament_colour="#FFFFFF",
+                )
+            ],
         }
-        _oc, _c, l_, _s = sp._dedupe_by_name(_slot([]), cloud, local, _slot([]))
-        # The filament-tier collision must NOT remove the printer-tier "Custom".
-        assert [p.name for p in l_["printer"]] == ["Custom"]
+        _oc, c, _l, _s = sp._enrich_cloud_metadata(_slot([]), cloud, local, _slot([]))
+        assert c["filament"][0].filament_type == "PLA"
+        assert c["filament"][0].filament_colour == "#FFFFFF"
 
 
 def _user_with_cloud_auth(user_id: int = 1) -> MagicMock:
@@ -649,217 +704,6 @@ class TestResolveSlicerApiUrl:
         assert url is None
 
 
-class TestBundleRoutes:
-    """Route-level coverage for the bundle proxy endpoints. Each route
-    resolves the sidecar URL via _resolve_slicer_api_url, then proxies the
-    operation through SlicerApiService. We mock both pieces so we can pin
-    the HTTP-status mapping (sidecar input error → 400, BundleNotFoundError
-    → 404, unreachable → 503) without spinning up a sidecar.
-    """
-
-    SAMPLE_SUMMARY = sp.BundleSummary(
-        id="abc123def456abcd",
-        printer_preset_name="# Bambu Lab H2D 0.4 nozzle",
-        printer=["# Bambu Lab H2D 0.4 nozzle"],
-        process=["# 0.20mm Standard @BBL H2D"],
-        filament=["# Bambu PLA Basic @BBL H2D"],
-        version="02.06.00.50",
-    )
-
-    def _patched_service(self, **methods) -> MagicMock:
-        """Build a SlicerApiService mock that supports `async with` and
-        exposes the bundle methods via AsyncMock per the override dict."""
-        svc = MagicMock()
-        svc.__aenter__ = AsyncMock(return_value=svc)
-        svc.__aexit__ = AsyncMock(return_value=False)
-        for name, mock in methods.items():
-            setattr(svc, name, mock)
-        return svc
-
-    @pytest.mark.asyncio
-    async def test_import_bundle_happy_path(self):
-        from io import BytesIO
-
-        from fastapi import UploadFile
-
-        svc = self._patched_service(
-            import_bundle=AsyncMock(return_value=self.SAMPLE_SUMMARY),
-        )
-        with (
-            patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
-            patch.object(sp, "SlicerApiService", return_value=svc),
-        ):
-            file = UploadFile(filename="H2D.bbscfg", file=BytesIO(b"PK\x03\x04"))
-            result = await sp.import_slicer_bundle(file=file, db=MagicMock(), _=None)
-        assert result["id"] == "abc123def456abcd"
-        assert result["printer"] == ["# Bambu Lab H2D 0.4 nozzle"]
-        svc.import_bundle.assert_awaited_once()
-        kwargs = svc.import_bundle.await_args.kwargs
-        assert kwargs["filename"] == "H2D.bbscfg"
-
-    @pytest.mark.asyncio
-    async def test_import_bundle_no_sidecar_returns_503(self):
-        from io import BytesIO
-
-        from fastapi import HTTPException, UploadFile
-
-        with (
-            patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value=None)),
-            pytest.raises(HTTPException) as exc,
-        ):
-            await sp.import_slicer_bundle(
-                file=UploadFile(filename="x.bbscfg", file=BytesIO(b"x")),
-                db=MagicMock(),
-                _=None,
-            )
-        assert exc.value.status_code == 503
-
-    @pytest.mark.asyncio
-    async def test_import_bundle_empty_file_returns_400(self):
-        from io import BytesIO
-
-        from fastapi import HTTPException, UploadFile
-
-        with (
-            patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
-            pytest.raises(HTTPException) as exc,
-        ):
-            await sp.import_slicer_bundle(
-                file=UploadFile(filename="x.bbscfg", file=BytesIO(b"")),
-                db=MagicMock(),
-                _=None,
-            )
-        assert exc.value.status_code == 400
-
-    @pytest.mark.asyncio
-    async def test_import_bundle_sidecar_400_passes_through(self, caplog):
-        from io import BytesIO
-
-        from fastapi import HTTPException, UploadFile
-
-        svc = self._patched_service(
-            import_bundle=AsyncMock(side_effect=sp.SlicerInputError("bad zip")),
-        )
-        with (
-            patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
-            patch.object(sp, "SlicerApiService", return_value=svc),
-            caplog.at_level("WARNING", logger="backend.app.api.routes.slicer_presets"),
-            pytest.raises(HTTPException) as exc,
-        ):
-            await sp.import_slicer_bundle(
-                file=UploadFile(filename="x.bbscfg", file=BytesIO(b"x")),
-                db=MagicMock(),
-                _=None,
-            )
-        assert exc.value.status_code == 400
-        # #1312: the sidecar's reject reason MUST land in the log so it
-        # ends up in support bundles without us having to ask reporters
-        # to copy the FE toast.
-        assert any("bad zip" in r.message for r in caplog.records)
-        assert any("x.bbscfg" in r.message for r in caplog.records)
-
-    @pytest.mark.asyncio
-    async def test_import_bundle_sidecar_unreachable_returns_503(self):
-        from io import BytesIO
-
-        from fastapi import HTTPException, UploadFile
-
-        svc = self._patched_service(
-            import_bundle=AsyncMock(side_effect=sp.SlicerApiUnavailableError("offline")),
-        )
-        with (
-            patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
-            patch.object(sp, "SlicerApiService", return_value=svc),
-            pytest.raises(HTTPException) as exc,
-        ):
-            await sp.import_slicer_bundle(
-                file=UploadFile(filename="x.bbscfg", file=BytesIO(b"x")),
-                db=MagicMock(),
-                _=None,
-            )
-        assert exc.value.status_code == 503
-
-    @pytest.mark.asyncio
-    async def test_list_bundles_happy_path(self):
-        svc = self._patched_service(
-            list_bundles=AsyncMock(return_value=[self.SAMPLE_SUMMARY]),
-        )
-        with (
-            patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
-            patch.object(sp, "SlicerApiService", return_value=svc),
-        ):
-            result = await sp.list_slicer_bundles(db=MagicMock(), _=None)
-        assert len(result) == 1
-        assert result[0]["id"] == "abc123def456abcd"
-
-    @pytest.mark.asyncio
-    async def test_list_bundles_no_sidecar_returns_empty(self):
-        # Differs from import: list returns [] instead of 503 so the
-        # SliceModal still renders cleanly when no sidecar is configured
-        # (matches bundled-tier behaviour above).
-        with patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value=None)):
-            result = await sp.list_slicer_bundles(db=MagicMock(), _=None)
-        assert result == []
-
-    @pytest.mark.asyncio
-    async def test_list_bundles_sidecar_unreachable_returns_503(self):
-        from fastapi import HTTPException
-
-        svc = self._patched_service(
-            list_bundles=AsyncMock(side_effect=sp.SlicerApiUnavailableError("offline")),
-        )
-        with (
-            patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
-            patch.object(sp, "SlicerApiService", return_value=svc),
-            pytest.raises(HTTPException) as exc,
-        ):
-            await sp.list_slicer_bundles(db=MagicMock(), _=None)
-        assert exc.value.status_code == 503
-
-    @pytest.mark.asyncio
-    async def test_get_bundle_404(self):
-        from fastapi import HTTPException
-
-        svc = self._patched_service(
-            get_bundle=AsyncMock(side_effect=sp.BundleNotFoundError("not found")),
-        )
-        with (
-            patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
-            patch.object(sp, "SlicerApiService", return_value=svc),
-            pytest.raises(HTTPException) as exc,
-        ):
-            await sp.get_slicer_bundle("missing", db=MagicMock(), _=None)
-        assert exc.value.status_code == 404
-
-    @pytest.mark.asyncio
-    async def test_delete_bundle_204(self):
-        # delete returns None on success; FastAPI sends 204 because the route
-        # declares status_code=204.
-        svc = self._patched_service(delete_bundle=AsyncMock(return_value=None))
-        with (
-            patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
-            patch.object(sp, "SlicerApiService", return_value=svc),
-        ):
-            result = await sp.delete_slicer_bundle("abc", db=MagicMock(), _=None)
-        assert result is None
-        svc.delete_bundle.assert_awaited_once_with("abc")
-
-    @pytest.mark.asyncio
-    async def test_delete_bundle_404(self):
-        from fastapi import HTTPException
-
-        svc = self._patched_service(
-            delete_bundle=AsyncMock(side_effect=sp.BundleNotFoundError("not found")),
-        )
-        with (
-            patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
-            patch.object(sp, "SlicerApiService", return_value=svc),
-            pytest.raises(HTTPException) as exc,
-        ):
-            await sp.delete_slicer_bundle("missing", db=MagicMock(), _=None)
-        assert exc.value.status_code == 404
-
-
 class TestParseCompatiblePrinters:
     """``compatible_printers`` exposed for local process / filament presets so
     the SliceModal can filter the dropdowns by the selected printer (#1325)."""

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

@@ -1116,3 +1116,123 @@ class TestCollectGitHubBackupInfo:
         assert info["providers_used"] == {"github": 2, "gitea": 1}
         assert info["schedule_enabled_count"] == 2
         assert info["last_failure_count"] == 2
+
+
+class TestRedactRawPushStatus:
+    """Tests for _redact_raw_push_status() — the bundle dump scrubber."""
+
+    def test_drops_user_filename_and_cloud_ids(self):
+        from backend.app.api.routes.support import _redact_raw_push_status
+
+        raw = {
+            "subtask_name": "private_model.gcode",
+            "gcode_file": "Metadata/private.gcode",
+            "subtask_id": "1234567890",
+            "task_id": "9999",
+            "project_id": "proj-abc",
+            "design_id": "design-1",
+            "profile_id": "p-1",
+            "model_id": "m-1",
+            "gcode_state": "RUNNING",
+            "layer_num": 42,  # control: non-sensitive sibling must survive
+        }
+
+        out = _redact_raw_push_status(raw)
+
+        assert "subtask_name" not in out
+        assert "gcode_file" not in out
+        assert "subtask_id" not in out
+        assert "task_id" not in out
+        assert "project_id" not in out
+        assert "design_id" not in out
+        assert "profile_id" not in out
+        assert "model_id" not in out
+        assert "gcode_state" not in out
+        assert out["layer_num"] == 42
+
+    def test_redacts_net_info_ip_addresses(self):
+        from backend.app.api.routes.support import _redact_raw_push_status
+
+        raw = {
+            "net": {
+                "conf": 1,
+                "info": [
+                    {"ip": "192.168.1.42", "mask": "255.255.255.0"},
+                    {"ip": "10.0.0.1", "mask": "255.0.0.0"},
+                ],
+            },
+        }
+
+        out = _redact_raw_push_status(raw)
+
+        # LAN topology must be scrubbed (mirrors the #1429 VP fix).
+        assert out["net"]["info"][0]["ip"] == "0.0.0.0"  # nosec B104 - redaction sentinel, not a bind address
+        assert out["net"]["info"][1]["ip"] == "0.0.0.0"  # nosec B104 - redaction sentinel, not a bind address
+        # Non-IP siblings inside the entry survive so the shape stays
+        # diagnosable (interface count, mask presence, etc.).
+        assert out["net"]["info"][0]["mask"] == "255.255.255.0"
+        assert out["net"]["conf"] == 1
+
+    def test_preserves_print_cfg_and_ams_payloads(self):
+        """The point of bundling raw_data is keeping these — print.cfg is what
+        unblocks per-model AMS Backup detection (deferred in 85fbd7fc).
+        """
+        from backend.app.api.routes.support import _redact_raw_push_status
+
+        raw = {
+            "print": {
+                "cfg": 0x4000000,  # bit-26 — the H2D AMS Backup bit
+                "option": 12345,
+            },
+            "ams": {
+                "ams": [
+                    {
+                        "id": "0",
+                        "humidity": "3",
+                        "tray": [
+                            {"id": "0", "tray_type": "PLA", "tray_color": "FF0000FF"},
+                        ],
+                    }
+                ]
+            },
+            "vt_tray": {"tray_info_idx": "GFA00", "tray_type": "PLA", "tray_color": "00FF00FF"},
+            "vir_slot": [{"id": "0", "tray_type": "PLA"}],
+            "mapping": [0, 1, 2, 3],
+            "ams_extruder_map": {"0": 1},
+        }
+
+        out = _redact_raw_push_status(raw)
+
+        assert out["print"]["cfg"] == 0x4000000
+        assert out["print"]["option"] == 12345
+        assert out["ams"]["ams"][0]["tray"][0]["tray_type"] == "PLA"
+        assert out["vt_tray"]["tray_info_idx"] == "GFA00"
+        assert out["vir_slot"][0]["tray_type"] == "PLA"
+        assert out["mapping"] == [0, 1, 2, 3]
+        assert out["ams_extruder_map"] == {"0": 1}
+
+    def test_does_not_mutate_input(self):
+        """Live state.raw_data must not be touched — the dispatcher reads it on
+        every tick, mutation would race the next push.
+        """
+        from backend.app.api.routes.support import _redact_raw_push_status
+
+        raw = {
+            "subtask_name": "secret.gcode",
+            "net": {"info": [{"ip": "192.168.1.5"}]},
+            "print": {"cfg": 1},
+        }
+        original_subtask = raw["subtask_name"]
+        original_ip = raw["net"]["info"][0]["ip"]
+
+        _redact_raw_push_status(raw)
+
+        assert raw["subtask_name"] == original_subtask
+        assert raw["net"]["info"][0]["ip"] == original_ip
+
+    def test_handles_non_dict_gracefully(self):
+        from backend.app.api.routes.support import _redact_raw_push_status
+
+        assert _redact_raw_push_status(None) == {}  # type: ignore[arg-type]
+        assert _redact_raw_push_status([]) == {}  # type: ignore[arg-type]
+        assert _redact_raw_push_status("") == {}  # type: ignore[arg-type]

+ 405 - 2
backend/tests/unit/test_vp_mqtt_bridge.py

@@ -150,6 +150,22 @@ class TestBridgeLifecycle:
         target.request_status_update.assert_called_once()
         await bridge.stop()
 
+    @pytest.mark.asyncio
+    async def test_post_bind_nudge_skipped_when_target_not_connected(self):
+        """#1721: the bridge can attach before the real printer's MQTT TLS
+        handshake completes. Calling request_status_update on a disconnected
+        client logs WARNING (bambu_mqtt.py:3224); on A1 firmware that
+        reconnects aggressively, every bind cycle pollutes the support bundle
+        with a benign line. The bridge must check state.connected before
+        nudging — the next periodic pushall picks up the cache anyway.
+        """
+        target = _make_paho_client(connected=False)
+        bridge = _make_bridge(_make_server(), target)
+        await bridge.start()
+        target._request_version.assert_not_called()
+        target.request_status_update.assert_not_called()
+        await bridge.stop()
+
 
 # ---------------------------------------------------------------------------
 # Caching: push_status
@@ -402,6 +418,167 @@ class TestPushStatusCache:
 
         await bridge.stop()
 
+    @pytest.mark.asyncio
+    async def test_incremental_push_preserves_non_allowlisted_capability_fields(self):
+        """Regression for #1622: BambuStudio gates Device-tab UIs (manage
+        calibration, AMS-slot filament dropdown, ...) on capability /
+        lifecycle fields (cali_version, print_type, mc_print_stage,
+        device, ...) it reads off the cached push_status. Before the fix
+        these fields were not in the allowlist and drained out of the
+        bridge cache on the first 1 Hz incremental tick, so the slicer's
+        Device tab would grey out the gated UIs once the cache thinned.
+        After the fix the cache accumulates everything the printer has
+        ever sent, dropped only when explicitly overwritten.
+        """
+        server = _make_server()
+        bridge = _make_bridge(server)
+        await bridge.start()
+
+        full_push = json.dumps(
+            {
+                "print": {
+                    "command": "push_status",
+                    "cali_version": 2,
+                    "print_type": "idle",
+                    "gcode_state": "IDLE",
+                    "mc_print_stage": "0",
+                    "mc_stage": 0,
+                    "device": {"ext_tool": {"info": []}},
+                    "cfg": "",
+                    "home_flag": 256,
+                    "wifi_signal": "-50dBm",
+                }
+            }
+        ).encode()
+        bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", full_push)
+        await asyncio.sleep(0.01)
+
+        # Incremental push carrying only temps + wifi — none of the
+        # capability/lifecycle fields above are mentioned.
+        incremental_push = json.dumps(
+            {
+                "print": {
+                    "command": "push_status",
+                    "wifi_signal": "-55dBm",
+                    "nozzle_temper": 24.5,
+                }
+            }
+        ).encode()
+        bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", incremental_push)
+        await asyncio.sleep(0.01)
+
+        cached = bridge.get_latest_print_state()
+        # Incremental values applied.
+        assert cached["wifi_signal"] == "-55dBm"
+        assert cached["nozzle_temper"] == 24.5
+        # Capability / lifecycle fields preserved from the prior pushall
+        # — the symptoms in #1622 (Device-tab UIs disabled) trace to these
+        # exact keys missing.
+        assert cached["cali_version"] == 2
+        assert cached["print_type"] == "idle"
+        assert cached["gcode_state"] == "IDLE"
+        assert cached["mc_print_stage"] == "0"
+        assert cached["mc_stage"] == 0
+        assert cached["device"] == {"ext_tool": {"info": []}}
+        assert cached["cfg"] == ""
+        assert cached["home_flag"] == 256
+
+        await bridge.stop()
+
+    @pytest.mark.asyncio
+    async def test_partial_vt_tray_update_overlays_onto_cached_full_dict(self):
+        """Regression for #1622 round 5 (reported by @shaddowlink): right after
+        the slicer picks a filament for the external spool (vt_tray, ams_id=255),
+        Bambu firmware pushes a partial vt_tray carrying just the changed
+        fields — typically ``{tray_info_idx, tray_color}`` — and omits the
+        ~18 other keys (tray_type, state, k, n, cali_idx, nozzle_temp_min/max,
+        tray_uuid, xcam_info, ...) the slicer needs to render the slot.
+        Before this fix the per-field accumulate replaced the cached vt_tray
+        wholesale (it only carried over prev keys NOT present in new), so the
+        next 1 Hz cached-as-base push handed the slicer a stripped vt_tray and
+        BambuStudio rendered the external slot as "invalid" until a reload
+        triggered a fresh pushall. AMS slots didn't suffer because
+        `_merge_ams_dict` already deep-merged them. The fix overlays incoming
+        keys onto the previous dict for every top-level dict-shaped field
+        (excluding ams, which keeps its own deep merge).
+        """
+        server = _make_server()
+        bridge = _make_bridge(server)
+        await bridge.start()
+
+        # 1. Pushall response with the full ~20-field vt_tray dict a real
+        # P1S sends to bootstrap the slot.
+        full_push = json.dumps(
+            {
+                "print": {
+                    "command": "push_status",
+                    "vt_tray": {
+                        "id": "254",
+                        "tray_info_idx": "Pea5f68f",
+                        "tray_type": "PLA",
+                        "tray_sub_brands": "",
+                        "tray_color": "F72323FF",
+                        "tray_weight": "0",
+                        "tray_diameter": "0.00",
+                        "tray_temp": "0",
+                        "tray_time": "0",
+                        "bed_temp_type": "0",
+                        "bed_temp": "0",
+                        "nozzle_temp_max": "240",
+                        "nozzle_temp_min": "190",
+                        "xcam_info": "000000000000000000000000",
+                        "tray_uuid": "00000000000000000000000000000000",
+                        "ctype": 0,
+                        "remain": -1,
+                        "k": 0.01999999955296,
+                        "n": 1,
+                        "cali_idx": -1,
+                        "state": 3,
+                    },
+                }
+            }
+        ).encode()
+        bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", full_push)
+        await asyncio.sleep(0.01)
+
+        # 2. Incremental push carrying just the two fields the slicer's pick
+        # changed — exactly the shape the P1S firmware sends after an
+        # ams_filament_setting ack. This is what shaddowlink's wire dump
+        # captured for the failing case.
+        incremental_push = json.dumps(
+            {
+                "print": {
+                    "command": "push_status",
+                    "vt_tray": {
+                        "tray_info_idx": "Pea5f68f",
+                        "tray_color": "76D9F4FF",
+                    },
+                }
+            }
+        ).encode()
+        bridge._on_printer_raw(f"device/{H2D_SERIAL}/report", incremental_push)
+        await asyncio.sleep(0.01)
+
+        cached = bridge.get_latest_print_state()
+        vt = cached["vt_tray"]
+        # Incoming fields applied.
+        assert vt["tray_info_idx"] == "Pea5f68f"
+        assert vt["tray_color"] == "76D9F4FF"
+        # All other fields preserved from the prior pushall — without these
+        # the slicer rendered the slot as invalid.
+        assert vt["tray_type"] == "PLA"
+        assert vt["state"] == 3
+        assert vt["remain"] == -1
+        assert vt["k"] == 0.01999999955296
+        assert vt["n"] == 1
+        assert vt["cali_idx"] == -1
+        assert vt["nozzle_temp_min"] == "190"
+        assert vt["nozzle_temp_max"] == "240"
+        assert vt["tray_uuid"] == "00000000000000000000000000000000"
+        assert vt["id"] == "254"
+
+        await bridge.stop()
+
     @pytest.mark.asyncio
     async def test_partial_ams_status_update_preserves_unit_list(self):
         """#1387: Bambu firmware also sends `ams` updates where the key is
@@ -493,7 +670,12 @@ class TestPushStatusCache:
                                 {"id": "0", "tray": [{"id": "0", "tray_type": "PLA"}]},
                                 {"id": "1", "tray": [{"id": "0", "tray_type": "PETG"}]},
                             ],
-                            "tray_exist_bits": "3",
+                            # bit 0 (AMS 0 slot 0) + bit 4 (AMS 1 slot 0) = 0x11.
+                            # `_on_printer_raw` now applies the #1726 bitmask
+                            # cleanup to the cached state, so the test fixture
+                            # must declare both loaded slots — same shape the
+                            # real printer sends.
+                            "tray_exist_bits": "11",
                         },
                     }
                 }
@@ -527,6 +709,181 @@ class TestPushStatusCache:
 
         await bridge.stop()
 
+    @pytest.mark.asyncio
+    async def test_tray_exist_bits_clears_empty_slots_in_slicer_cache(self):
+        """#1726 (reported by @needo37): the bridge cache forwards the real
+        printer's raw AMS payload to the slicer. Without the empty-slot
+        cleanup that bambu_mqtt.py applies to Bambuddy's internal state, the
+        cached units carried stale `tray_type` / `tray_color` /
+        `tray_info_idx` for slots whose `tray_exist_bits` bit was 0 — and
+        BambuStudio's Sync rendered those empty slots as phantom loaded
+        filaments. After the fix the bridge runs the same shared
+        ``apply_tray_exist_bits`` helper before storing the cache.
+        """
+        server = _make_server()
+        bridge = _make_bridge(server)
+        await bridge.start()
+
+        # Pushall: AMS 0 has slots 0/1/2/3; only slots 1, 2, 3 are loaded.
+        # Slot 0 carries stale data (RFID/color/material from a previously
+        # loaded spool). `tray_exist_bits` = 0xe = 0b1110 → bit 0 unset.
+        bridge._on_printer_raw(
+            f"device/{H2D_SERIAL}/report",
+            json.dumps(
+                {
+                    "print": {
+                        "command": "push_status",
+                        "ams": {
+                            "ams": [
+                                {
+                                    "id": "0",
+                                    "tray": [
+                                        {
+                                            "id": "0",
+                                            "tray_type": "PLA",
+                                            "tray_color": "FF0000FF",
+                                            "tray_info_idx": "GFL00",
+                                            "tag_uid": "1234567890abcdef",
+                                            "tray_uuid": "abcdef1234567890abcdef1234567890",
+                                            "remain": 75,
+                                            "state": "11",
+                                        },
+                                        {"id": "1", "tray_type": "PETG", "tray_color": "00FF00FF"},
+                                        {"id": "2", "tray_type": "ABS", "tray_color": "0000FFFF"},
+                                        {"id": "3", "tray_type": "TPU", "tray_color": "FFFF00FF"},
+                                    ],
+                                }
+                            ],
+                            "tray_exist_bits": "e",
+                        },
+                    }
+                }
+            ).encode(),
+        )
+        await asyncio.sleep(0.01)
+
+        cached = bridge.get_latest_print_state()
+        slot0 = cached["ams"]["ams"][0]["tray"][0]
+        # Empty slot: stale per-tray fields wiped, state promoted to 9.
+        assert slot0["state"] == 9, "empty slot must be promoted to state=9"
+        assert slot0["tray_type"] == ""
+        assert slot0["tray_color"] == ""
+        assert slot0["tray_info_idx"] == ""
+        assert slot0["tag_uid"] == "0000000000000000"
+        assert slot0["tray_uuid"] == "00000000000000000000000000000000"
+        assert slot0["remain"] == 0
+        # Loaded slots preserved.
+        assert cached["ams"]["ams"][0]["tray"][1]["tray_type"] == "PETG"
+        assert cached["ams"]["ams"][0]["tray"][2]["tray_type"] == "ABS"
+        assert cached["ams"]["ams"][0]["tray"][3]["tray_type"] == "TPU"
+
+        await bridge.stop()
+
+    @pytest.mark.asyncio
+    async def test_tray_exist_bits_shutdown_guard_preserves_cache(self):
+        """#765 shutdown guard mirrored at the bridge: when the printer
+        powers off it sends all-zero `tray_exist_bits` paired with
+        `power_on_flag=False`. Wiping the cache on that pattern would
+        propagate phantom empties to every slicer reconnect until the
+        printer powers back on and pushes a real state. Skip cleanup
+        on the shutdown-shaped payload."""
+        server = _make_server()
+        bridge = _make_bridge(server)
+        await bridge.start()
+
+        # 1. Normal pushall — all four slots loaded.
+        bridge._on_printer_raw(
+            f"device/{H2D_SERIAL}/report",
+            json.dumps(
+                {
+                    "print": {
+                        "command": "push_status",
+                        "ams": {
+                            "ams": [
+                                {
+                                    "id": "0",
+                                    "tray": [
+                                        {"id": str(i), "tray_type": "PLA", "tray_color": f"{i:02x}{i:02x}{i:02x}FF"}
+                                        for i in range(4)
+                                    ],
+                                }
+                            ],
+                            "tray_exist_bits": "f",
+                            "power_on_flag": True,
+                        },
+                    }
+                }
+            ).encode(),
+        )
+        await asyncio.sleep(0.01)
+
+        # 2. Shutdown-shaped push: tray_exist_bits=0 + power_on_flag=False.
+        bridge._on_printer_raw(
+            f"device/{H2D_SERIAL}/report",
+            json.dumps(
+                {
+                    "print": {
+                        "command": "push_status",
+                        "ams": {
+                            "tray_exist_bits": "0",
+                            "power_on_flag": False,
+                        },
+                    }
+                }
+            ).encode(),
+        )
+        await asyncio.sleep(0.01)
+
+        cached = bridge.get_latest_print_state()
+        for i in range(4):
+            assert cached["ams"]["ams"][0]["tray"][i]["tray_type"] == "PLA", f"slot {i} must survive the shutdown push"
+
+        await bridge.stop()
+
+    @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.
+        """
+        server = _make_server()
+        bridge = _make_bridge(server)
+        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,
+                        },
+                    }
+                }
+            ).encode(),
+        )
+        await asyncio.sleep(0.01)
+
+        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"
+
+        await bridge.stop()
+
     @pytest.mark.asyncio
     async def test_partial_ams_tray_update_preserves_other_trays(self):
         """Same shape as the unit-level test but at the tray level. AMS
@@ -743,7 +1100,7 @@ class TestStatusReportCachedAsBase:
         """Wrap _publish_to_report to capture (topic, payload_dict)."""
         published: list = []
 
-        async def _capture(writer, payload, serial=""):
+        async def _capture(writer, payload, serial="", log_event=True):
             published.append((serial or server.serial, payload))
 
         server._publish_to_report = _capture  # type: ignore[assignment]
@@ -939,6 +1296,52 @@ class TestWireFormat:
         body = b"".join(captured)
         assert b'\n    "print"' in body, "publish_to_report must use indent=4 JSON"
 
+    @pytest.mark.asyncio
+    async def test_publish_records_bridge_to_slicer_event_by_default(self, monkeypatch):
+        """#1622 round 3: every bridge-synthesised reply (info.get_version answer,
+        project_file ack, on-demand pushall response) must show up in the
+        cmd.jsonl trace under the ``bridge_to_slicer`` direction so a P1S↔H2D
+        diff captures the fingerprint the slicer reads back from us."""
+        server = _make_server()
+        writer = MagicMock()
+        writer.write = lambda data: None
+        writer.drain = AsyncMock()
+
+        recorded: list = []
+        monkeypatch.setattr(
+            "backend.app.services.virtual_printer.mqtt_server.append_event",
+            lambda vp_name, direction, topic, payload: recorded.append((vp_name, direction, topic, payload)),
+        )
+
+        payload = {"info": {"command": "get_version", "sequence_id": "0"}}
+        await server._publish_to_report(writer, payload)
+
+        assert len(recorded) == 1
+        vp_name, direction, topic, recorded_payload = recorded[0]
+        assert direction == "bridge_to_slicer"
+        assert topic.endswith("/report")
+        assert recorded_payload == payload
+
+    @pytest.mark.asyncio
+    async def test_publish_skips_event_when_log_event_false(self, monkeypatch):
+        """The 1Hz periodic-push path passes ``log_event=False`` so dump_wire's
+        snapshot stays the canonical record of cache shape and the cmd.jsonl
+        isn't flooded with ~60 lines/min per VP."""
+        server = _make_server()
+        writer = MagicMock()
+        writer.write = lambda data: None
+        writer.drain = AsyncMock()
+
+        recorded: list = []
+        monkeypatch.setattr(
+            "backend.app.services.virtual_printer.mqtt_server.append_event",
+            lambda *args, **kwargs: recorded.append(args),
+        )
+
+        await server._publish_to_report(writer, {"print": {"command": "push_status"}}, log_event=False)
+
+        assert recorded == []
+
 
 # ---------------------------------------------------------------------------
 # Routing: _handle_publish

+ 206 - 0
backend/tests/unit/test_vp_wire_dump.py

@@ -0,0 +1,206 @@
+"""Tests for the env-flagged VP wire-payload dump helper used to triage
+shape-of-payload bugs like #1622."""
+
+import json
+import os
+from unittest.mock import patch
+
+import pytest
+
+from backend.app.core.config import settings as app_settings
+from backend.app.services.virtual_printer import _debug
+
+
+@pytest.fixture
+def _isolated_log_dir(tmp_path, monkeypatch):
+    with patch.object(app_settings, "log_dir", tmp_path):
+        yield tmp_path
+
+
+def test_disabled_by_default_writes_nothing(_isolated_log_dir, monkeypatch):
+    monkeypatch.delenv("BAMBUDDY_VP_DUMP_WIRE", raising=False)
+    _debug.dump_wire("VP1", "out", {"hello": "world"})
+    assert not (_isolated_log_dir / "vp_wire").exists()
+
+
+def test_enabled_writes_dict_as_pretty_json(_isolated_log_dir, monkeypatch):
+    monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
+    payload = {"print": {"ams": {"ams": [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}]}]}}}
+    _debug.dump_wire("Bambuddy P1S", "out", payload)
+    out = _isolated_log_dir / "vp_wire" / "Bambuddy_P1S_out.json"
+    assert out.is_file()
+    assert json.loads(out.read_text()) == payload
+
+
+def test_overwrites_on_repeat_call(_isolated_log_dir, monkeypatch):
+    monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
+    _debug.dump_wire("VP1", "out", {"v": 1})
+    _debug.dump_wire("VP1", "out", {"v": 2})
+    out = _isolated_log_dir / "vp_wire" / "VP1_out.json"
+    assert json.loads(out.read_text()) == {"v": 2}
+
+
+def test_separate_files_per_direction(_isolated_log_dir, monkeypatch):
+    monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
+    _debug.dump_wire("VP1", "in", {"src": "printer"})
+    _debug.dump_wire("VP1", "out", {"src": "slicer"})
+    assert (_isolated_log_dir / "vp_wire" / "VP1_in.json").is_file()
+    assert (_isolated_log_dir / "vp_wire" / "VP1_out.json").is_file()
+
+
+def test_sanitizes_path_traversal_in_vp_name(_isolated_log_dir, monkeypatch):
+    monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
+    _debug.dump_wire("../../etc/passwd", "out", {"x": 1})
+    vp_wire = _isolated_log_dir / "vp_wire"
+    files = list(vp_wire.glob("*"))
+    assert len(files) == 1
+    # The actual safety property: the written file is inside vp_wire/.
+    # `..` as a substring of a single filename component is harmless because
+    # the path separator (/) is collapsed to _ before construction.
+    assert files[0].resolve().parent == vp_wire.resolve()
+    assert "/" not in files[0].name
+
+
+def test_empty_vp_name_falls_back_to_default(_isolated_log_dir, monkeypatch):
+    monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
+    _debug.dump_wire("", "out", {"x": 1})
+    assert (_isolated_log_dir / "vp_wire" / "vp_out.json").is_file()
+
+
+def test_bytes_payload_decoded(_isolated_log_dir, monkeypatch):
+    monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
+    _debug.dump_wire("VP1", "in", b'{"raw": true}')
+    out = _isolated_log_dir / "vp_wire" / "VP1_in.json"
+    assert out.read_text() == '{"raw": true}'
+
+
+def test_unwritable_dir_is_swallowed(_isolated_log_dir, monkeypatch):
+    """A debug-instrumentation failure must not crash the bridge or 1Hz loop."""
+    monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
+    # Point log_dir at a location that mkdir refuses (a regular file occupying
+    # the path). Failure must be swallowed.
+    blocker = _isolated_log_dir / "blocker"
+    blocker.write_text("not a dir")
+    with patch.object(app_settings, "log_dir", blocker):
+        _debug.dump_wire("VP1", "out", {"x": 1})  # must not raise
+
+
+@pytest.mark.parametrize("flag_value", ["0", "false", "off", "", "no"])
+def test_falsy_flag_values_disable(_isolated_log_dir, monkeypatch, flag_value):
+    monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", flag_value)
+    _debug.dump_wire("VP1", "out", {"x": 1})
+    assert not (_isolated_log_dir / "vp_wire").exists()
+
+
+@pytest.mark.parametrize("flag_value", ["1", "true", "TRUE", "yes", "on", "On"])
+def test_truthy_flag_values_enable(_isolated_log_dir, monkeypatch, flag_value):
+    monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", flag_value)
+    _debug.dump_wire("VP1", "out", {"x": 1})
+    assert (_isolated_log_dir / "vp_wire" / "VP1_out.json").is_file()
+
+
+def test_idempotent_atomic_no_partial_file_visible(_isolated_log_dir, monkeypatch):
+    """tmp+rename pattern means a reader never sees a half-written .json file."""
+    monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
+    _debug.dump_wire("VP1", "out", {"x": 1})
+    files = sorted(p.name for p in (_isolated_log_dir / "vp_wire").iterdir())
+    # No leftover .tmp file after a successful write.
+    assert files == ["VP1_out.json"]
+
+
+def test_env_check_is_per_call_not_module_load(_isolated_log_dir, monkeypatch):
+    """Flag toggle must take effect on the next call without restarting; we
+    re-read the env var inside ``dump_wire`` rather than caching at import."""
+    monkeypatch.delenv("BAMBUDDY_VP_DUMP_WIRE", raising=False)
+    _debug.dump_wire("VP1", "out", {"v": 1})
+    assert not (_isolated_log_dir / "vp_wire").exists()
+
+    os.environ["BAMBUDDY_VP_DUMP_WIRE"] = "1"
+    try:
+        _debug.dump_wire("VP1", "out", {"v": 2})
+        assert (_isolated_log_dir / "vp_wire" / "VP1_out.json").is_file()
+    finally:
+        os.environ.pop("BAMBUDDY_VP_DUMP_WIRE", None)
+
+
+# --- append_event (command-flow trace) --------------------------------------
+
+
+def _read_jsonl(path):
+    return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
+
+
+def test_append_event_disabled_by_default_writes_nothing(_isolated_log_dir, monkeypatch):
+    monkeypatch.delenv("BAMBUDDY_VP_DUMP_WIRE", raising=False)
+    _debug.append_event("VP1", "slicer_to_bridge", "device/X/request", {"hello": "world"})
+    assert not (_isolated_log_dir / "vp_wire").exists()
+
+
+def test_append_event_appends_one_jsonl_line_per_call(_isolated_log_dir, monkeypatch):
+    monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
+    _debug.append_event("VP1", "slicer_to_bridge", "device/X/request", {"print": {"command": "ams_filament_setting"}})
+    _debug.append_event(
+        "VP1", "printer_to_slicer", "device/X/report", {"print": {"command": "ams_filament_setting", "result": "ok"}}
+    )
+    path = _isolated_log_dir / "vp_wire" / "VP1_cmd.jsonl"
+    rows = _read_jsonl(path)
+    assert len(rows) == 2
+    assert rows[0]["dir"] == "slicer_to_bridge"
+    assert rows[0]["topic"] == "device/X/request"
+    assert rows[0]["cmd"] == "print.ams_filament_setting"
+    assert rows[0]["payload"] == {"print": {"command": "ams_filament_setting"}}
+    assert rows[1]["dir"] == "printer_to_slicer"
+    assert rows[1]["cmd"] == "print.ams_filament_setting"
+
+
+def test_append_event_parses_bytes_payload(_isolated_log_dir, monkeypatch):
+    monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
+    raw = b'{"info": {"command": "get_version", "sequence_id": "0"}}'
+    _debug.append_event("VP1", "slicer_to_bridge", "device/X/request", raw)
+    rows = _read_jsonl(_isolated_log_dir / "vp_wire" / "VP1_cmd.jsonl")
+    assert rows[0]["payload"] == {"info": {"command": "get_version", "sequence_id": "0"}}
+    assert rows[0]["cmd"] == "info.get_version"
+
+
+def test_append_event_unparseable_payload_kept_as_raw(_isolated_log_dir, monkeypatch):
+    monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
+    _debug.append_event("VP1", "printer_to_slicer", "device/X/report", b"not-json-just-bytes")
+    rows = _read_jsonl(_isolated_log_dir / "vp_wire" / "VP1_cmd.jsonl")
+    assert rows[0]["payload"] == {"raw": "not-json-just-bytes"}
+    assert rows[0]["cmd"] == "?"
+
+
+def test_append_event_handles_trailing_null_from_orca(_isolated_log_dir, monkeypatch):
+    """Same #927 quirk as ``_handle_publish``: OrcaSlicer can ship publishes with a
+    trailing C-string null. The trace must still parse so the dump matches what
+    the bridge actually saw, not raw text."""
+    monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
+    _debug.append_event("VP1", "slicer_to_bridge", "device/X/request", b'{"info":{"command":"get_version"}}\x00')
+    rows = _read_jsonl(_isolated_log_dir / "vp_wire" / "VP1_cmd.jsonl")
+    assert rows[0]["payload"] == {"info": {"command": "get_version"}}
+
+
+def test_append_event_sanitizes_vp_name(_isolated_log_dir, monkeypatch):
+    monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
+    _debug.append_event("../../etc/passwd", "slicer_to_bridge", "device/X/request", {"x": 1})
+    files = list((_isolated_log_dir / "vp_wire").glob("*_cmd.jsonl"))
+    assert len(files) == 1
+    assert "/" not in files[0].name
+
+
+def test_append_event_includes_iso_timestamp(_isolated_log_dir, monkeypatch):
+    monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
+    _debug.append_event("VP1", "slicer_to_bridge", "device/X/request", {"x": 1})
+    rows = _read_jsonl(_isolated_log_dir / "vp_wire" / "VP1_cmd.jsonl")
+    ts = rows[0]["ts"]
+    # ISO-8601 with timezone (Z or +00:00 suffix from UTC).
+    assert "T" in ts and (ts.endswith("+00:00") or ts.endswith("Z"))
+
+
+def test_append_event_failure_swallowed(_isolated_log_dir, monkeypatch):
+    """Debug instrumentation must never crash the bridge or slicer loop."""
+    monkeypatch.setenv("BAMBUDDY_VP_DUMP_WIRE", "1")
+    blocker = _isolated_log_dir / "blocker"
+    blocker.write_text("not a dir")
+    with patch.object(app_settings, "log_dir", blocker):
+        _debug.append_event("VP1", "slicer_to_bridge", "device/X/request", {"x": 1})  # must not raise

BIN=BIN
frontend/public/img/printers/a2l.png


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

@@ -210,4 +210,47 @@ describe('EditArchiveModal', () => {
       expect(nameInput).toHaveValue('New Name');
     });
   });
+
+  describe('failure_reason vocabulary (#1687 follow-up)', () => {
+    // The Stats page's Failure Analysis widget groups by the raw column value.
+    // Before this fix this modal saved the translated label, so a language
+    // switch fragmented historical buckets and any round-trip through the
+    // new PATCH /print-log endpoint (which validates against camelCase keys)
+    // would reject the value. The dropdown now saves the key.
+
+    const failedArchive = { ...mockArchive, status: 'failed', failure_reason: 'filamentRunout' };
+    const legacyArchive = { ...mockArchive, status: 'failed', failure_reason: 'Filament runout' };
+
+    it('preselects the option when the stored value is already a camelCase key', () => {
+      render(<EditArchiveModal archive={failedArchive} onClose={mockOnClose} onSave={mockOnSave} />);
+      const select = screen.getByLabelText(/failure reason/i) as HTMLSelectElement;
+      expect(select.value).toBe('filamentRunout');
+    });
+
+    it('reverse-looks-up a legacy translated value back to its key', () => {
+      render(<EditArchiveModal archive={legacyArchive} onClose={mockOnClose} onSave={mockOnSave} />);
+      const select = screen.getByLabelText(/failure reason/i) as HTMLSelectElement;
+      expect(select.value).toBe('filamentRunout');
+    });
+
+    it('sends the camelCase key on save, not the translated label', async () => {
+      const user = userEvent.setup();
+      let patched: { failure_reason?: string } | undefined;
+      server.use(
+        http.patch('/api/v1/archives/:id', async ({ request }) => {
+          patched = (await request.json()) as { failure_reason?: string };
+          return HttpResponse.json({ ...failedArchive, ...patched });
+        }),
+      );
+
+      render(<EditArchiveModal archive={failedArchive} onClose={mockOnClose} onSave={mockOnSave} />);
+      const select = screen.getByLabelText(/failure reason/i);
+      await user.selectOptions(select, 'cloggedNozzle');
+      await user.click(screen.getByRole('button', { name: /save/i }));
+
+      await waitFor(() => {
+        expect(patched?.failure_reason).toBe('cloggedNozzle');
+      });
+    });
+  });
 });

+ 159 - 7
frontend/src/__tests__/components/FilamentMapping.test.tsx

@@ -10,7 +10,7 @@
  */
 
 import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
-import { screen, waitFor, cleanup } from '@testing-library/react';
+import { screen, waitFor, cleanup, fireEvent } from '@testing-library/react';
 import { http, HttpResponse } from 'msw';
 import { render } from '../utils';
 import { server } from '../mocks/server';
@@ -111,7 +111,97 @@ describe('FilamentMapping — FTS routing', () => {
     expect(plaOption.textContent).not.toMatch(/\[[LR]\]/);
   });
 
-  it('still applies the per-nozzle filter when FTS is null', async () => {
+  it('renders the per-slot force-color-match checkbox in printer mode (#1717)', async () => {
+    // Specific-printer assignment used to render FilamentMapping with no
+    // force-color-match UI even though the dispatcher honours the flag. Pin
+    // that the checkbox is now mounted and bubbles toggle events up.
+    server.use(
+      http.get(
+        '/api/v1/printers/:id/status',
+        () =>
+          HttpResponse.json(
+            createStatus({
+              fila_switch: null,
+              ams_extruder_map: { '0': 1 },  // AMS 0 → left nozzle, matching the requirement
+            }),
+          ),
+      ),
+    );
+
+    const onForceColorMatchChange = vi.fn();
+    render(
+      <FilamentMapping
+        printerId={1}
+        filamentReqs={mockFilamentReqs}
+        manualMappings={{}}
+        onManualMappingChange={() => {}}
+        currencySymbol="$"
+        defaultCostPerKg={0}
+        defaultExpanded
+        forceColorMatch={{}}
+        onForceColorMatchChange={onForceColorMatchChange}
+      />,
+    );
+
+    const checkbox = await waitFor(() => {
+      const cb = screen.getByLabelText(/Force color match/i) as HTMLInputElement;
+      expect(cb).toBeInTheDocument();
+      return cb;
+    });
+    expect(checkbox.checked).toBe(false);
+
+    fireEvent.click(checkbox);
+    expect(onForceColorMatchChange).toHaveBeenCalledTimes(1);
+    expect(onForceColorMatchChange).toHaveBeenCalledWith(1, true);
+  });
+
+  it('omits the force-color-match checkbox when no handler is provided', async () => {
+    // The checkbox is only meaningful when the caller is wired to persist the
+    // toggle; absent a handler we must not render dead UI.
+    server.use(
+      http.get(
+        '/api/v1/printers/:id/status',
+        () =>
+          HttpResponse.json(
+            createStatus({
+              fila_switch: null,
+              ams_extruder_map: { '0': 1 },
+            }),
+          ),
+      ),
+    );
+
+    render(
+      <FilamentMapping
+        printerId={1}
+        filamentReqs={mockFilamentReqs}
+        manualMappings={{}}
+        onManualMappingChange={() => {}}
+        currencySymbol="$"
+        defaultCostPerKg={0}
+        defaultExpanded
+      />,
+    );
+
+    // Wait for the panel to finish mounting (Re-read button only renders once
+    // printer status has loaded and the expanded view is open) before asserting
+    // the checkbox is absent — otherwise the queryByLabelText would pass
+    // trivially during the loading window.
+    await waitFor(() => {
+      expect(screen.getByText(/Re-read/i)).toBeInTheDocument();
+    });
+    expect(screen.queryByLabelText(/Force color match/i)).not.toBeInTheDocument();
+  });
+
+  it('offers cross-extruder slots in the dropdown without FTS (#1722)', async () => {
+    // Before #1722 the dropdown filtered to only slots whose extruder matched
+    // the filament's slicer-assigned nozzle. On a dual-nozzle printer with one
+    // AMS per side, that prevented the user from picking a slot on the OTHER
+    // extruder even when they'd intentionally loaded the required filament
+    // there. The fix: trust the user, show every loaded slot regardless of
+    // which extruder it's wired to. The L/R badge on the filament row still
+    // tells the user what the slicer planned; the printer firmware accepts
+    // or rejects the cross-extruder ams_mapping at start-print.
     server.use(
       http.get(
         '/api/v1/printers/:id/status',
@@ -137,12 +227,74 @@ describe('FilamentMapping — FTS routing', () => {
       />,
     );
 
-    // Required nozzle is 1 (LEFT) but AMS 0 is on extruder 0 (RIGHT) — neither
-    // slot should appear in the dropdown.
+    // Required nozzle is 1 (LEFT) and AMS 0 is wired to extruder 0 (RIGHT).
+    // Both slots must STILL appear so the user can pick them — explicitly the
+    // cross-extruder scenario the #1722 fix unblocks.
+    await waitFor(() => {
+      expect(screen.getByText(/Bambu PLA/)).toBeInTheDocument();
+    });
+    expect(screen.getByText(/Bambu PETG/)).toBeInTheDocument();
+  });
+
+  it('renders sub-brand + material-disambiguated colour on the required side (#1718)', async () => {
+    // Same fix as FilamentOverride: required-side label was rendering the
+    // raw 3MF type ("PLA") and the generic getColorName bucket ("Black").
+    // After the shared useFilamentLabels hook it must now resolve
+    // tray_info_idx → "Bambu PLA Matte" and the material-disambiguated
+    // colour catalogue → "Charcoal" — the Specific-Printer panel matched
+    // the Any-Model panel that was already correct.
+    server.use(
+      http.get(
+        '/api/v1/printers/:id/status',
+        () =>
+          HttpResponse.json(
+            createStatus({
+              fila_switch: null,
+              ams_extruder_map: { '0': 1 },
+            }),
+          ),
+      ),
+      http.get('/api/v1/cloud/builtin-filaments', () =>
+        HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]),
+      ),
+      http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
+      http.get('/api/v1/inventory/colors/by-material', ({ request }) => {
+        const url = new URL(request.url);
+        if (url.searchParams.get('hex') === '#000000' && url.searchParams.get('material') === 'PLA Matte') {
+          return HttpResponse.json({ color_name: 'Charcoal' });
+        }
+        return HttpResponse.json({ color_name: null });
+      }),
+    );
+
+    const charcoalReqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 25, used_meters: 8.5, nozzle_id: 1, tray_info_idx: 'GFA01' },
+      ],
+    };
+
+    render(
+      <FilamentMapping
+        printerId={1}
+        filamentReqs={charcoalReqs}
+        manualMappings={{}}
+        onManualMappingChange={() => {}}
+        currencySymbol="$"
+        defaultCostPerKg={0}
+        defaultExpanded
+      />,
+    );
+
+    // Required-side type text picks up the resolved sub-brand.
+    await waitFor(() => {
+      expect(screen.getByText(/Bambu PLA Matte/)).toBeInTheDocument();
+    });
+    // The swatch tooltip carries the disambiguated "Charcoal" instead of
+    // the generic "Black" bucket; check the title attr on the colour
+    // circle's parent span.
     await waitFor(() => {
-      // Wait for component to render — the slot label should NOT be present
-      expect(screen.queryByText(/Bambu PLA/)).not.toBeInTheDocument();
-      expect(screen.queryByText(/Bambu PETG/)).not.toBeInTheDocument();
+      const swatch = screen.getByTitle(/Required: Bambu PLA Matte - Charcoal/);
+      expect(swatch).toBeInTheDocument();
     });
   });
 });

+ 240 - 1
frontend/src/__tests__/components/FilamentOverride.test.tsx

@@ -6,8 +6,10 @@
  */
 
 import { describe, it, expect, vi, afterEach } from 'vitest';
-import { screen, fireEvent, cleanup } from '@testing-library/react';
+import { screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
 import { render } from '../utils';
+import { server } from '../mocks/server';
 import { FilamentOverride } from '../../components/PrintModal/FilamentOverride';
 import type { FilamentReqsData } from '../../components/PrintModal/types';
 
@@ -337,4 +339,241 @@ describe('FilamentOverride', () => {
       expect(mockOnChange).toHaveBeenCalledWith({});
     });
   });
+
+  describe('original-label SKU resolution (#1718)', () => {
+    it('uses the builtin filament name when tray_info_idx maps to a known SKU', async () => {
+      // Stamped by Bambu Studio when slicing with PLA Matte Charcoal: 3MF
+      // carries type=PLA + the GFA01 SKU. Without resolution the label
+      // collapses to "PLA (Black)" which was Sam's bug.
+      server.use(
+        http.get('/api/v1/cloud/builtin-filaments', () =>
+          HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]),
+        ),
+        http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
+      );
+
+      const reqs: FilamentReqsData = {
+        filaments: [
+          { slot_id: 1, type: 'PLA', color: '#1A1A1A', used_grams: 25, used_meters: 8.5, tray_info_idx: 'GFA01' },
+        ],
+      };
+
+      render(
+        <FilamentOverride
+          filamentReqs={reqs}
+          availableFilaments={defaultAvailable}
+          overrides={{}}
+          onChange={mockOnChange}
+        />,
+      );
+
+      // Wait for the queries to resolve and the resolved label to land in
+      // the dropdown's "original" placeholder option. The tooltip on the
+      // color swatch carries the same text, so we scope to the option to
+      // avoid the multi-match.
+      await waitFor(() => {
+        const select = screen.getByRole('combobox');
+        const placeholder = select.querySelector('option[value=""]');
+        expect(placeholder?.textContent).toMatch(/Bambu PLA Matte/);
+      });
+    });
+
+    it('prefers the cloud user-preset name over the builtin entry for the same id', async () => {
+      // Cloud user-preset names are more specific than the builtin fallback —
+      // e.g. a user has renamed GFA00 to "My House PLA".
+      server.use(
+        http.get('/api/v1/cloud/builtin-filaments', () =>
+          HttpResponse.json([{ filament_id: 'GFA00', name: 'Bambu PLA Basic' }]),
+        ),
+        http.get('/api/v1/cloud/filament-id-map', () =>
+          HttpResponse.json({ GFA00: 'My House PLA' }),
+        ),
+      );
+
+      const reqs: FilamentReqsData = {
+        filaments: [
+          { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 25, used_meters: 8.5, tray_info_idx: 'GFA00' },
+        ],
+      };
+
+      render(
+        <FilamentOverride
+          filamentReqs={reqs}
+          availableFilaments={defaultAvailable}
+          overrides={{}}
+          onChange={mockOnChange}
+        />,
+      );
+
+      await waitFor(() => {
+        const select = screen.getByRole('combobox');
+        const placeholder = select.querySelector('option[value=""]');
+        expect(placeholder?.textContent).toMatch(/My House PLA/);
+      });
+      // The builtin fallback must NOT bleed through anywhere — neither the
+      // placeholder option nor the tooltip.
+      expect(screen.queryByText(/Bambu PLA Basic/)).not.toBeInTheDocument();
+    });
+
+    it('uses the material-disambiguated catalogue color name (PLA Matte Charcoal — #1718 round 2)', async () => {
+      // Sam's exact case: 3MF carries hex #000000 + tray_info_idx GFA01.
+      // Without material context, /colors/map collapses #000000 to "Black"
+      // (PLA Basic wins the priority race). The override panel must pass
+      // the derived material hint "PLA Matte" through to /colors/by-material
+      // so the user sees "Charcoal" — the actually-sliced color.
+      server.use(
+        http.get('/api/v1/cloud/builtin-filaments', () =>
+          HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]),
+        ),
+        http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
+        http.get('/api/v1/inventory/colors/by-material', ({ request }) => {
+          const url = new URL(request.url);
+          const hex = url.searchParams.get('hex');
+          const material = url.searchParams.get('material');
+          if (hex === '#000000' && material === 'PLA Matte') {
+            return HttpResponse.json({ color_name: 'Charcoal' });
+          }
+          return HttpResponse.json({ color_name: null });
+        }),
+      );
+
+      const reqs: FilamentReqsData = {
+        filaments: [
+          { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 25, used_meters: 8.5, tray_info_idx: 'GFA01' },
+        ],
+      };
+
+      render(
+        <FilamentOverride
+          filamentReqs={reqs}
+          availableFilaments={defaultAvailable}
+          overrides={{}}
+          onChange={mockOnChange}
+        />,
+      );
+
+      await waitFor(() => {
+        const select = screen.getByRole('combobox');
+        const placeholder = select.querySelector('option[value=""]');
+        expect(placeholder?.textContent).toMatch(/Bambu PLA Matte \(Charcoal\)/);
+      });
+    });
+
+    it('disambiguates per slot when two slots share a hex but differ in material', async () => {
+      // Regression guard: the per-slot useQueries dispatch must key on
+      // (hex, material) so a "PLA Matte Charcoal" slot does not adopt the
+      // "PLA Basic Black" slot's answer.
+      server.use(
+        http.get('/api/v1/cloud/builtin-filaments', () =>
+          HttpResponse.json([
+            { filament_id: 'GFA00', name: 'Bambu PLA Basic' },
+            { filament_id: 'GFA01', name: 'Bambu PLA Matte' },
+          ]),
+        ),
+        http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
+        http.get('/api/v1/inventory/colors/by-material', ({ request }) => {
+          const url = new URL(request.url);
+          const material = url.searchParams.get('material');
+          if (material === 'PLA Matte') return HttpResponse.json({ color_name: 'Charcoal' });
+          if (material === 'PLA Basic') return HttpResponse.json({ color_name: 'Black' });
+          return HttpResponse.json({ color_name: null });
+        }),
+      );
+
+      const reqs: FilamentReqsData = {
+        filaments: [
+          { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 25, used_meters: 8.5, tray_info_idx: 'GFA01' },
+          { slot_id: 2, type: 'PLA', color: '#000000', used_grams: 10, used_meters: 3.2, tray_info_idx: 'GFA00' },
+        ],
+      };
+
+      render(
+        <FilamentOverride
+          filamentReqs={reqs}
+          availableFilaments={defaultAvailable}
+          overrides={{}}
+          onChange={mockOnChange}
+        />,
+      );
+
+      await waitFor(() => {
+        const selects = screen.getAllByRole('combobox');
+        expect(selects).toHaveLength(2);
+        expect(selects[0].querySelector('option[value=""]')?.textContent).toMatch(/Bambu PLA Matte \(Charcoal\)/);
+        expect(selects[1].querySelector('option[value=""]')?.textContent).toMatch(/Bambu PLA Basic \(Black\)/);
+      });
+    });
+
+    it('falls back to getColorName(hex) when the by-material lookup returns null', async () => {
+      // Any time the catalogue has no entry for the hex (or the endpoint is
+      // unreachable), the placeholder must still render — the HSL-bucket
+      // fallback is strictly better than a blank.
+      server.use(
+        http.get('/api/v1/cloud/builtin-filaments', () =>
+          HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu 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 }),
+        ),
+      );
+
+      const reqs: FilamentReqsData = {
+        filaments: [
+          { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 25, used_meters: 8.5, tray_info_idx: 'GFA01' },
+        ],
+      };
+
+      render(
+        <FilamentOverride
+          filamentReqs={reqs}
+          availableFilaments={defaultAvailable}
+          overrides={{}}
+          onChange={mockOnChange}
+        />,
+      );
+
+      // Wait for the builtin lookup to land so we know the row mounted; the
+      // colour fallback to getColorName for #FF0000 produces "Red"-shaped text.
+      await waitFor(() => {
+        const select = screen.getByRole('combobox');
+        const placeholder = select.querySelector('option[value=""]');
+        expect(placeholder?.textContent).toMatch(/Bambu PLA Matte/);
+        expect(placeholder?.textContent).not.toMatch(/null/);
+      });
+    });
+
+    it('falls back to the raw type when the SKU is unknown to both maps', async () => {
+      // Unknown ids must not break rendering — the original "PLA" label is
+      // still better than a blank.
+      server.use(
+        http.get('/api/v1/cloud/builtin-filaments', () => HttpResponse.json([])),
+        http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
+      );
+
+      const reqs: FilamentReqsData = {
+        filaments: [
+          { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 25, used_meters: 8.5, tray_info_idx: 'GFXXX' },
+        ],
+      };
+
+      render(
+        <FilamentOverride
+          filamentReqs={reqs}
+          availableFilaments={defaultAvailable}
+          overrides={{}}
+          onChange={mockOnChange}
+        />,
+      );
+
+      // (25g) is the easiest signal the row mounted at all; once it's there,
+      // assert the placeholder option carries the raw type.
+      await waitFor(() => {
+        expect(screen.getByText('(25g)')).toBeInTheDocument();
+      });
+      const select = screen.getByRole('combobox');
+      const placeholder = select.querySelector('option[value=""]');
+      expect(placeholder?.textContent).toMatch(/PLA \(/);
+    });
+  });
 });

+ 17 - 0
frontend/src/__tests__/components/PrintLogModal.test.tsx

@@ -97,6 +97,23 @@ describe('PrintLogModal', () => {
     });
   });
 
+  it('translates camelCase failure_reason keys (#1687 follow-up)', async () => {
+    vi.mocked(api.getArchiveRuns).mockResolvedValue({
+      total: 1,
+      items: [
+        {
+          ...sampleRuns.items[0],
+          failure_reason: 'filamentRunout',
+        },
+      ],
+    });
+    render(<PrintLogModal archiveId={42} archiveName="Benchy" onClose={vi.fn()} />);
+    await waitFor(() => {
+      expect(screen.getByText('Filament runout')).toBeInTheDocument();
+    });
+    expect(screen.queryByText('filamentRunout')).not.toBeInTheDocument();
+  });
+
   it('shows the empty state when there are no runs', async () => {
     vi.mocked(api.getArchiveRuns).mockResolvedValue({ total: 0, items: [] });
     render(<PrintLogModal archiveId={42} archiveName="Benchy" onClose={vi.fn()} />);

+ 11 - 179
frontend/src/__tests__/components/SliceModal.test.tsx

@@ -26,7 +26,6 @@ vi.mock('../../api/client', () => ({
     getArchivePlates: vi.fn(),
     getLibraryFileFilamentRequirements: vi.fn(),
     getArchiveFilamentRequirements: vi.fn(),
-    listSlicerBundles: vi.fn(),
     getSettings: vi.fn().mockResolvedValue({}),
     updateSettings: vi.fn().mockResolvedValue({}),
   },
@@ -41,7 +40,6 @@ const mockApi = api as unknown as {
   getArchivePlates: ReturnType<typeof vi.fn>;
   getLibraryFileFilamentRequirements: ReturnType<typeof vi.fn>;
   getArchiveFilamentRequirements: ReturnType<typeof vi.fn>;
-  listSlicerBundles: ReturnType<typeof vi.fn>;
 };
 
 function makeUnified(overrides: Partial<UnifiedPresetsResponse> = {}): UnifiedPresetsResponse {
@@ -123,10 +121,6 @@ describe('SliceModal', () => {
       plate_id: 1,
       filaments: [],
     });
-    // Default: no bundles imported. Bundle-tier tests override this with a
-    // populated array; everything else inherits the empty default so the
-    // modal renders the original (preset-only) layout.
-    mockApi.listSlicerBundles.mockResolvedValue([]);
   });
 
   it('auto-selects the highest-priority tier per slot on first load', async () => {
@@ -392,10 +386,15 @@ describe('SliceModal', () => {
     });
   });
 
-  it('renders a "sign in" banner when cloud_status is not_authenticated', async () => {
+  it('omits the cloud banner when status is not_authenticated (#1712)', async () => {
+    // A signed-out user (Bambu or Orca) shouldn't get a permanent "sign in"
+    // nag at the top of every slice. Sign-in lives on the Profiles page; the
+    // modal stays silent unless a previously-signed-in session actually broke
+    // (expired / unreachable).
     mockApi.getSlicerPresets.mockResolvedValue(
       makeUnified({
         cloud_status: 'not_authenticated',
+        orca_cloud_status: 'not_authenticated',
         local: fullThreeTier.local,
         standard: fullThreeTier.standard,
       }),
@@ -405,9 +404,8 @@ describe('SliceModal', () => {
       onClose: vi.fn(),
     });
 
-    await waitFor(() => {
-      expect(screen.getByRole('status')).toHaveTextContent(/Sign in to Bambu Cloud/i);
-    });
+    await waitFor(() => expect(screen.getByText('Imported X1C 0.4')).toBeDefined());
+    expect(screen.queryByRole('status')).toBeNull();
   });
 
   it('renders an "expired" banner when cloud_status is expired', async () => {
@@ -832,9 +830,9 @@ describe('SliceModal', () => {
 
   // Cross-printer re-slicing is a normal, supported operation as of
   // 2026-05-20 (Step 0 empirical test: sidecar overrides printer / process
-  // / bed / kinematics from the picked bundle, producing valid target-
-  // printer G-code). No banner, no warning — the picker UI already shows
-  // which printer the user picked, and that's enough.
+  // / bed / kinematics from the picked profile triplet, producing valid
+  // target-printer G-code). No banner, no warning — the picker UI already
+  // shows which printer the user picked, and that's enough.
   it('does not surface any cross-printer banner and keeps Slice enabled when models differ', async () => {
     mockApi.getLibraryFilePlates.mockResolvedValue({
       file_id: 100,
@@ -1020,170 +1018,4 @@ describe('SliceModal', () => {
     });
   });
 
-  // -------------------------------------------------------------------------
-  // Bundle tier — picking an imported .bbscfg replaces the cloud/local/standard
-  // dropdown set with bundle-scoped pickers and routes the slice through the
-  // backend's bundle dispatch shape (no PresetRefs in the body).
-  // -------------------------------------------------------------------------
-
-  describe('Bundle tier', () => {
-    const sampleBundle = {
-      id: 'abc123def456abcd',
-      printer_preset_name: '# Bambu Lab H2D 0.4 nozzle',
-      printer: ['# Bambu Lab H2D 0.4 nozzle'],
-      process: [
-        '# 0.20mm Standard @BBL H2D',
-        '# 0.16mm Standard @BBL H2D',
-      ],
-      filament: [
-        '# Bambu PLA Basic @BBL H2D',
-        '# Bambu PETG HF @BBL H2D 0.4 nozzle',
-      ],
-      version: '02.06.00.50',
-    };
-
-    it('hides the bundle picker when no bundles are imported', async () => {
-      // Default beforeEach already returns []; assert the picker isn't
-      // rendered so users without bundles see the original layout.
-      renderWithTracker({
-        source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
-        onClose: vi.fn(),
-      });
-      await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
-      expect(screen.queryByText(/slicer bundle/i)).toBeNull();
-    });
-
-    it('renders the bundle picker when at least one bundle is imported', async () => {
-      mockApi.listSlicerBundles.mockResolvedValue([sampleBundle]);
-      renderWithTracker({
-        source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
-        onClose: vi.fn(),
-      });
-      await waitFor(() =>
-        expect(screen.getByText(/slicer bundle/i)).toBeDefined(),
-      );
-      // The bundle option is in the dropdown.
-      const bundleSelect = screen.getAllByRole('combobox')[0] as HTMLSelectElement;
-      expect(
-        Array.from(bundleSelect.options).map((o) => o.textContent),
-      ).toContain('# Bambu Lab H2D 0.4 nozzle');
-    });
-
-    it('replaces preset dropdowns with bundle-scoped pickers when a bundle is selected', async () => {
-      mockApi.listSlicerBundles.mockResolvedValue([sampleBundle]);
-      renderWithTracker({
-        source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
-        onClose: vi.fn(),
-      });
-      await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
-
-      const user = userEvent.setup();
-      const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
-      // First select is the bundle picker (new top-of-modal dropdown).
-      await user.selectOptions(selects[0], sampleBundle.id);
-
-      // Wait for the bundle-mode UI to take over: process options should
-      // now reflect the bundle's process names.
-      await waitFor(() => {
-        expect(
-          screen.getByText('# 0.20mm Standard @BBL H2D'),
-        ).toBeDefined();
-      });
-
-      // The static printer label shows the bundle's printer. Both the
-      // <option> in the bundle picker and the read-only <div> below
-      // contain this text, so use getAllByText.
-      const printerNameMatches = screen.getAllByText('# Bambu Lab H2D 0.4 nozzle');
-      expect(printerNameMatches.length).toBeGreaterThanOrEqual(2);
-
-      // Cloud/local/standard preset names from the original tier no longer
-      // appear in the visible dropdowns (the bundle replaced them).
-      const visibleSelects = screen.getAllByRole('combobox') as HTMLSelectElement[];
-      const allOptionTexts = visibleSelects.flatMap((sel) =>
-        Array.from(sel.options).map((o) => o.textContent ?? ''),
-      );
-      // Cloud printer name shouldn't be in any visible dropdown anymore.
-      expect(allOptionTexts).not.toContain('My Custom X1C');
-    });
-
-    it('submits bundle dispatch shape (no PresetRefs) when a bundle is selected', async () => {
-      mockApi.listSlicerBundles.mockResolvedValue([sampleBundle]);
-      mockApi.sliceLibraryFile.mockResolvedValue({
-        job_id: 99,
-        status: 'pending',
-        status_url: '/api/v1/slice-jobs/99',
-      });
-
-      renderWithTracker({
-        source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
-        onClose: vi.fn(),
-      });
-      await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
-
-      const user = userEvent.setup();
-      const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
-      await user.selectOptions(selects[0], sampleBundle.id);
-
-      // Wait for bundle-mode dropdowns to render.
-      await waitFor(() =>
-        expect(screen.getByText('# 0.20mm Standard @BBL H2D')).toBeDefined(),
-      );
-      await user.click(screen.getByRole('button', { name: /^Slice$/ }));
-
-      await waitFor(() => {
-        const [fileId, body] = mockApi.sliceLibraryFile.mock.calls[0];
-        expect(fileId).toBe(100);
-        expect(body.bundle).toEqual({
-          bundle_id: sampleBundle.id,
-          printer_name: '# Bambu Lab H2D 0.4 nozzle',
-          process_name: '# 0.20mm Standard @BBL H2D',
-          filament_names: ['# Bambu PLA Basic @BBL H2D'],
-        });
-        // The preset triplet must NOT be in the body — bundle dispatch
-        // skips PresetRef resolution entirely on the backend.
-        expect(body.printer_preset).toBeUndefined();
-        expect(body.process_preset).toBeUndefined();
-        expect(body.filament_presets).toBeUndefined();
-      });
-    });
-
-    it('switching back to "None" restores the preset triplet path', async () => {
-      mockApi.listSlicerBundles.mockResolvedValue([sampleBundle]);
-      mockApi.sliceLibraryFile.mockResolvedValue({
-        job_id: 100,
-        status: 'pending',
-        status_url: '/api/v1/slice-jobs/100',
-      });
-
-      renderWithTracker({
-        source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
-        onClose: vi.fn(),
-      });
-      await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
-
-      const user = userEvent.setup();
-      const bundleSelect = screen.getAllByRole('combobox')[0] as HTMLSelectElement;
-      await user.selectOptions(bundleSelect, sampleBundle.id);
-      await waitFor(() =>
-        expect(screen.getByText('# 0.20mm Standard @BBL H2D')).toBeDefined(),
-      );
-
-      // Flip back to None.
-      await user.selectOptions(bundleSelect, '');
-      await waitFor(() => {
-        const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
-        // After de-selecting bundle, the printer dropdown's first option
-        // should be one of the original cloud/local/standard names.
-        const printerOptions = Array.from(selects[1].options).map((o) => o.textContent);
-        expect(printerOptions).toContain('My Custom X1C');
-      });
-
-      await user.click(screen.getByRole('button', { name: /^Slice$/ }));
-      await waitFor(() => {
-        const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
-        expect(body.bundle).toBeUndefined();
-        expect(body.printer_preset).toBeDefined();
-      });
-    });
-  });
 });

+ 0 - 214
frontend/src/__tests__/components/SlicerBundlesPanel.test.tsx

@@ -1,214 +0,0 @@
-/**
- * Tests for the SlicerBundlesPanel — Settings panel for managing
- * BambuStudio Printer Preset Bundles (.bbscfg) on the slicer sidecar.
- *
- * Coverage:
- *  - Empty state when the sidecar has no bundles imported yet.
- *  - List rendering with summary line (process / filament counts).
- *  - Upload happy path → success toast + list invalidation.
- *  - Upload error → error toast.
- *  - Delete with confirmation → success toast + list invalidation.
- *  - Delete error → error toast.
- */
-
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { screen, fireEvent, waitFor } from '@testing-library/react';
-import { render } from '../utils';
-import { api } from '../../api/client';
-import { SlicerBundlesPanel } from '../../components/SlicerBundlesPanel';
-
-vi.mock('../../api/client', async () => {
-  const actual: typeof import('../../api/client') = await vi.importActual(
-    '../../api/client',
-  );
-  return {
-    ...actual,
-    api: {
-      ...actual.api,
-      listSlicerBundles: vi.fn(),
-      importSlicerBundle: vi.fn(),
-      deleteSlicerBundle: vi.fn(),
-    },
-    getAuthToken: vi.fn(() => null),
-  };
-});
-
-const SAMPLE_BUNDLE = {
-  id: 'abc123def456abcd',
-  printer_preset_name: '# Bambu Lab H2D 0.4 nozzle',
-  printer: ['# Bambu Lab H2D 0.4 nozzle'],
-  process: [
-    '# 0.20mm Standard @BBL H2D',
-    '# 0.16mm Standard @BBL H2D',
-  ],
-  filament: [
-    '# Bambu PLA Basic @BBL H2D',
-    '# Bambu PETG HF @BBL H2D 0.4 nozzle',
-    '# Bambu ABS @BBL H2D',
-  ],
-  version: '02.06.00.50',
-};
-
-beforeEach(() => {
-  vi.clearAllMocks();
-});
-
-describe('SlicerBundlesPanel — empty state', () => {
-  it('renders the empty-state message when no bundles exist', async () => {
-    vi.mocked(api.listSlicerBundles).mockResolvedValueOnce([]);
-
-    render(<SlicerBundlesPanel />);
-
-    await waitFor(() =>
-      expect(api.listSlicerBundles).toHaveBeenCalled(),
-    );
-    expect(
-      await screen.findByText(/no bundles imported yet/i),
-    ).toBeInTheDocument();
-  });
-});
-
-describe('SlicerBundlesPanel — list rendering', () => {
-  it('renders bundle name + summary (process and filament counts)', async () => {
-    vi.mocked(api.listSlicerBundles).mockResolvedValueOnce([SAMPLE_BUNDLE]);
-
-    render(<SlicerBundlesPanel />);
-
-    expect(
-      await screen.findByText('# Bambu Lab H2D 0.4 nozzle'),
-    ).toBeInTheDocument();
-    // Summary should reflect 2 process + 3 filament from the fixture.
-    expect(
-      await screen.findByText(/2 process · 3 filament/i),
-    ).toBeInTheDocument();
-    // Version suffix appended after the summary.
-    expect(screen.getByText(/v02\.06\.00\.50/)).toBeInTheDocument();
-  });
-});
-
-describe('SlicerBundlesPanel — upload flow', () => {
-  it('imports a selected file and refreshes the list on success', async () => {
-    // First listing call returns empty so the test can detect the post-import
-    // re-fetch (second call) returning the new bundle.
-    vi.mocked(api.listSlicerBundles)
-      .mockResolvedValueOnce([])
-      .mockResolvedValueOnce([SAMPLE_BUNDLE]);
-    vi.mocked(api.importSlicerBundle).mockResolvedValueOnce(SAMPLE_BUNDLE);
-
-    const { container } = render(<SlicerBundlesPanel />);
-
-    // The file input is hidden (display: none for styling); grab it directly.
-    const fileInput = container.querySelector(
-      'input[type="file"]',
-    ) as HTMLInputElement;
-    expect(fileInput).toBeTruthy();
-
-    const file = new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], 'H2D.bbscfg', {
-      type: 'application/zip',
-    });
-
-    fireEvent.change(fileInput, { target: { files: [file] } });
-
-    await waitFor(() =>
-      expect(api.importSlicerBundle).toHaveBeenCalledWith(file),
-    );
-    // After the success, the list call should fire a second time (cache
-    // invalidation by react-query).
-    await waitFor(() =>
-      expect(api.listSlicerBundles).toHaveBeenCalledTimes(2),
-    );
-    // The newly imported bundle should now be visible in the list.
-    expect(
-      await screen.findByText('# Bambu Lab H2D 0.4 nozzle'),
-    ).toBeInTheDocument();
-  });
-
-  it('shows an error and does not refresh on upload failure', async () => {
-    vi.mocked(api.listSlicerBundles).mockResolvedValueOnce([]);
-    vi.mocked(api.importSlicerBundle).mockRejectedValueOnce(
-      new Error('Bundle is missing bundle_structure.json'),
-    );
-
-    const { container } = render(<SlicerBundlesPanel />);
-
-    await waitFor(() => expect(api.listSlicerBundles).toHaveBeenCalled());
-
-    const fileInput = container.querySelector(
-      'input[type="file"]',
-    ) as HTMLInputElement;
-    const file = new File([new Uint8Array([0])], 'bad.bbscfg', {
-      type: 'application/zip',
-    });
-    fireEvent.change(fileInput, { target: { files: [file] } });
-
-    await waitFor(() =>
-      expect(api.importSlicerBundle).toHaveBeenCalled(),
-    );
-    // Listing should NOT be re-called on failure — only the initial load.
-    expect(api.listSlicerBundles).toHaveBeenCalledTimes(1);
-    // Empty state still showing.
-    expect(
-      screen.getByText(/no bundles imported yet/i),
-    ).toBeInTheDocument();
-  });
-});
-
-describe('SlicerBundlesPanel — delete flow', () => {
-  it('deletes a bundle after confirmation and refreshes the list', async () => {
-    vi.mocked(api.listSlicerBundles)
-      .mockResolvedValueOnce([SAMPLE_BUNDLE])
-      .mockResolvedValueOnce([]);
-    vi.mocked(api.deleteSlicerBundle).mockResolvedValueOnce(undefined);
-
-    render(<SlicerBundlesPanel />);
-
-    // Wait for the bundle to render.
-    await screen.findByText('# Bambu Lab H2D 0.4 nozzle');
-
-    // Click the trash button (aria-label="Delete").
-    fireEvent.click(screen.getByRole('button', { name: /delete/i }));
-
-    // ConfirmModal should appear with the bundle name in the message.
-    const confirmMessage = await screen.findByText(
-      /Slice requests that reference "# Bambu Lab H2D 0.4 nozzle" will fail/i,
-    );
-    expect(confirmMessage).toBeInTheDocument();
-
-    // The modal renders its own "Delete" button — there are now two buttons
-    // matching /delete/i. Click the one inside the dialog (last in document
-    // order, since the modal portal renders after the panel).
-    const deleteButtons = screen.getAllByRole('button', { name: /delete/i });
-    fireEvent.click(deleteButtons[deleteButtons.length - 1]);
-
-    await waitFor(() =>
-      expect(api.deleteSlicerBundle).toHaveBeenCalledWith(
-        'abc123def456abcd',
-      ),
-    );
-    // Cache invalidation should re-fire the list query.
-    await waitFor(() =>
-      expect(api.listSlicerBundles).toHaveBeenCalledTimes(2),
-    );
-  });
-
-  it('keeps the bundle in the list when the user cancels the delete dialog', async () => {
-    vi.mocked(api.listSlicerBundles).mockResolvedValueOnce([SAMPLE_BUNDLE]);
-
-    render(<SlicerBundlesPanel />);
-
-    await screen.findByText('# Bambu Lab H2D 0.4 nozzle');
-    fireEvent.click(screen.getByRole('button', { name: /delete/i }));
-
-    // Cancel by clicking the "Cancel" button on the ConfirmModal.
-    const cancelButton = await screen.findByRole('button', { name: /cancel/i });
-    fireEvent.click(cancelButton);
-
-    // Delete API never called, list never re-fetched.
-    expect(api.deleteSlicerBundle).not.toHaveBeenCalled();
-    expect(api.listSlicerBundles).toHaveBeenCalledTimes(1);
-    // Bundle still rendered.
-    expect(
-      screen.getByText('# Bambu Lab H2D 0.4 nozzle'),
-    ).toBeInTheDocument();
-  });
-});

+ 230 - 0
frontend/src/__tests__/hooks/useFilamentLabels.test.tsx

@@ -0,0 +1,230 @@
+/**
+ * Tests for the shared filament-label resolution hook (#1718 round 3).
+ *
+ * Round 3 extracted the three-query resolution machinery out of
+ * ``FilamentOverride`` so the printer-mode ``FilamentMapping`` could share
+ * the same label logic without drift. Both panels are integration-tested
+ * already, but the hook deserves direct coverage so future edits don't break
+ * a subtle contract (positional output alignment, fallback chain, query
+ * dedup when the SKU is unknown).
+ */
+
+import { describe, it, expect, afterEach } from 'vitest';
+import { renderHook, waitFor, cleanup } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { http, HttpResponse } from 'msw';
+import { type ReactNode } from 'react';
+import { server } from '../mocks/server';
+import { extractMaterialHint, useFilamentLabels } from '../../components/PrintModal/useFilamentLabels';
+
+function makeWrapper() {
+  // Fresh QueryClient per renderHook so cached data from one test doesn't
+  // bleed into the next — the hook keys queries on (hex, materialHint), so a
+  // stale "PLA Matte → Charcoal" cache entry would silently mask a misrouted
+  // request in a later test.
+  const client = new QueryClient({
+    defaultOptions: { queries: { retry: false, gcTime: 0 } },
+  });
+  return function Wrapper({ children }: { children: ReactNode }) {
+    return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
+  };
+}
+
+afterEach(() => {
+  cleanup();
+});
+
+describe('extractMaterialHint', () => {
+  it('strips the leading brand token from multi-word names', () => {
+    expect(extractMaterialHint('Bambu PLA Matte')).toBe('PLA Matte');
+    expect(extractMaterialHint('PolyLite ABS')).toBe('ABS');
+    expect(extractMaterialHint('Bambu PLA-CF')).toBe('PLA-CF');
+  });
+
+  it('returns single-word names unchanged so "PLA" stays "PLA"', () => {
+    // The catalog's ``material`` column has plain "PLA" entries; passing the
+    // single token through means the by-material lookup can still match.
+    expect(extractMaterialHint('PLA')).toBe('PLA');
+    expect(extractMaterialHint('PETG-HF')).toBe('PETG-HF');
+  });
+
+  it('collapses interior whitespace and trims edges', () => {
+    expect(extractMaterialHint('  Bambu   PLA   Matte  ')).toBe('PLA Matte');
+  });
+
+  it('returns "" when the input is blank', () => {
+    // Empty material is the same priority-fallback case as omitting the param.
+    expect(extractMaterialHint('')).toBe('');
+    expect(extractMaterialHint('   ')).toBe('');
+  });
+});
+
+describe('useFilamentLabels', () => {
+  it('returns [] for undefined or empty inputs', () => {
+    const { result, rerender } = renderHook(
+      ({ reqs }: { reqs: undefined | Array<{ type: string; color: string }> }) =>
+        useFilamentLabels(reqs),
+      { wrapper: makeWrapper(), initialProps: { reqs: undefined } },
+    );
+    expect(result.current).toEqual([]);
+
+    rerender({ reqs: [] });
+    expect(result.current).toEqual([]);
+  });
+
+  it('resolves tray_info_idx → sub-brand via the builtin map', async () => {
+    server.use(
+      http.get('/api/v1/cloud/builtin-filaments', () =>
+        HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]),
+      ),
+      http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
+      http.get('/api/v1/inventory/colors/by-material', () =>
+        HttpResponse.json({ color_name: 'Charcoal' }),
+      ),
+    );
+
+    const { result } = renderHook(
+      () => useFilamentLabels([{ type: 'PLA', color: '#000000', tray_info_idx: 'GFA01' }]),
+      { wrapper: makeWrapper() },
+    );
+
+    await waitFor(() => {
+      expect(result.current[0]?.resolvedName).toBe('Bambu PLA Matte');
+      expect(result.current[0]?.colorLabel).toBe('Charcoal');
+    });
+  });
+
+  it('prefers the cloud user-preset name over the builtin entry for the same id', async () => {
+    // Round 2 contract: same id in both maps → cloud wins because the user-
+    // authored name is the more specific label.
+    server.use(
+      http.get('/api/v1/cloud/builtin-filaments', () =>
+        HttpResponse.json([{ filament_id: 'GFA00', name: 'Bambu PLA Basic' }]),
+      ),
+      http.get('/api/v1/cloud/filament-id-map', () =>
+        HttpResponse.json({ GFA00: 'My House PLA' }),
+      ),
+      http.get('/api/v1/inventory/colors/by-material', () =>
+        HttpResponse.json({ color_name: null }),
+      ),
+    );
+
+    const { result } = renderHook(
+      () => useFilamentLabels([{ type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA00' }]),
+      { wrapper: makeWrapper() },
+    );
+
+    await waitFor(() => {
+      expect(result.current[0]?.resolvedName).toBe('My House PLA');
+    });
+  });
+
+  it('falls back to req.type when the SKU is unknown to both maps', async () => {
+    server.use(
+      http.get('/api/v1/cloud/builtin-filaments', () => HttpResponse.json([])),
+      http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
+      http.get('/api/v1/inventory/colors/by-material', () =>
+        HttpResponse.json({ color_name: null }),
+      ),
+    );
+
+    const { result } = renderHook(
+      () => useFilamentLabels([{ type: 'PETG-HF', color: '#00FF00', tray_info_idx: 'GFXXX' }]),
+      { wrapper: makeWrapper() },
+    );
+
+    await waitFor(() => {
+      expect(result.current[0]?.resolvedName).toBe('PETG-HF');
+    });
+  });
+
+  it('falls back colorLabel to getColorName(hex) when the by-material lookup returns null', async () => {
+    server.use(
+      http.get('/api/v1/cloud/builtin-filaments', () =>
+        HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu 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 }),
+      ),
+    );
+
+    const { result } = renderHook(
+      () => useFilamentLabels([{ type: 'PLA', color: '#FF0000', tray_info_idx: 'GFA01' }]),
+      { wrapper: makeWrapper() },
+    );
+
+    await waitFor(() => {
+      // Anything non-empty from getColorName is fine; the critical contract
+      // is "never returns the empty string / null in the colorLabel".
+      expect(result.current[0]?.colorLabel).toBeTruthy();
+      expect(result.current[0]?.colorLabel).not.toBe('null');
+    });
+  });
+
+  it('keeps positional alignment across slots with different (hex, material) tuples', async () => {
+    // Regression guard for the position-indexed output contract: labels[i]
+    // MUST correspond to reqs[i]. If useQueries answered out-of-order or
+    // dedup'd same-hex slots, FilamentMapping would render a PLA Matte
+    // Charcoal slot as PLA Basic Black (and vice versa).
+    server.use(
+      http.get('/api/v1/cloud/builtin-filaments', () =>
+        HttpResponse.json([
+          { filament_id: 'GFA00', name: 'Bambu PLA Basic' },
+          { filament_id: 'GFA01', name: 'Bambu PLA Matte' },
+        ]),
+      ),
+      http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
+      http.get('/api/v1/inventory/colors/by-material', ({ request }) => {
+        const material = new URL(request.url).searchParams.get('material');
+        if (material === 'PLA Matte') return HttpResponse.json({ color_name: 'Charcoal' });
+        if (material === 'PLA Basic') return HttpResponse.json({ color_name: 'Black' });
+        return HttpResponse.json({ color_name: null });
+      }),
+    );
+
+    const { result } = renderHook(
+      () =>
+        useFilamentLabels([
+          { type: 'PLA', color: '#000000', tray_info_idx: 'GFA01' }, // PLA Matte
+          { type: 'PLA', color: '#000000', tray_info_idx: 'GFA00' }, // PLA Basic
+        ]),
+      { wrapper: makeWrapper() },
+    );
+
+    await waitFor(() => {
+      expect(result.current[0]?.resolvedName).toBe('Bambu PLA Matte');
+      expect(result.current[0]?.colorLabel).toBe('Charcoal');
+      expect(result.current[1]?.resolvedName).toBe('Bambu PLA Basic');
+      expect(result.current[1]?.colorLabel).toBe('Black');
+    });
+  });
+
+  it('skips the by-material query when the slot has no hex (enabled: !!color)', async () => {
+    // Defensive: 3MFs occasionally leave the color attribute blank. The
+    // query is gated on truthy color so we don't fire a request that we
+    // know can't disambiguate anything.
+    let byMaterialCalls = 0;
+    server.use(
+      http.get('/api/v1/cloud/builtin-filaments', () =>
+        HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]),
+      ),
+      http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
+      http.get('/api/v1/inventory/colors/by-material', () => {
+        byMaterialCalls += 1;
+        return HttpResponse.json({ color_name: null });
+      }),
+    );
+
+    const { result } = renderHook(
+      () => useFilamentLabels([{ type: 'PLA', color: '', tray_info_idx: 'GFA01' }]),
+      { wrapper: makeWrapper() },
+    );
+
+    await waitFor(() => {
+      // The sub-brand half resolves from the builtin map even without a hex.
+      expect(result.current[0]?.resolvedName).toBe('Bambu PLA Matte');
+    });
+    expect(byMaterialCalls).toBe(0);
+  });
+});

+ 43 - 0
frontend/src/__tests__/pages/StatsPage.test.tsx

@@ -293,6 +293,49 @@ describe('StatsPage', () => {
       });
     });
 
+    it('translates camelCase failure-reason keys instead of rendering them raw (#1687 follow-up)', async () => {
+      // The widget groups by the raw PrintLogEntry.failure_reason column.
+      // The new editor stores camelCase keys (`filamentRunout`), so the widget
+      // must translate them — otherwise users see the literal key text.
+      server.use(
+        http.get('/api/v1/archives/analysis/failures', () => {
+          return HttpResponse.json({
+            ...mockFailureAnalysis,
+            failures_by_reason: { filamentRunout: 2, cloggedNozzle: 1 },
+          });
+        }),
+      );
+
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Filament runout')).toBeInTheDocument();
+        expect(screen.getByText('Clogged nozzle')).toBeInTheDocument();
+      });
+      expect(screen.queryByText('filamentRunout')).not.toBeInTheDocument();
+      expect(screen.queryByText('cloggedNozzle')).not.toBeInTheDocument();
+    });
+
+    it('renders legacy translated-text failure reasons unchanged (#1687 follow-up)', async () => {
+      // Old rows from before the key/value migration stored the translated
+      // text. The defaultValue fallback in the t() call must surface them
+      // as-is rather than turning them into the literal key string.
+      server.use(
+        http.get('/api/v1/archives/analysis/failures', () => {
+          return HttpResponse.json({
+            ...mockFailureAnalysis,
+            failures_by_reason: { 'Custom legacy reason': 4 },
+          });
+        }),
+      );
+
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Custom legacy reason')).toBeInTheDocument();
+      });
+    });
+
     it('shows printer stats widget', async () => {
       render(<StatsPage />);
 

+ 24 - 0
frontend/src/__tests__/utils/printer.test.ts

@@ -32,6 +32,30 @@ describe('getPrinterImage', () => {
     });
   });
 
+  describe('A2L (#1684)', () => {
+    it('resolves display name "A2L" to a2l.png', () => {
+      expect(getPrinterImage('A2L')).toBe('/img/printers/a2l.png');
+    });
+
+    it('resolves case-insensitive variants', () => {
+      expect(getPrinterImage('a2l')).toBe('/img/printers/a2l.png');
+      expect(getPrinterImage(' A2L ')).toBe('/img/printers/a2l.png');
+    });
+
+    it('resolves the internal SSDP code "N9" to a2l.png', () => {
+      expect(getPrinterImage('N9')).toBe('/img/printers/a2l.png');
+    });
+
+    it('does not match A2L on unrelated A-series strings', () => {
+      // Regression guard: a hypothetical future "A2M" or similar must not
+      // silently pick up a2l.png until it's explicitly mapped, and "A1" /
+      // "A1 Mini" must still resolve to their own artwork.
+      expect(getPrinterImage('A2M')).toBe('/img/printers/default.png');
+      expect(getPrinterImage('A1')).toBe('/img/printers/a1.png');
+      expect(getPrinterImage('A1 Mini')).toBe('/img/printers/a1mini.png');
+    });
+  });
+
   describe('regression: existing families unchanged', () => {
     it('X1C → x1c.png', () => {
       expect(getPrinterImage('X1C')).toBe('/img/printers/x1c.png');

+ 16 - 141
frontend/src/__tests__/utils/slicerPrinterMatch.test.ts

@@ -4,7 +4,6 @@ import {
   matchesPrinterModelSuffix,
   presetCompatibility,
   EMPTY_COMPATIBILITY_INDEX,
-  type CompatibilityBundle,
 } from '../../utils/slicerPrinterMatch';
 
 const X1C = 'Bambu Lab X1 Carbon 0.4 nozzle';
@@ -31,60 +30,9 @@ const PRINTER_MODELS: Record<string, string> = {
   'Bambu Lab X2D': 'X2D',
 };
 
-// Two uploaded bundles, one per printer — the ground truth all matching
-// is derived from. Note P2S: a model the old hard-coded list never knew
-// about, now covered purely because its bundle was uploaded (#1325).
-const BUNDLES: CompatibilityBundle[] = [
-  {
-    printer_preset_name: X1C,
-    process: ['0.20mm Standard @BBL X1C', '0.20mm Strength @BBL X1C'],
-    filament: ['Bambu PLA Basic @BBL X1C'],
-  },
-  {
-    printer_preset_name: P2S,
-    process: ['0.20mm Standard @BBL P2S', '0.16mm Standard @BBL P2S'],
-    filament: ['Bambu PLA Basic @BBL P2S'],
-  },
-];
-
 describe('buildCompatibilityIndex', () => {
-  it('maps each preset name to the printers whose bundles ship it', () => {
-    const index = buildCompatibilityIndex(BUNDLES, PRINTER_MODELS);
-    expect([...(index.process.get('0.20mm Standard @BBL X1C') ?? [])]).toEqual([X1C]);
-    expect([...(index.process.get('0.16mm Standard @BBL P2S') ?? [])]).toEqual([P2S]);
-    expect([...(index.filament.get('Bambu PLA Basic @BBL P2S') ?? [])]).toEqual([P2S]);
-  });
-
-  it('unions printers when several bundles ship the same preset name', () => {
-    const shared = '0.20mm Standard';
-    const index = buildCompatibilityIndex(
-      [
-        { printer_preset_name: X1C, process: [shared], filament: [] },
-        { printer_preset_name: P2S, process: [shared], filament: [] },
-      ],
-      PRINTER_MODELS,
-    );
-    expect(index.process.get(shared)).toEqual(new Set([X1C, P2S]));
-  });
-
-  it("strips BambuStudio's '# ' user-clone prefix so names compare equal", () => {
-    const index = buildCompatibilityIndex(
-      [{ printer_preset_name: X1C, process: ['# 0.20mm Custom'], filament: [] }],
-      PRINTER_MODELS,
-    );
-    expect(index.process.has('0.20mm Custom')).toBe(true);
-  });
-
-  it('skips bundles with no printer name', () => {
-    const index = buildCompatibilityIndex(
-      [{ printer_preset_name: '', process: ['Orphan Process'], filament: [] }],
-      PRINTER_MODELS,
-    );
-    expect(index.process.size).toBe(0);
-  });
-
   it('inverts the printer-model registry into short-code → display fragment', () => {
-    const index = buildCompatibilityIndex([], PRINTER_MODELS);
+    const index = buildCompatibilityIndex(PRINTER_MODELS);
     expect(index.bambuModelByShortCode.X1C).toBe('X1 Carbon');
     expect(index.bambuModelByShortCode.P2S).toBe('P2S');
     expect(index.bambuModelByShortCode['A1 Mini']).toBe('A1 Mini');
@@ -92,18 +40,13 @@ describe('buildCompatibilityIndex', () => {
   });
 
   it('tolerates an empty printer-model registry (model fetch hasn\'t resolved yet)', () => {
-    const index = buildCompatibilityIndex(BUNDLES);
+    const index = buildCompatibilityIndex();
     expect(index.bambuModelByShortCode).toEqual({});
-    // Bundle matching still works on its own.
-    expect([...(index.process.get('0.20mm Standard @BBL X1C') ?? [])]).toEqual([X1C]);
   });
 });
 
 describe('presetCompatibility', () => {
-  const index = buildCompatibilityIndex(BUNDLES, PRINTER_MODELS);
-  // Bundle-free index used by the #1325 follow-up fallback tests: any match
-  // here must come from the @BBL name parse alone.
-  const namesOnlyIndex = buildCompatibilityIndex([], PRINTER_MODELS);
+  const index = buildCompatibilityIndex(PRINTER_MODELS);
 
   it('uses compatible_printers exactly when present (imported / local tier)', () => {
     const preset = { name: 'My Process', compatible_printers: [X1C] };
@@ -117,65 +60,12 @@ describe('presetCompatibility', () => {
     ).toBe('unknown');
   });
 
-  it('matches a preset shipped by the selected printer\'s bundle', () => {
-    expect(presetCompatibility({ name: '0.20mm Standard @BBL X1C' }, 'process', X1C, index)).toBe(
-      'match',
-    );
-    expect(
-      presetCompatibility({ name: 'Bambu PLA Basic @BBL P2S' }, 'filament', P2S, index),
-    ).toBe('match');
-  });
-
-  it('flags a preset whose bundle is for a different printer (the #1325 bug)', () => {
-    // X1C selected, but this process only ships in the P2S bundle.
-    expect(presetCompatibility({ name: '0.16mm Standard @BBL P2S' }, 'process', X1C, index)).toBe(
-      'mismatch',
-    );
-  });
-
-  it('falls back to @BBL name parsing when no bundle covers the preset (#1325 follow-up)', () => {
-    // No A1 bundle uploaded, but the preset's @BBL A1 tag is enough to
-    // resolve it: A1 ≠ X1C so it belongs in "Other printers".
-    expect(
-      presetCompatibility({ name: '0.20mm Standard @BBL A1' }, 'process', X1C, index),
-    ).toBe('mismatch');
-  });
-
-  it('falls back to @BBL name parsing when no bundles are imported at all', () => {
-    // Brand-new user, zero bundles, every preset would have been "unknown"
-    // under the bundle-only design — now resolves via the name suffix.
-    expect(
-      presetCompatibility(
-        { name: '0.20mm Standard @BBL X1C' },
-        'process',
-        X1C,
-        namesOnlyIndex,
-      ),
-    ).toBe('match');
-    expect(
-      presetCompatibility(
-        { name: '0.20mm Standard @BBL P2S' },
-        'process',
-        X1C,
-        namesOnlyIndex,
-      ),
-    ).toBe('mismatch');
-  });
-
   it('is unknown when no printer is selected', () => {
     expect(
       presetCompatibility({ name: '0.20mm Standard @BBL X1C' }, 'process', null, index),
     ).toBe('unknown');
   });
 
-  it("matches across the '# ' user-clone prefix", () => {
-    const index2 = buildCompatibilityIndex(
-      [{ printer_preset_name: X1C, process: ['# 0.20mm Custom'], filament: [] }],
-      PRINTER_MODELS,
-    );
-    expect(presetCompatibility({ name: '0.20mm Custom' }, 'process', X1C, index2)).toBe('match');
-  });
-
   it('compatible_printers wins over @BBL even when the name suggests a different printer', () => {
     // Authoritative slicer declaration: this @BBL P2S preset has been
     // manually reassigned to X1C. The compatible_printers list must win.
@@ -184,7 +74,7 @@ describe('presetCompatibility', () => {
         { name: '0.20mm Standard @BBL P2S', compatible_printers: [X1C] },
         'process',
         X1C,
-        namesOnlyIndex,
+        index,
       ),
     ).toBe('match');
     expect(
@@ -192,30 +82,16 @@ describe('presetCompatibility', () => {
         { name: '0.20mm Standard @BBL P2S', compatible_printers: [X1C] },
         'process',
         P2S,
-        namesOnlyIndex,
+        index,
       ),
     ).toBe('mismatch');
   });
-
-  it('bundle index wins over @BBL when they disagree', () => {
-    // Hypothetical bundle that ships a P2S-tagged preset as compatible
-    // with the X1C printer too — bundle-as-ground-truth overrules the
-    // name-suffix inference.
-    const reassigned = buildCompatibilityIndex(
-      [{ printer_preset_name: X1C, process: ['0.20mm Standard @BBL P2S'], filament: [] }],
-      PRINTER_MODELS,
-    );
-    expect(
-      presetCompatibility({ name: '0.20mm Standard @BBL P2S' }, 'process', X1C, reassigned),
-    ).toBe('match');
-  });
 });
 
 // ─── #1325 follow-up: @BBL name fallback ──────────────────────────────────
 
-describe('presetCompatibility — @BBL name fallback (no bundles)', () => {
-  // No bundles, but with the registry loaded — exactly the new-user shape.
-  const idx = buildCompatibilityIndex([], PRINTER_MODELS);
+describe('presetCompatibility — @BBL name fallback', () => {
+  const idx = buildCompatibilityIndex(PRINTER_MODELS);
 
   // Bambu's short codes vs the long forms in printer-preset names: the
   // entire reason the fallback needs a registry to consult.
@@ -235,8 +111,8 @@ describe('presetCompatibility — @BBL name fallback (no bundles)', () => {
     ['0.20mm Standard @BBL H2D', 'Bambu Lab H2D 0.4 nozzle', 'match'],
     ['0.20mm Standard @BBL H2D', 'Bambu Lab H2D Pro 0.4 nozzle', 'mismatch'],
     ['0.20mm Standard @BBL H2D Pro', 'Bambu Lab H2D Pro 0.4 nozzle', 'match'],
-    // Models missing from the original hardcoded list (the #1325 bug),
-    // now resolved via the backend registry.
+    // Models the original hardcoded list missed, now resolved via the
+    // backend registry.
     ['Bambu PLA Basic @BBL P2S', P2S, 'match'],
     ['Bambu PLA Basic @BBL P2S', X1C, 'mismatch'],
     ['0.20mm Standard @BBL X2D', 'Bambu Lab X2D 0.4 nozzle', 'match'],
@@ -306,11 +182,11 @@ describe('presetCompatibility — @BBL name fallback (no bundles)', () => {
   });
 
   it('still resolves @BBL when the registry has not loaded yet (raw-token only)', () => {
-    // EMPTY_COMPATIBILITY_INDEX = no bundles, no models — first paint of
-    // the SliceModal before the /slicer/printer-models fetch resolves.
-    // Short codes that match their printer-name fragment directly (P2S,
-    // H2D, etc.) still work; codes that differ in form (X1C vs "X1
-    // Carbon") gracefully fall through to 'unknown'.
+    // EMPTY_COMPATIBILITY_INDEX = no models — first paint of the SliceModal
+    // before the /slicer/printer-models fetch resolves. Short codes that
+    // match their printer-name fragment directly (P2S, H2D, etc.) still
+    // work; codes that differ in form (X1C vs "X1 Carbon") gracefully
+    // fall through to 'mismatch'.
     expect(
       presetCompatibility(
         { name: '0.20mm Standard @BBL P2S' },
@@ -341,8 +217,7 @@ describe('presetCompatibility — nozzle filtering on @BBL name fallback', () =>
   const X1C_04 = 'Bambu Lab X1 Carbon 0.4 nozzle';
   const X1C_06 = 'Bambu Lab X1 Carbon 0.6 nozzle';
   const X1C_08 = 'Bambu Lab X1 Carbon 0.8 nozzle';
-  // No bundles uploaded — exercise the @BBL fallback in isolation.
-  const index = buildCompatibilityIndex([], PRINTER_MODELS);
+  const index = buildCompatibilityIndex(PRINTER_MODELS);
 
   it('treats a no-suffix process as 0.4 (Bambu default) and matches a 0.4 printer', () => {
     expect(
@@ -461,7 +336,7 @@ describe('matchesPrinterModelSuffix (#1649)', () => {
 describe('presetCompatibility with Bambu cloud A1M rename (#1649)', () => {
   const A1_MINI = 'Bambu Lab A1 mini 0.4 nozzle';
   const A1 = 'Bambu Lab A1 0.4 nozzle';
-  const idx = buildCompatibilityIndex([], PRINTER_MODELS);
+  const idx = buildCompatibilityIndex(PRINTER_MODELS);
 
   it('matches a cloud preset using the new @BBL A1M suffix against an A1 Mini printer', () => {
     // The slicer-mirrored case: technopaw's report — A1 Mini cloud presets

+ 27 - 89
frontend/src/api/client.ts

@@ -1390,13 +1390,6 @@ export interface PresetRef {
   source: PresetSource;
   id: string;
 }
-export interface SliceBundleSpec {
-  bundle_id: string;
-  printer_name: string;
-  process_name: string;
-  // Per-slot filament names in plate order. Index 0 = slot 1, etc.
-  filament_names: string[];
-}
 export interface SliceRequest {
   printer_preset_id?: number;
   process_preset_id?: number;
@@ -1409,11 +1402,6 @@ export interface SliceRequest {
   // backend validator promotes a singular into a one-element list when this
   // is omitted, so legacy single-color clients keep working unchanged.
   filament_presets?: PresetRef[];
-  // Bundle dispatch: when set, the backend skips PresetRef resolution and
-  // picks the JSON triplet from a sidecar-stored .bbscfg by name. Mutually
-  // exclusive with the preset fields above (validator accepts both, but
-  // dispatch ignores the preset side when bundle is set).
-  bundle?: SliceBundleSpec;
   plate?: number;
   export_3mf?: boolean;
   // Build-plate override (#1337). When omitted, the slicer uses the process
@@ -1424,20 +1412,6 @@ export interface SliceRequest {
   bed_type?: string | null;
 }
 
-// GET /api/v1/slicer/bundles — Printer Preset Bundles imported from
-// BambuStudio's "File → Export → Export Preset Bundle" dialog. Each bundle
-// is a .bbscfg zip the user uploads once per printer, after which the
-// SliceModal can pick its inner presets by name (no re-upload per slice).
-// Backend: backend/app/api/routes/slicer_presets.py — bundle endpoints.
-export interface SlicerBundle {
-  id: string;
-  printer_preset_name: string;
-  printer: string[];
-  process: string[];
-  filament: string[];
-  version: string | null;
-}
-
 // GET /api/v1/slicer/presets — unified listing across cloud / local / standard.
 export type SlicerCloudStatus = 'ok' | 'not_authenticated' | 'expired' | 'unreachable';
 export interface UnifiedPreset {
@@ -1455,7 +1429,7 @@ export interface UnifiedPreset {
   // compatible with. Populated for the local tier (the slicer's own
   // `compatible_printers`); null for cloud / standard. The SliceModal filters
   // the process / filament dropdowns by the selected printer using this when
-  // present, and otherwise by the user's uploaded Slicer Bundles (#1325).
+  // present (#1325).
   compatible_printers?: string[] | null;
 }
 export interface UnifiedPresetsBySlot {
@@ -1464,8 +1438,9 @@ export interface UnifiedPresetsBySlot {
   filament: UnifiedPreset[];
 }
 export interface UnifiedPresetsResponse {
-  // Priority order: orca_cloud > cloud > local > standard. Dedup is applied
-  // backend-side so each name appears in only one tier.
+  // Priority order: local > orca_cloud > cloud > standard. No cross-tier
+  // dedup — every tier surfaces its full list so the user can pick from
+  // any source. The order drives auto-pick + visual group rendering only.
   orca_cloud: UnifiedPresetsBySlot;
   cloud: UnifiedPresetsBySlot;
   local: UnifiedPresetsBySlot;
@@ -3754,6 +3729,15 @@ export const api = {
   },
   getArchive: (id: number) => request<Archive>(`/archives/${id}`),
   getArchiveRuns: (id: number) => request<PrintLogResponse>(`/archives/${id}/runs`),
+  /**
+   * Pre-flight for the delete-confirm modal (#1734). Returns the number of
+   * related queue items that will be removed along with the archive AND how
+   * many are currently printing (server 409s on delete if > 0).
+   */
+  getArchiveDeleteImpact: (id: number) =>
+    request<{ related_queue_items: number; currently_printing: number }>(
+      `/archives/${id}/delete-impact`
+    ),
   searchArchives: (query: string, options?: {
     printerId?: number;
     projectId?: number;
@@ -4253,27 +4237,10 @@ export const api = {
     archiveId: number,
     plateId?: number,
     requestId?: string,
-    // Optional bundle context: when supplied, the backend's preview slice
-    // (run for unsliced project files) uses slice_with_bundle so gram
-    // numbers reflect the same triplet the real print will use. All four
-    // fields must be set for the bundle path to engage; partial context
-    // falls back to the embedded-settings preview without erroring.
-    bundle?: {
-      bundle_id: string;
-      printer_name: string;
-      process_name: string;
-      filament_names: string[];
-    },
   ) => {
     const qs = new URLSearchParams();
     if (plateId !== undefined) qs.set('plate_id', String(plateId));
     if (requestId) qs.set('request_id', requestId);
-    if (bundle) {
-      qs.set('bundle_id', bundle.bundle_id);
-      qs.set('printer_name', bundle.printer_name);
-      qs.set('process_name', bundle.process_name);
-      qs.set('filament_names', bundle.filament_names.join(';'));
-    }
     return request<{
       archive_id: number;
       filename: string;
@@ -4528,6 +4495,20 @@ export const api = {
   getFilamentIdMap: () =>
     request<Record<string, string>>('/cloud/filament-id-map'),
 
+  /** Material-disambiguated hex→name lookup. Same hex can map to different
+   *  catalog names depending on material (e.g. #000000 is "Charcoal" in PLA
+   *  Matte but "Black" in PLA Basic). The flat ``/inventory/colors/map``
+   *  collapses these to the first hit; this endpoint preserves the material
+   *  context. Returns ``{color_name: null}`` when the hex isn't in the
+   *  catalog at all. #1718. */
+  getColorByMaterial: (hex: string, material?: string) => {
+    const params = new URLSearchParams({ hex });
+    if (material) params.set('material', material);
+    return request<{ color_name: string | null }>(
+      `/inventory/colors/by-material?${params.toString()}`,
+    );
+  },
+
   // MakerWorld URL-paste import flow.
   getMakerworldStatus: () =>
     request<MakerworldStatus>('/makerworld/status'),
@@ -5871,24 +5852,10 @@ export const api = {
     fileId: number,
     plateId?: number,
     requestId?: string,
-    // Optional bundle context — see getArchiveFilamentRequirements above
-    // for the contract. Same shape so callers can share a builder helper.
-    bundle?: {
-      bundle_id: string;
-      printer_name: string;
-      process_name: string;
-      filament_names: string[];
-    },
   ) => {
     const qs = new URLSearchParams();
     if (plateId !== undefined) qs.set('plate_id', String(plateId));
     if (requestId) qs.set('request_id', requestId);
-    if (bundle) {
-      qs.set('bundle_id', bundle.bundle_id);
-      qs.set('printer_name', bundle.printer_name);
-      qs.set('process_name', bundle.process_name);
-      qs.set('filament_names', bundle.filament_names.join(';'));
-    }
     return request<{
       file_id: number;
       filename: string;
@@ -6025,35 +5992,6 @@ export const api = {
   getSlicerPrinterModels: () =>
     request<Record<string, string>>('/slicer/printer-models'),
 
-  // Slicer Bundles (.bbscfg) — Printer Preset Bundles imported from BambuStudio.
-  // Settings → Slicer Bundles uploads/lists/deletes; the SliceModal picks
-  // presets by name from a chosen bundle (separate follow-up).
-  listSlicerBundles: () =>
-    request<SlicerBundle[]>('/slicer/bundles'),
-  importSlicerBundle: (file: File) => {
-    // The /slicer/bundles upload accepts multipart with field name "file"
-    // (matches the FastAPI route's UploadFile parameter). Bypass `request`
-    // because it always JSON-stringifies the body — multipart needs the
-    // browser to set the boundary in the Content-Type header.
-    const fd = new FormData();
-    fd.append('file', file);
-    return fetch(`${API_BASE}/slicer/bundles`, {
-      method: 'POST',
-      headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {},
-      body: fd,
-    }).then(async (res) => {
-      if (!res.ok) {
-        const err = await res.json().catch(() => ({}));
-        throw new Error(err.detail || `HTTP ${res.status}`);
-      }
-      return res.json() as Promise<SlicerBundle>;
-    });
-  },
-  deleteSlicerBundle: (bundleId: string) =>
-    request<void>(`/slicer/bundles/${encodeURIComponent(bundleId)}`, {
-      method: 'DELETE',
-    }),
-
   // Local Presets (OrcaSlicer imports)
   getLocalPresets: () =>
     request<LocalPresetsResponse>('/local-presets/'),

+ 162 - 24
frontend/src/components/ConfigureAmsSlotModal.tsx

@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
 import { X, Loader2, Settings2, ChevronDown, CheckCircle2, RotateCcw } from 'lucide-react';
 import { api } from '../api/client';
 import type { KProfile } from '../api/client';
-import { matchesPrinterModelSuffix } from '../utils/slicerPrinterMatch';
+import { matchesPrinterModelSuffix, presetCompatibility, buildCompatibilityIndex } from '../utils/slicerPrinterMatch';
 import { toFilamentId, isGenericFilamentId } from './spool-form/utils';
 import { Button } from './Button';
 
@@ -230,13 +230,80 @@ function colorNameToHex(name: string): string | null {
   return COLOR_NAME_MAP[normalized] || null;
 }
 
-// Extract printer model from preset name suffix "@BBL X1C 0.4 nozzle" → "X1C"
-function extractPresetModel(name: string): string | null {
+// Escape regex metacharacters and turn whitespace into ``\s+`` so a literal
+// model token compiles to a flexible-whitespace word-boundary regex.
+function _tokenToRegex(token: string): RegExp {
+  const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+');
+  return new RegExp(`\\b${escaped}\\b`, 'i');
+}
+
+// Extract printer model from a preset name → normalized short code
+// (e.g. "X1C", "H2D"). Two strategies in order:
+//
+// (1) ``@`` suffix — the BambuStudio naming convention. Two shapes:
+//   - "@BBL X1C 0.4 nozzle"               → "X1C"  (short-code form,
+//      Bambu Cloud system presets)
+//   - "@Bambu Lab X1 Carbon 0.4 nozzle"   → "X1C"  (long-form, used by
+//      user-renamed Bambu Cloud presets and most Orca Cloud profiles —
+//      reverse-looked-up via the backend printer-model registry)
+//
+// (2) Body scan — many user-authored / Orca Cloud presets put the printer
+// model at the START of the name with no @ suffix at all (the literal
+// shape that surfaced #1623: "X1C eSUN PETG-Basic Filament"). Scan the
+// name for any known model token (every long-name fragment + every short
+// code from the registry) and return the first match. Long-first sort
+// keeps "A1 Mini" / "X1 Carbon" / "H2D Pro" from being eaten by their
+// shorter sibling ("A1" / "X1" / "H2D"). Word-boundary regex prevents
+// false-positives on partial substrings (e.g. "PA1" doesn't match "A1",
+// "X1Box" doesn't match "X1").
+//
+// Returns null when neither strategy resolves; the caller keeps such
+// presets visible (can't filter what we can't classify).
+//
+// ``printerModelsLongToShort`` is the backend's PRINTER_MODEL_MAP shape:
+// keys are "Bambu Lab <long>", values are short codes.
+function extractPresetModel(
+  name: string,
+  printerModelsLongToShort: Record<string, string>,
+): string | null {
   const atIdx = name.indexOf('@');
-  if (atIdx < 0) return null;
-  const suffix = name.slice(atIdx + 1).trim();
-  const bblMatch = suffix.match(/^BBL\s+(.+?)(?:\s+[\d.]+\s*nozzle)?$/i);
-  if (bblMatch) return bblMatch[1].trim();
+  if (atIdx >= 0) {
+    const suffix = name.slice(atIdx + 1).trim();
+    const bblMatch = suffix.match(/^BBL\s+(.+?)(?:\s+[\d.]+\s*nozzle)?$/i);
+    if (bblMatch) return bblMatch[1].trim();
+    const longMatch = suffix.match(/^Bambu Lab\s+(.+?)(?:\s+[\d.]+\s*nozzle)?$/i);
+    if (longMatch) {
+      const longFragment = longMatch[1].trim();
+      const fullKey = `Bambu Lab ${longFragment}`;
+      if (printerModelsLongToShort[fullKey]) return printerModelsLongToShort[fullKey];
+      const lower = fullKey.toLowerCase();
+      for (const [k, v] of Object.entries(printerModelsLongToShort)) {
+        if (k.toLowerCase() === lower) return v;
+      }
+      return longFragment;
+    }
+  }
+
+  // Body scan — accumulate {token, short} pairs and try long-first.
+  const tokens: Array<{ token: string; short: string }> = [];
+  const seen = new Set<string>();
+  for (const [longName, short] of Object.entries(printerModelsLongToShort)) {
+    const fragment = longName.replace(/^Bambu Lab\s+/, '');
+    const key = fragment.toLowerCase();
+    if (!seen.has(key)) {
+      tokens.push({ token: fragment, short });
+      seen.add(key);
+    }
+    const shortKey = short.toLowerCase();
+    if (!seen.has(shortKey)) {
+      tokens.push({ token: short, short });
+      seen.add(shortKey);
+    }
+  }
+  tokens.sort((a, b) => b.token.length - a.token.length);
+  for (const { token, short } of tokens) {
+    if (_tokenToRegex(token).test(name)) return short;
+  }
   return null;
 }
 
@@ -309,6 +376,39 @@ export function ConfigureAmsSlotModal({
     staleTime: Infinity,
   });
 
+  // Backend Bambu printer-model registry — drives the @BBL short-code matcher
+  // and (here) the reverse short-code → long-name lookup that lets us check
+  // imported local presets' `compatible_printers` list against the slot's
+  // printer (#1623). Long staleTime: the registry only changes across backend
+  // releases.
+  const { data: printerModelsData } = useQuery({
+    queryKey: ['slicerPrinterModels'],
+    queryFn: api.getSlicerPrinterModels,
+    enabled: isOpen,
+    staleTime: Infinity,
+  });
+
+  const compatIndex = useMemo(
+    () => buildCompatibilityIndex(printerModelsData ?? {}),
+    [printerModelsData],
+  );
+
+  // The full printer-preset name for this slot's printer — e.g. the short
+  // code "X1C" resolves to "Bambu Lab X1 Carbon 0.4 nozzle" via the backend
+  // registry plus the slot's nozzle. Used to filter imported local presets
+  // whose `compatible_printers` list is keyed by the full slicer preset name.
+  // Null when the registry hasn't loaded or no model is known — caller skips
+  // the filter in that case (fail-open, same shape as the cloud-preset filter).
+  const fullPrinterName = useMemo<string | null>(() => {
+    if (!printerModel || !printerModelsData) return null;
+    for (const [longName, shortCode] of Object.entries(printerModelsData)) {
+      if (shortCode === printerModel) {
+        return `${longName.startsWith('Bambu Lab ') ? longName : `Bambu Lab ${longName}`} ${nozzleDiameter} nozzle`;
+      }
+    }
+    return null;
+  }, [printerModel, printerModelsData, nozzleDiameter]);
+
   // Configure slot mutation
   const configureMutation = useMutation({
     mutationFn: async () => {
@@ -584,7 +684,7 @@ export function ConfigureAmsSlotModal({
         coveredIds.add(orcaId);
         if (query && !op.name.toLowerCase().includes(query)) continue;
         if (printerModel) {
-          const presetModel = extractPresetModel(op.name);
+          const presetModel = extractPresetModel(op.name, printerModelsData ?? {});
           if (presetModel && !matchesPrinterModelSuffix(presetModel, printerModel)) continue;
         }
         // All Orca Cloud profiles are user-authored, so isUser is always true.
@@ -607,18 +707,45 @@ export function ConfigureAmsSlotModal({
         // alias-aware match so Bambu's "A1 Mini" → "A1M" cloud rename (#1649)
         // doesn't hide A1 Mini cloud profiles.
         if (!isCurrentPreset && printerModel) {
-          const presetModel = extractPresetModel(cp.name);
+          const presetModel = extractPresetModel(cp.name, printerModelsData ?? {});
           if (presetModel && !matchesPrinterModelSuffix(presetModel, printerModel)) continue;
         }
         items.push({ id: cp.setting_id, name: cp.name, source: 'cloud', isUser: isUserPreset(cp.setting_id) });
       }
     }
 
-    // 2. Local presets (always shown — user-imported profiles work on any printer)
+    // 2. Local presets — filter by the slicer's own ``compatible_printers``
+    // list when present (#1623). LocalPreset.compatible_printers is a JSON-
+    // encoded string array; presetCompatibility returns 'mismatch' when the
+    // list is set and our derived full printer name isn't in it, 'unknown'
+    // when neither the list nor an @BBL token is parseable — we hide on
+    // 'mismatch' only so user-imported presets without compatible_printers
+    // still surface (back-compat with the prior "always show" behaviour for
+    // hand-edited / lossily-imported presets).
     if (localPresets?.filament) {
+      const savedLocalId = slotInfo.savedPresetId;
       for (const lp of localPresets.filament) {
         const localId = `local_${lp.id}`;
         if (query && !lp.name.toLowerCase().includes(query)) continue;
+        const isCurrentPreset = savedLocalId === localId;
+        if (!isCurrentPreset && fullPrinterName) {
+          let compatList: string[] | null = null;
+          if (lp.compatible_printers) {
+            try {
+              const parsed = JSON.parse(lp.compatible_printers);
+              if (Array.isArray(parsed)) compatList = parsed.filter((s): s is string => typeof s === 'string');
+            } catch {
+              compatList = null;
+            }
+          }
+          const verdict = presetCompatibility(
+            { name: lp.name, compatible_printers: compatList },
+            'filament',
+            fullPrinterName,
+            compatIndex,
+          );
+          if (verdict === 'mismatch') continue;
+        }
         items.push({ id: localId, name: lp.name, source: 'local', isUser: false });
       }
     }
@@ -639,16 +766,17 @@ export function ConfigureAmsSlotModal({
       }
     }
 
-    // Sort: orca_cloud first (user-curated), then cloud user presets, then
-    // cloud built-in, then local, then builtin fallback
+    // Sort: local first (user explicitly imported them), then orca_cloud,
+    // then bambu cloud, then builtin fallback. Matches the SliceModal
+    // tier priority.
     return items.sort((a, b) => {
-      const sourceOrder = { orca_cloud: 0, cloud: 1, local: 2, builtin: 3 };
+      const sourceOrder = { local: 0, orca_cloud: 1, cloud: 2, builtin: 3 };
       if (a.source !== b.source) return sourceOrder[a.source] - sourceOrder[b.source];
       if (a.isUser && !b.isUser) return -1;
       if (!a.isUser && b.isUser) return 1;
       return a.name.localeCompare(b.name);
     });
-  }, [orcaCloudList?.filament, cloudSettings?.filament, localPresets?.filament, builtinFilaments, searchQuery, printerModel, slotInfo.savedPresetId, slotInfo.trayInfoIdx]);
+  }, [orcaCloudList?.filament, cloudSettings?.filament, localPresets?.filament, builtinFilaments, searchQuery, printerModel, slotInfo.savedPresetId, slotInfo.trayInfoIdx, fullPrinterName, compatIndex, printerModelsData]);
 
   // Get full preset name for K profile filtering (brand + material, without printer suffix)
   const selectedPresetInfo = useMemo(() => {
@@ -1099,14 +1227,19 @@ export function ConfigureAmsSlotModal({
                                 {t('profiles.localProfiles.badge')}
                               </span>
                             )}
-                            {preset.source === 'builtin' && (
-                              <span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400">
-                                {t('configureAmsSlot.builtin')}
+                            {preset.source === 'orca_cloud' && (
+                              <span className="text-xs px-1.5 py-0.5 rounded bg-purple-500/20 text-purple-400">
+                                {t('configureAmsSlot.orcaCloud')}
                               </span>
                             )}
-                            {preset.isUser && (
+                            {preset.source === 'cloud' && (
                               <span className="text-xs px-1.5 py-0.5 rounded bg-bambu-blue/20 text-bambu-blue">
-                                {t('configureAmsSlot.custom')}
+                                {t('configureAmsSlot.bambuCloud')}
+                              </span>
+                            )}
+                            {preset.source === 'builtin' && (
+                              <span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400">
+                                {t('configureAmsSlot.builtin')}
                               </span>
                             )}
                           </div>
@@ -1334,14 +1467,19 @@ export function ConfigureAmsSlotModal({
                                   {t('profiles.localProfiles.badge')}
                                 </span>
                               )}
-                              {preset.source === 'builtin' && (
-                                <span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400">
-                                  {t('configureAmsSlot.builtin')}
+                              {preset.source === 'orca_cloud' && (
+                                <span className="text-xs px-1.5 py-0.5 rounded bg-purple-500/20 text-purple-400">
+                                  {t('configureAmsSlot.orcaCloud')}
                                 </span>
                               )}
-                              {preset.isUser && (
+                              {preset.source === 'cloud' && (
                                 <span className="text-xs px-1.5 py-0.5 rounded bg-bambu-blue/20 text-bambu-blue">
-                                  {t('configureAmsSlot.custom')}
+                                  {t('configureAmsSlot.bambuCloud')}
+                                </span>
+                              )}
+                              {preset.source === 'builtin' && (
+                                <span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400">
+                                  {t('configureAmsSlot.builtin')}
                                 </span>
                               )}
                             </div>

+ 7 - 1
frontend/src/components/ConfirmModal.tsx

@@ -19,6 +19,11 @@ interface ConfirmModalProps {
   variant?: 'danger' | 'warning' | 'default';
   isLoading?: boolean;
   loadingText?: string;
+  // Disable the confirm button without a loading spinner. Used when an
+  // external precondition forbids the action (e.g. #1734 — a related queue
+  // item is mid-print, so the archive delete must be blocked at the UI
+  // layer too even though the backend will 409 anyway).
+  confirmDisabled?: boolean;
   // Optional extra content rendered between the message and the buttons —
   // used for opt-in checkboxes (e.g. the "Also remove from statistics"
   // toggle in the archive delete confirmation, #1343).
@@ -38,6 +43,7 @@ export function ConfirmModal({
   variant = 'default',
   isLoading = false,
   loadingText,
+  confirmDisabled = false,
   children,
   onConfirm,
   onCancel,
@@ -104,7 +110,7 @@ export function ConfirmModal({
             <Button
               onClick={onConfirm}
               className={`flex-1 ${styles.button}`}
-              disabled={isLoading}
+              disabled={isLoading || confirmDisabled}
             >
               {isLoading ? (
                 <>

+ 16 - 3
frontend/src/components/EditArchiveModal.tsx

@@ -51,7 +51,19 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
   const [projectId, setProjectId] = useState<number | null>(archive.project_id ?? null);
   const [notes, setNotes] = useState(archive.notes || '');
   const [tags, setTags] = useState(archive.tags || '');
-  const [failureReason, setFailureReason] = useState(archive.failure_reason || '');
+  // Failure reason is stored as a camelCase key (`filamentRunout`), but earlier
+  // versions of this modal saved the translated label as the value. Reverse-
+  // lookup any legacy translated text against the current locale so the
+  // dropdown pre-selects the right option, then any save converts it forward.
+  const [failureReason, setFailureReason] = useState(() => {
+    const raw = archive.failure_reason || '';
+    if (!raw) return '';
+    if ((FAILURE_REASON_KEYS as readonly string[]).includes(raw)) return raw;
+    const match = FAILURE_REASON_KEYS.find(
+      (k) => t(`editArchive.failureReasons.${k}`) === raw,
+    );
+    return match || '';
+  });
   const [status, setStatus] = useState(archive.status);
   const [quantity, setQuantity] = useState(archive.quantity ?? 1);
   const [photos, setPhotos] = useState<string[]>(archive.photos || []);
@@ -417,15 +429,16 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
           {/* Failure Reason - only show for failed/aborted prints */}
           {(status === 'failed' || status === 'aborted') && (
             <div>
-              <label className="block text-sm text-bambu-gray mb-1">{t('editArchive.failureReason')}</label>
+              <label htmlFor="failure-reason-select" className="block text-sm text-bambu-gray mb-1">{t('editArchive.failureReason')}</label>
               <select
+                id="failure-reason-select"
                 value={failureReason}
                 onChange={(e) => setFailureReason(e.target.value)}
                 className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
               >
                 <option value="">{t('editArchive.selectReason')}</option>
                 {FAILURE_REASON_KEYS.map((reasonKey) => (
-                  <option key={reasonKey} value={t(`editArchive.failureReasons.${reasonKey}`)}>
+                  <option key={reasonKey} value={reasonKey}>
                     {t(`editArchive.failureReasons.${reasonKey}`)}
                   </option>
                 ))}

+ 1 - 1
frontend/src/components/PrintLogTable.tsx

@@ -78,7 +78,7 @@ export function PrintLogTable({ archiveId }: PrintLogTableProps) {
                   {t(`archives.runLog.status.${run.status}`, { defaultValue: run.status })}
                   {run.failure_reason && (
                     <span className="block text-[10px] text-bambu-gray font-normal">
-                      {run.failure_reason}
+                      {t(`editArchive.failureReasons.${run.failure_reason}`, { defaultValue: run.failure_reason })}
                     </span>
                   )}
                 </td>

+ 124 - 82
frontend/src/components/PrintModal/FilamentMapping.tsx

@@ -1,11 +1,12 @@
 import { useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useQuery, useQueryClient } from '@tanstack/react-query';
-import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
+import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp, Palette } from 'lucide-react';
 import { api } from '../../api/client';
 import { useFilamentMapping } from '../../hooks/useFilamentMapping';
 import { getGlobalTrayId } from '../../utils/amsHelpers';
 import { getColorName } from '../../utils/colors';
+import { useFilamentLabels } from './useFilamentLabels';
 import type { FilamentMappingProps } from './types';
 
 /**
@@ -20,6 +21,8 @@ export function FilamentMapping({
   currencySymbol,
   defaultCostPerKg,
   defaultExpanded = false,
+  forceColorMatch,
+  onForceColorMatchChange,
 }: FilamentMappingProps & { defaultExpanded?: boolean }) {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
@@ -42,6 +45,13 @@ export function FilamentMapping({
   const { loadedFilaments, filamentComparison, hasTypeMismatch, hasColorMismatch } =
     useFilamentMapping(filamentReqs, printerStatus, manualMappings);
 
+  // Per-slot sub-brand + material-disambiguated colour labels (#1718). Same
+  // shared hook the model-mode FilamentOverride uses so both panels render
+  // the same sliced-3MF identity. Falls back to the raw type / generic
+  // colour bucket when the SKU is unknown or the by-material lookup hasn't
+  // resolved — never blanks out the required row.
+  const filamentLabels = useFilamentLabels(filamentReqs?.filaments);
+
   const trayCostMap = useMemo(() => {
     const map = new Map<number, number | null>();
     for (const assignment of assignments || []) {
@@ -184,92 +194,124 @@ export function FilamentMapping({
               <span>Re-read</span>
             </button>
           </div>
-          {filamentComparison.map((item, idx) => (
-            <div
-              key={idx}
-              className="grid items-center gap-2 text-xs"
-              style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 16px' }}
-            >
-              {/* Required color */}
-              <span title={`Required: ${item.type} - ${getColorName(item.color)}`}>
-                <Circle className="w-3 h-3" fill={item.color} stroke={item.color} />
-              </span>
-              {/* Required type + grams + nozzle badge */}
-              <span className="text-white truncate flex items-center gap-1">
-                {isDualNozzle && item.nozzle_id != null && (
-                  <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"
-                    title={item.nozzle_id === 1 ? t('printModal.leftNozzleTooltip') : t('printModal.rightNozzleTooltip')}
-                  >
-                    {item.nozzle_id === 1 ? t('printModal.leftNozzle') : t('printModal.rightNozzle')}
-                  </span>
-                )}
-                {item.type} <span className="text-bambu-gray">({item.used_grams}g)</span>
-              </span>
-              {/* Arrow */}
-              <span className="text-bambu-gray">→</span>
-              {/* Slot selector dropdown */}
-              <select
-                value={item.loaded?.globalTrayId ?? ''}
-                onChange={(e) => handleSlotChange(item.slot_id || 0, e.target.value)}
-                className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
-                  item.status === 'match'
-                    ? 'border-bambu-green/50 text-bambu-green'
-                    : item.status === 'type_only'
-                    ? 'border-yellow-400/50 text-yellow-400'
-                    : 'border-orange-400/50 text-orange-400'
-                } ${item.isManual ? 'ring-1 ring-blue-400/50' : ''}`}
-                title={item.isManual ? 'Manually selected' : 'Auto-matched'}
+          {filamentComparison.map((item, idx) => {
+            // #1717: surface the same per-slot force-color-match checkbox here
+            // that FilamentOverride exposes for model-mode dispatch. The
+            // scheduler honors the flag in both modes; only the UI was missing.
+            const slotId = item.slot_id ?? 0;
+            const canForceMatch = slotId > 0 && onForceColorMatchChange != null;
+            // #1718: same sub-brand + colour resolution as FilamentOverride.
+            // Indexing is safe because ``useFilamentLabels`` mirrors the input
+            // array shape; defensive fallback covers the empty-reqs render
+            // path that shouldn't reach here anyway.
+            const { resolvedName, colorLabel } = filamentLabels[idx] ?? { resolvedName: item.type, colorLabel: getColorName(item.color) };
+            return (
+            <div key={idx} className="space-y-1">
+              <div
+                className="grid items-center gap-2 text-xs"
+                style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 16px' }}
               >
-                <option value="" className="bg-bambu-dark text-bambu-gray">
-                  -- Select slot --
-                </option>
-                {loadedFilaments
-                  .filter(
-                    (f) =>
-                      item.nozzle_id == null ||
-                      ftsInstalled ||
-                      f.extruderId === item.nozzle_id,
-                  )
-                  .map((f) => {
-                    const remainingWeight = trayRemainingWeightMap.get(f.globalTrayId);
-                    const remainingLabel = remainingWeight != null
-                      ? t('printModal.slotRemainingShort', {
-                          grams: remainingWeight,
-                          defaultValue: ` - ${remainingWeight}g left`,
-                        })
-                      : '';
-                    // FTS routing badge: if this slot is currently fed into an FTS
-                    // track, show the destination extruder. Idle (not-loaded) slots
-                    // get no badge — they can be routed to either extruder on demand.
-                    const ftsTargetExtruder = ftsInstalled
-                      ? ftsExtruderForSlot(f.globalTrayId)
-                      : null;
-                    const ftsBadge =
-                      ftsTargetExtruder == null
-                        ? ''
-                        : ` [${ftsTargetExtruder === 1 ? t('printModal.leftNozzle') : t('printModal.rightNozzle')}]`;
-                    return (
-                      <option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
-                        {f.label}: {f.traySubBrands || f.type} ({f.colorName}){remainingLabel}{ftsBadge}
-                      </option>
-                    );
-                })}
-              </select>
-              {/* Status icon */}
-              {item.status === 'match' ? (
-                <Check className="w-3 h-3 text-bambu-green" />
-              ) : item.status === 'type_only' ? (
-                <span title="Same type, different color">
-                  <AlertTriangle className="w-3 h-3 text-yellow-400" />
+                {/* Required color */}
+                <span title={`Required: ${resolvedName} - ${colorLabel}`}>
+                  <Circle className="w-3 h-3" fill={item.color} stroke={item.color} />
                 </span>
-              ) : (
-                <span title="Filament type not loaded">
-                  <AlertTriangle className="w-3 h-3 text-orange-400" />
+                {/* Required type + grams + nozzle badge */}
+                <span className="text-white truncate flex items-center gap-1">
+                  {isDualNozzle && item.nozzle_id != null && (
+                    <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"
+                      title={item.nozzle_id === 1 ? t('printModal.leftNozzleTooltip') : t('printModal.rightNozzleTooltip')}
+                    >
+                      {item.nozzle_id === 1 ? t('printModal.leftNozzle') : t('printModal.rightNozzle')}
+                    </span>
+                  )}
+                  {resolvedName} <span className="text-bambu-gray">({item.used_grams}g)</span>
                 </span>
+                {/* Arrow */}
+                <span className="text-bambu-gray">→</span>
+                {/* Slot selector dropdown */}
+                <select
+                  value={item.loaded?.globalTrayId ?? ''}
+                  onChange={(e) => handleSlotChange(slotId, e.target.value)}
+                  className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
+                    item.status === 'match'
+                      ? 'border-bambu-green/50 text-bambu-green'
+                      : item.status === 'type_only'
+                      ? 'border-yellow-400/50 text-yellow-400'
+                      : 'border-orange-400/50 text-orange-400'
+                  } ${item.isManual ? 'ring-1 ring-blue-400/50' : ''}`}
+                  title={item.isManual ? 'Manually selected' : 'Auto-matched'}
+                >
+                  <option value="" className="bg-bambu-dark text-bambu-gray">
+                    -- Select slot --
+                  </option>
+                  {/*
+                    #1722: every loaded slot is offered for every filament row,
+                    regardless of which extruder the slot is wired to. Before this
+                    change a slot was only listed when its extruder matched the
+                    filament's slicer-assigned nozzle (item.nozzle_id), which
+                    locked users out of cross-extruder picks even when they'd
+                    intentionally loaded the required filament into the "other"
+                    AMS. The L/R badge on the filament row still tells the user
+                    what the slicer planned; the dropdown now trusts the user to
+                    pick based on their physical setup. Printer firmware accepts
+                    or rejects the ams_mapping at start-print — failure is loud,
+                    not silent.
+                  */}
+                  {loadedFilaments.map((f) => {
+                      const remainingWeight = trayRemainingWeightMap.get(f.globalTrayId);
+                      const remainingLabel = remainingWeight != null
+                        ? t('printModal.slotRemainingShort', {
+                            grams: remainingWeight,
+                            defaultValue: ` - ${remainingWeight}g left`,
+                          })
+                        : '';
+                      // FTS routing badge: if this slot is currently fed into an FTS
+                      // track, show the destination extruder. Idle (not-loaded) slots
+                      // get no badge — they can be routed to either extruder on demand.
+                      const ftsTargetExtruder = ftsInstalled
+                        ? ftsExtruderForSlot(f.globalTrayId)
+                        : null;
+                      const ftsBadge =
+                        ftsTargetExtruder == null
+                          ? ''
+                          : ` [${ftsTargetExtruder === 1 ? t('printModal.leftNozzle') : t('printModal.rightNozzle')}]`;
+                      return (
+                        <option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
+                          {f.label}: {f.traySubBrands || f.type} ({f.colorName}){remainingLabel}{ftsBadge}
+                        </option>
+                      );
+                  })}
+                </select>
+                {/* Status icon */}
+                {item.status === 'match' ? (
+                  <Check className="w-3 h-3 text-bambu-green" />
+                ) : item.status === 'type_only' ? (
+                  <span title="Same type, different color">
+                    <AlertTriangle className="w-3 h-3 text-yellow-400" />
+                  </span>
+                ) : (
+                  <span title="Filament type not loaded">
+                    <AlertTriangle className="w-3 h-3 text-orange-400" />
+                  </span>
+                )}
+              </div>
+              {/* Force Color Match checkbox — matches FilamentOverride's layout. */}
+              {canForceMatch && (
+                <label className="inline-flex items-center gap-1.5 text-xs text-bambu-gray cursor-pointer select-none pl-5">
+                  <input
+                    type="checkbox"
+                    checked={forceColorMatch?.[slotId] ?? false}
+                    onChange={(e) => onForceColorMatchChange(slotId, e.target.checked)}
+                    className="accent-bambu-green w-3 h-3"
+                  />
+                  <Palette className="w-3 h-3" />
+                  {t('printModal.forceColorMatch')}
+                </label>
               )}
             </div>
-          ))}
+            );
+          })}
           <div className="text-xs text-bambu-gray">
             {t('printModal.totalCost')}{' '}
             <span className="text-white">

+ 18 - 4
frontend/src/components/PrintModal/FilamentOverride.tsx

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
 import { Circle, RotateCcw, Palette } from 'lucide-react';
 import { getColorName } from '../../utils/colors';
 import { canonicalFilamentType } from '../../utils/amsHelpers';
+import { useFilamentLabels } from './useFilamentLabels';
 import type { FilamentReqsData } from './types';
 
 interface FilamentOverrideProps {
@@ -32,6 +33,11 @@ export function FilamentOverride({
 }: FilamentOverrideProps) {
   const { t } = useTranslation();
 
+  // Per-slot sub-brand + material-disambiguated colour labels (#1718). The
+  // shared hook fronts the three queries that power the resolution so this
+  // component and ``FilamentMapping`` cannot drift apart on label content.
+  const labels = useFilamentLabels(filamentReqs?.filaments);
+
   // Index available filaments by canonical type for per-slot filtering.
   // Types in the same equivalence group (e.g. PA-CF / PA12-CF / PAHT-CF) share one bucket.
   const filamentsByType = useMemo(() => {
@@ -69,7 +75,7 @@ export function FilamentOverride({
       </div>
       <p className="text-xs text-bambu-gray mb-2">{t('printModal.filamentOverrideHint')}</p>
       <div className="bg-bambu-dark rounded-lg p-3 space-y-2">
-        {filaments.map((req) => {
+        {filaments.map((req, slotIdx) => {
           const override = overrides[req.slot_id];
           const isOverridden = !!override;
           // Only show filaments of the same type AND compatible nozzle/extruder
@@ -81,6 +87,14 @@ export function FilamentOverride({
             ? sameType.filter((f) => f.extruder_id == null || f.extruder_id === req.nozzle_id)
             : sameType;
 
+          // #1718: sub-brand resolved from the 3MF's tray_info_idx via the
+          // builtin / cloud-id maps, plus the material-disambiguated catalogue
+          // colour for the hex. Both fall back gracefully (resolvedName →
+          // req.type when the SKU is unknown; colorLabel → getColorName(hex)
+          // when the by-material lookup hasn't resolved yet, returned null,
+          // or errored) so a slow query never blanks out the row.
+          const { resolvedName, colorLabel } = labels[slotIdx] ?? { resolvedName: req.type, colorLabel: getColorName(req.color) };
+
           return (
             <div key={req.slot_id} className="space-y-1">
               <div
@@ -88,12 +102,12 @@ export function FilamentOverride({
                 style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 20px' }}
               >
                 {/* Original color swatch */}
-                <span title={`${t('printModal.originalFilament')}: ${req.type} - ${getColorName(req.color)}`}>
+                <span title={`${t('printModal.originalFilament')}: ${resolvedName} - ${colorLabel}`}>
                   <Circle className="w-3 h-3" fill={req.color} stroke={req.color} />
                 </span>
                 {/* Original type + grams */}
                 <span className="text-white truncate">
-                  {req.type} <span className="text-bambu-gray">({req.used_grams}g)</span>
+                  {resolvedName} <span className="text-bambu-gray">({req.used_grams}g)</span>
                 </span>
                 {/* Arrow */}
                 <span className="text-bambu-gray">→</span>
@@ -109,7 +123,7 @@ export function FilamentOverride({
                   }`}
                 >
                   <option value="" className="bg-bambu-dark text-bambu-gray">
-                    {t('printModal.originalFilament')}: {req.type} ({getColorName(req.color)})
+                    {t('printModal.originalFilament')}: {resolvedName} ({colorLabel})
                   </option>
                   {compatible.map((f, idx) => (
                     <option

+ 4 - 0
frontend/src/components/PrintModal/index.tsx

@@ -1106,6 +1106,10 @@ export function PrintModal({
                 defaultExpanded={!!initialSelectedPrinterIds?.length || (settings?.per_printer_mapping_expanded ?? false)}
                 currencySymbol={currencySymbol}
                 defaultCostPerKg={defaultCostPerKg}
+                forceColorMatch={forceColorMatch}
+                onForceColorMatchChange={(slotId, value) =>
+                  setForceColorMatch((prev) => ({ ...prev, [slotId]: value }))
+                }
               />
             )}
 

+ 10 - 0
frontend/src/components/PrintModal/types.ts

@@ -183,6 +183,10 @@ export interface FilamentReqsData {
     used_grams: number;
     used_meters: number;
     nozzle_id?: number;
+    /** Bambu SKU code from the 3MF (e.g. `GFA01` = Bambu PLA Matte, `P4d64437`
+     *  = user custom). Used to resolve the "original" filament label in
+     *  FilamentOverride against the builtin + cloud user-preset maps. #1718. */
+    tray_info_idx?: string;
   }>;
 }
 
@@ -197,6 +201,12 @@ export interface FilamentMappingProps {
   onManualMappingChange: (mappings: Record<number, number>) => void;
   currencySymbol: string;
   defaultCostPerKg: number;
+  /** Per-slot force-color-match flags. The scheduler honors this flag in both
+   *  model-mode and printer-mode dispatch, but the checkbox was previously only
+   *  surfaced in FilamentOverride (model mode). #1717. */
+  forceColorMatch?: Record<number, boolean>;
+  /** Called when a slot's force-color-match checkbox is toggled. */
+  onForceColorMatchChange?: (slotId: number, value: boolean) => void;
 }
 
 /**

+ 118 - 0
frontend/src/components/PrintModal/useFilamentLabels.ts

@@ -0,0 +1,118 @@
+import { useMemo } from 'react';
+import { useQueries, useQuery } from '@tanstack/react-query';
+import { api } from '../../api/client';
+import { getColorName } from '../../utils/colors';
+
+/** Strip a leading brand token (the first whitespace-separated word) from a
+ *  resolved filament name so what remains can be matched against the color
+ *  catalog's ``material`` column. Examples:
+ *    "Bambu PLA Matte"   → "PLA Matte"
+ *    "PolyLite ABS"      → "ABS"
+ *    "Bambu PLA-CF"      → "PLA-CF"
+ *    "PLA"               → "PLA"        (no brand to strip; pass through)
+ *    "Devil Design PLA"  → "Design PLA" (won't match catalog → falls back
+ *                                        to priority-order answer, no regression)
+ *  Never returns ``""`` — the empty-material case is the same priority
+ *  fallback as omitting the param.
+ */
+export function extractMaterialHint(name: string): string {
+  const parts = name.trim().split(/\s+/);
+  if (parts.length <= 1) return name.trim();
+  return parts.slice(1).join(' ');
+}
+
+export interface FilamentLabel {
+  /** Bambu sub-brand from the SKU lookup ("Bambu PLA Matte") falling back to
+   *  the raw 3MF ``type`` ("PLA") when the SKU is unknown to both maps. */
+  resolvedName: string;
+  /** Material-disambiguated catalogue color ("Charcoal") falling back to
+   *  ``getColorName(hex)`` when the by-material lookup hasn't resolved yet,
+   *  returned null, or errored. Always non-empty. */
+  colorLabel: string;
+}
+
+interface FilamentReqLike {
+  type: string;
+  color: string;
+  tray_info_idx?: string;
+}
+
+/**
+ * Resolve per-slot human-readable labels for the schedule modal's filament
+ * panels (#1718). Both the model-mode ``FilamentOverride`` and the printer-
+ * mode ``FilamentMapping`` consume this so the two panels render the same
+ * sub-brand + disambiguated color for the same sliced 3MF. Extracted from
+ * the inline implementation in ``FilamentOverride`` so the two callers can't
+ * drift.
+ *
+ * Three queries back the resolution:
+ *   - ``/cloud/builtin-filaments`` → Bambu factory SKU → name map (GFA01 →
+ *     "Bambu PLA Matte" etc.).
+ *   - ``/cloud/filament-id-map``   → user custom cloud preset SKU → name
+ *     map (P-prefix). Wins over the builtin entry for the same id.
+ *   - ``/inventory/colors/by-material`` (one ``useQuery`` per slot via
+ *     ``useQueries``, keyed on hex + material hint) → catalog color name
+ *     disambiguated by material context.
+ *
+ * Output is positional — ``labels[i]`` corresponds to ``reqs[i]``. Returns
+ * an empty array when ``reqs`` is undefined / empty so callers can safely
+ * index without a length check.
+ */
+export function useFilamentLabels(reqs: readonly FilamentReqLike[] | undefined): FilamentLabel[] {
+  const { data: builtinFilaments } = useQuery({
+    queryKey: ['builtin-filaments'],
+    queryFn: () => api.getBuiltinFilaments(),
+    staleTime: 5 * 60 * 1000,
+  });
+  const { data: cloudFilamentIdMap } = useQuery({
+    queryKey: ['filament-id-map'],
+    queryFn: () => api.getFilamentIdMap(),
+    staleTime: 5 * 60 * 1000,
+  });
+
+  const filamentNameByIdx = useMemo(() => {
+    const map: Record<string, string> = {};
+    for (const f of builtinFilaments || []) {
+      if (f.filament_id) map[f.filament_id] = f.name;
+    }
+    // Cloud user-preset map wins when both have the same id — the user-
+    // authored name is the more specific label.
+    for (const [fid, name] of Object.entries(cloudFilamentIdMap || {})) {
+      if (fid && name) map[fid] = name;
+    }
+    return map;
+  }, [builtinFilaments, cloudFilamentIdMap]);
+
+  // Compute the per-slot (resolvedName, materialHint) pairs up-front so the
+  // ``useQueries`` call below has a stable shape and the render path below
+  // can reuse the same resolvedName without recomputing.
+  const perSlot = useMemo(() => {
+    return (reqs || []).map((req) => {
+      const resolvedName = (req.tray_info_idx && filamentNameByIdx[req.tray_info_idx]) || req.type;
+      return {
+        resolvedName,
+        materialHint: extractMaterialHint(resolvedName),
+        color: req.color,
+      };
+    });
+  }, [reqs, filamentNameByIdx]);
+
+  const colorQueries = useQueries({
+    queries: perSlot.map(({ color, materialHint }) => ({
+      queryKey: ['color-by-material', color, materialHint],
+      queryFn: () => api.getColorByMaterial(color, materialHint),
+      // Treat empty colour as "nothing to look up" so we don't spam the
+      // endpoint for entries the 3MF left blank.
+      enabled: !!color,
+      staleTime: 5 * 60 * 1000,
+    })),
+  });
+
+  return perSlot.map(({ resolvedName, color }, idx) => {
+    const disambiguated = colorQueries[idx]?.data?.color_name ?? null;
+    return {
+      resolvedName,
+      colorLabel: disambiguated || getColorName(color),
+    };
+  });
+}

+ 54 - 320
frontend/src/components/SliceModal.tsx

@@ -1,4 +1,4 @@
-import { Cloud, CloudOff, Cog, Loader2, Package, RefreshCw, X } from 'lucide-react';
+import { Cloud, CloudOff, Cog, Loader2, RefreshCw, X } from 'lucide-react';
 import { useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -6,10 +6,8 @@ import {
   api,
   type PresetRef,
   type PresetSource,
-  type SliceBundleSpec,
   type SliceJobProgress,
   type SliceRequest,
-  type SlicerBundle,
   type SlicerCloudStatus,
   type UnifiedPreset,
   type UnifiedPresetsBySlot,
@@ -38,15 +36,14 @@ interface SliceModalProps {
 
 type Slot = 'printer' | 'process' | 'filament';
 
-// SliceModal-specific tier priority: orca_cloud → local → cloud → standard.
-// Imported (local) profiles are surfaced before Bambu Cloud because they're
-// metadata-tagged (Bambu Cloud isn't, by design — see
-// `_fetch_cloud_presets`'s rate-limit note). Orca Cloud comes first because
-// its sync_pull response inlines metadata too AND represents the user's
-// most-recently-curated source. Standard is the bundled fallback. This is
-// distinct from the listing endpoint's dedup order and only affects what
-// the SliceModal renders / pre-picks.
-const SLICE_MODAL_TIER_ORDER = ['orca_cloud', 'local', 'cloud', 'standard'] as const;
+// Lookup priority: local → orca_cloud → cloud → standard. Local imports
+// outrank everything else because the user explicitly imported them for
+// this install; Orca Cloud comes next; Bambu Cloud after that; standard
+// (bundled) is the final fallback. The backend does NOT dedup tiers —
+// every group renders its full set so the user can pick a same-named
+// preset from a lower-priority source if they want to override the
+// auto-pick.
+const SLICE_MODAL_TIER_ORDER = ['local', 'orca_cloud', 'cloud', 'standard'] as const;
 
 function pickDefault(by: UnifiedPresetsResponse, slot: Slot): PresetRef | null {
   for (const tier of SLICE_MODAL_TIER_ORDER) {
@@ -117,8 +114,8 @@ function pickProcessDefault(
 }
 
 const TIER_BONUS: Record<PresetSource, number> = {
-  orca_cloud: 1.75,
-  local: 1.5,
+  local: 1.75,
+  orca_cloud: 1.5,
   cloud: 1.0,
   standard: 0.5,
 };
@@ -324,14 +321,6 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
   // entry per AMS slot the plate uses. Pre-pick (effect below) initialises
   // each slot from the source plate's required (type, colour).
   const [filamentPresets, setFilamentPresets] = useState<(PresetRef | null)[]>([]);
-  // Bundle dispatch (alternative to the preset triplet). When non-null, the
-  // SliceModal hides the cloud/local/standard preset dropdowns and shows
-  // bundle-scoped pickers (process + per-slot filament from the chosen
-  // bundle's contents). Submit routes through the backend's bundle dispatch
-  // (`SliceRequest.bundle`) which skips PresetRef resolution.
-  const [selectedBundleId, setSelectedBundleId] = useState<string | null>(null);
-  const [bundleProcessName, setBundleProcessName] = useState<string | null>(null);
-  const [bundleFilamentNames, setBundleFilamentNames] = useState<(string | null)[]>([]);
   const [errorMessage, setErrorMessage] = useState<string | null>(null);
   // null = plate not yet picked (or single-plate / non-3MF — picker is skipped
   // and we'll backfill 1 at submit time). Set to a 1-indexed plate number once
@@ -457,46 +446,26 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
     }
   };
 
-  // Imported Printer Preset Bundles (.bbscfg). Empty list when no sidecar
-  // configured / no bundles imported yet; the bundle picker hides itself
-  // in that case so users without bundles see the original modal layout.
-  const bundlesQuery = useQuery({
-    queryKey: ['slicerBundles'],
-    queryFn: api.listSlicerBundles,
-    staleTime: 60_000,
-    enabled: !platesQuery.isLoading && !needsPlatePicker,
-    // Bundle listing is a hard 503 when the sidecar is offline; don't
-    // retry tight loops in that case.
-    retry: false,
-  });
   // Canonical Bambu printer-model registry — drives the @BBL <code> name
-  // fallback in slicerPrinterMatch when no slicer bundle covers a cloud /
-  // standard preset (#1325 follow-up). Long staleTime: the registry only
-  // changes across backend releases.
+  // fallback in slicerPrinterMatch for cloud / standard presets (#1325).
+  // Long staleTime: the registry only changes across backend releases.
   const printerModelsQuery = useQuery({
     queryKey: ['slicerPrinterModels'],
     queryFn: api.getSlicerPrinterModels,
     staleTime: Infinity,
   });
-  const selectedBundle: SlicerBundle | null = useMemo(() => {
-    if (!selectedBundleId || !bundlesQuery.data) return null;
-    return bundlesQuery.data.find((b) => b.id === selectedBundleId) ?? null;
-  }, [selectedBundleId, bundlesQuery.data]);
-  const isBundleMode = selectedBundle != null;
 
   // Selected-printer context for the process / filament filter (#1325).
   const selectedPrinterName = useMemo<string | null>(() => {
     if (!presetsQuery.data || !printerPreset) return null;
     return findPreset(presetsQuery.data, printerPreset, 'printer')?.name ?? null;
   }, [presetsQuery.data, printerPreset]);
-  // Compatibility ground truth: the user's uploaded Slicer Bundles plus the
-  // backend Bambu printer-model registry (#1325 + follow-up). The bundle
-  // path handles imported / custom presets; the registry-driven @BBL name
-  // fallback inside slicerPrinterMatch picks up cloud / standard presets
-  // for users who haven't uploaded bundles yet.
+  // Compatibility ground truth: the slicer's own `compatible_printers` list
+  // on local-imported presets, plus the @BBL <code> name fallback for cloud
+  // / standard presets via the backend Bambu printer-model registry.
   const compatIndex = useMemo<PrinterCompatibilityIndex>(
-    () => buildCompatibilityIndex(bundlesQuery.data ?? [], printerModelsQuery.data ?? {}),
-    [bundlesQuery.data, printerModelsQuery.data],
+    () => buildCompatibilityIndex(printerModelsQuery.data ?? {}),
+    [printerModelsQuery.data],
   );
 
   // Printer / process preset names the source 3MF was prepared with. The
@@ -564,35 +533,6 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
     });
   }, [presetsQuery.data, filamentSlots, selectedPrinterName, compatIndex]);
 
-  // Bundle-mode auto-pick: when the user picks a bundle (or the slot count
-  // changes after the picker is open), default the process to the bundle's
-  // first listed process and every filament slot to the bundle's first
-  // listed filament. Plain string match — bundles store delta files keyed
-  // by user preset name, no scoring needed since the user picks per-slot
-  // afterwards if the default is wrong.
-  useEffect(() => {
-    if (!selectedBundle) {
-      // Reset bundle picks when bundle is cleared so re-selection
-      // re-defaults rather than carrying stale values.
-      setBundleProcessName(null);
-      setBundleFilamentNames([]);
-      return;
-    }
-    setBundleProcessName((current) => {
-      // Preserve a manual pick if it still exists in the bundle; otherwise
-      // re-default. Same shape as the preset auto-pick effect above.
-      if (current && selectedBundle.process.includes(current)) return current;
-      return selectedBundle.process[0] ?? null;
-    });
-    setBundleFilamentNames((current) => {
-      if (current.length === filamentSlots.length && current.every((n) => n != null)) {
-        return current;
-      }
-      const fallback = selectedBundle.filament[0] ?? null;
-      return filamentSlots.map((_, i) => current[i] ?? fallback);
-    });
-  }, [selectedBundle, filamentSlots]);
-
   const enqueueMutation = useMutation({
     mutationFn: async (plate: number | null) => {
       const body = buildSliceBody(plate);
@@ -615,29 +555,6 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
   // is the 1-indexed plate number to slice, or ``null`` for STL / single-
   // plate 3MF sources where the field is omitted entirely.
   function buildSliceBody(plate: number | null): SliceRequest {
-    if (isBundleMode) {
-      if (
-        !selectedBundle ||
-        !bundleProcessName ||
-        bundleFilamentNames.length === 0 ||
-        bundleFilamentNames.some((n) => n == null)
-      ) {
-        throw new Error(t('slice.bundleAllRequired'));
-      }
-      const bundleSpec: SliceBundleSpec = {
-        bundle_id: selectedBundle.id,
-        printer_name: selectedBundle.printer[0] ?? selectedBundle.printer_preset_name,
-        process_name: bundleProcessName,
-        filament_names: bundleFilamentNames as string[],
-      };
-      return {
-        bundle: bundleSpec,
-        ...(plate != null ? { plate } : {}),
-        // Bed-type override (#1337) also flows through the bundle path —
-        // the sidecar forwards `bedType` as --curr_bed_type to the CLI.
-        ...(bedType != null ? { bed_type: bedType } : {}),
-      };
-    }
     if (
       !printerPreset ||
       !processPreset ||
@@ -660,17 +577,12 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
   // Slice button stays disabled until the preview slice / embedded-metadata
   // read has succeeded (filamentReqsQuery.isSuccess) and every filament slot
   // has a picked profile.
-  const isReady = isBundleMode
-    ? selectedBundle != null &&
-      bundleProcessName != null &&
-      filamentReqsQuery.isSuccess &&
-      bundleFilamentNames.length > 0 &&
-      bundleFilamentNames.every((n) => n != null)
-    : printerPreset != null &&
-      processPreset != null &&
-      filamentReqsQuery.isSuccess &&
-      filamentPresets.length > 0 &&
-      filamentPresets.every((r) => r != null);
+  const isReady =
+    printerPreset != null &&
+    processPreset != null &&
+    filamentReqsQuery.isSuccess &&
+    filamentPresets.length > 0 &&
+    filamentPresets.every((r) => r != null);
   const isEnqueuing = enqueueMutation.isPending;
   const totalPlateCount = platesQuery.data?.plates?.length ?? 0;
   const canSliceAll = isMultiPlate && totalPlateCount > 1 && !needsPlatePicker;
@@ -770,71 +682,27 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
                   status === 'ok' (returns null in that case), but the Refresh
                   button stays visible regardless so users can pick up cloud /
                   bundled changes even when sign-in is healthy. */}
-              {/* Bundle picker — only renders when at least one .bbscfg has
-                  been imported via Settings → Slicer Bundles. Lets the user
-                  trade the cloud/local/standard tier for a single curated
-                  triplet from a previously-uploaded BambuStudio bundle. */}
-              {bundlesQuery.data && bundlesQuery.data.length > 0 && (
-                <BundlePicker
-                  bundles={bundlesQuery.data}
-                  selectedId={selectedBundleId}
-                  onChange={setSelectedBundleId}
-                  disabled={isEnqueuing}
-                />
-              )}
-              {/* Preset triplet — hidden when a bundle is selected so the
-                  user only sees one tier at a time. The bundle's process +
-                  filament dropdowns render below in their stead. */}
-              {!isBundleMode && (
-                <>
-                  <PresetDropdown
-                    label={t('slice.printer')}
-                    slot="printer"
-                    data={presetsQuery.data}
-                    value={printerPreset}
-                    onChange={setPrinterPreset}
-                    disabled={isEnqueuing}
-                  />
-                  <PresetDropdown
-                    label={t('slice.process')}
-                    slot="process"
-                    data={presetsQuery.data}
-                    value={processPreset}
-                    onChange={setProcessPreset}
-                    disabled={isEnqueuing}
-                    selectedPrinterName={selectedPrinterName}
-                    compatIndex={compatIndex}
-                  />
-                </>
-              )}
-              {isBundleMode && selectedBundle && (
-                <>
-                  {/* Bundle's printer is implicit (each .bbscfg has exactly
-                      one). Show it as a read-only label so the user can
-                      verify the printer they're slicing for. */}
-                  <div>
-                    <label className="block text-sm text-bambu-gray mb-1">
-                      {t('slice.printer')}
-                    </label>
-                    <div className="px-3 py-2 rounded-md bg-bambu-dark/40 border border-bambu-dark-tertiary text-white text-sm">
-                      {selectedBundle.printer_preset_name}
-                    </div>
-                  </div>
-                  <BundleStringDropdown
-                    label={t('slice.process')}
-                    options={selectedBundle.process}
-                    value={bundleProcessName}
-                    onChange={setBundleProcessName}
-                    disabled={isEnqueuing}
-                  />
-                </>
-              )}
+              <PresetDropdown
+                label={t('slice.printer')}
+                slot="printer"
+                data={presetsQuery.data}
+                value={printerPreset}
+                onChange={setPrinterPreset}
+                disabled={isEnqueuing}
+              />
+              <PresetDropdown
+                label={t('slice.process')}
+                slot="process"
+                data={presetsQuery.data}
+                value={processPreset}
+                onChange={setProcessPreset}
+                disabled={isEnqueuing}
+                selectedPrinterName={selectedPrinterName}
+                compatIndex={compatIndex}
+              />
               {/* Bed-type override (#1337). Always visible, always enabled.
-                  In non-bundle mode the backend patches curr_bed_type on the
-                  resolved process JSON before forwarding to the sidecar; in
-                  bundle mode the same value rides through as a sidecar form
-                  field so the bundle's materialised process JSON gets the
-                  override applied there too. */}
+                  The backend patches curr_bed_type on the resolved process
+                  JSON before forwarding to the sidecar. */}
               <BedTypeDropdown
                 value={bedType}
                 onChange={setBedType}
@@ -849,39 +717,6 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
                   requestId={previewRequestId}
                   sourceName={source.filename}
                 />
-              ) : isBundleMode && selectedBundle ? (
-                filamentSlots.map((slot, idx) => {
-                  const isUsed = slot.used_in_plate !== false;
-                  const baseLabel =
-                    filamentSlots.length > 1
-                      ? t('slice.filamentSlot', {
-                          index: idx + 1,
-                          type: slot.type,
-                        })
-                      : t('slice.filament');
-                  const label = isUsed
-                    ? baseLabel
-                    : `${baseLabel} ${t('slice.notUsedByPlate')}`;
-                  return (
-                    <BundleStringDropdown
-                      key={`bundle-filament-${idx}`}
-                      label={label}
-                      options={selectedBundle.filament}
-                      value={bundleFilamentNames[idx] ?? null}
-                      onChange={(name) =>
-                        setBundleFilamentNames((current) => {
-                          const next = current.length === filamentSlots.length
-                            ? [...current]
-                            : filamentSlots.map((_, i) => current[i] ?? null);
-                          next[idx] = name;
-                          return next;
-                        })
-                      }
-                      disabled={isEnqueuing || !isUsed}
-                      swatchColor={filamentSlots.length > 1 ? slot.color : undefined}
-                    />
-                  );
-                })
               ) : (
                 filamentSlots.map((slot, idx) => {
                   // Slots flagged by the backend as not used by the
@@ -999,7 +834,12 @@ function CloudStatusBanner({
   cloudName?: 'bambu' | 'orca';
 }) {
   const { t } = useTranslation();
-  if (status === 'ok') return null;
+  // `ok` is the happy path. `not_authenticated` is silenced too: a user who
+  // hasn't signed in (or has explicitly logged out — #1712) doesn't need a
+  // permanent nag at the top of the modal; sign-in lives on the Profiles
+  // page if they want it. Only `expired` and `unreachable` surface — those
+  // are real breakage states a previously-signed-in user needs to see.
+  if (status === 'ok' || status === 'not_authenticated') return null;
 
   // Same status vocabulary for both Bambu and Orca Cloud — only the
   // user-facing text varies. The fallbacks below name each cloud explicitly
@@ -1008,10 +848,6 @@ function CloudStatusBanner({
   const messages =
     cloudName === 'orca'
       ? {
-          not_authenticated: {
-            key: 'slice.orcaCloud.notAuthenticated',
-            fallback: 'Sign in to Orca Cloud (Profiles → Orca Cloud) to see your Orca presets.',
-          },
           expired: {
             key: 'slice.orcaCloud.expired',
             fallback: 'Orca Cloud session expired — sign in again to refresh your Orca presets.',
@@ -1022,10 +858,6 @@ function CloudStatusBanner({
           },
         }
       : {
-          not_authenticated: {
-            key: 'slice.cloud.notAuthenticated',
-            fallback: 'Sign in to Bambu Cloud (Settings → Profiles → Cloud) to see your cloud presets.',
-          },
           expired: {
             key: 'slice.cloud.expired',
             fallback: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
@@ -1036,11 +868,7 @@ function CloudStatusBanner({
           },
         };
 
-  const tones: Record<Exclude<SlicerCloudStatus, 'ok'>, { tone: string; icon: typeof Cloud }> = {
-    not_authenticated: {
-      tone: 'border-bambu-dark-tertiary/40 bg-bambu-dark text-bambu-gray',
-      icon: Cloud,
-    },
+  const tones: Record<'expired' | 'unreachable', { tone: string; icon: typeof Cloud }> = {
     expired: {
       tone: 'border-amber-700/40 bg-amber-900/20 text-amber-200',
       icon: CloudOff,
@@ -1122,9 +950,8 @@ interface PresetDropdownProps {
   // configuring against the source 3MF's per-slot colour.
   swatchColor?: string;
   // Selected printer context (#1325). When provided for a process / filament
-  // slot, presets that resolve to a different printer (per the uploaded
-  // Slicer Bundles in compatIndex) move into a trailing "Other printers"
-  // group instead of the main tier list.
+  // slot, presets that resolve to a different printer (per compatIndex) move
+  // into a trailing "Other printers" group instead of the main tier list.
   selectedPrinterName?: string | null;
   compatIndex?: PrinterCompatibilityIndex;
 }
@@ -1149,8 +976,8 @@ function PresetDropdown({
   // empty sections collapse out.
   const { sections, otherEntries } = useMemo(() => {
     const tiers: { key: keyof UnifiedPresetsResponse; label: string; fallback: string }[] = [
-      { key: 'orca_cloud', label: 'slice.tier.orcaCloud', fallback: 'Orca Cloud' },
       { key: 'local', label: 'slice.tier.local', fallback: 'Imported' },
+      { key: 'orca_cloud', label: 'slice.tier.orcaCloud', fallback: 'Orca Cloud' },
       { key: 'cloud', label: 'slice.tier.cloud', fallback: 'Bambu Cloud' },
       { key: 'standard', label: 'slice.tier.standard', fallback: 'Standard' },
     ];
@@ -1234,96 +1061,3 @@ function PresetDropdown({
     </label>
   );
 }
-
-// Top-of-modal bundle picker. The "None" option leaves the user on the
-// cloud/local/standard tier path; selecting a bundle id flips the modal
-// into bundle dispatch mode (see SliceModal state above).
-interface BundlePickerProps {
-  bundles: SlicerBundle[];
-  selectedId: string | null;
-  onChange: (id: string | null) => void;
-  disabled?: boolean;
-}
-
-function BundlePicker({ bundles, selectedId, onChange, disabled }: BundlePickerProps) {
-  const { t } = useTranslation();
-  return (
-    <label className="block">
-      <span className="block text-sm text-bambu-gray mb-1 inline-flex items-center gap-1.5">
-        <Package className="w-3.5 h-3.5" />
-        {t('slice.bundle')}
-      </span>
-      <select
-        value={selectedId ?? ''}
-        onChange={(e) => onChange(e.target.value || null)}
-        disabled={disabled}
-        className="w-full px-3 py-2 rounded-md bg-bambu-dark border border-bambu-dark-tertiary text-white text-sm focus:outline-none focus:border-bambu-gray disabled:opacity-50"
-      >
-        <option value="">
-          {t('slice.bundleNone')}
-        </option>
-        {bundles.map((b) => (
-          <option key={b.id} value={b.id}>
-            {b.printer_preset_name}
-          </option>
-        ))}
-      </select>
-    </label>
-  );
-}
-
-// Plain-string dropdown used for bundle-mode process / filament selectors.
-// Bundles store presets as a flat list of names within their printer-tied
-// directory, so a `<select>` of strings is enough — no source tier, no
-// optgroups. Same swatch / disabled affordances as the cloud/local/standard
-// PresetDropdown above so the visual rhythm of the form stays consistent.
-interface BundleStringDropdownProps {
-  label: string;
-  options: string[];
-  value: string | null;
-  onChange: (next: string | null) => void;
-  disabled?: boolean;
-  swatchColor?: string;
-}
-
-function BundleStringDropdown({
-  label,
-  options,
-  value,
-  onChange,
-  disabled,
-  swatchColor,
-}: BundleStringDropdownProps) {
-  const { t } = useTranslation();
-  return (
-    <label className="block">
-      <span className="block text-sm text-bambu-gray mb-1 inline-flex items-center gap-1.5">
-        {swatchColor && (
-          <span
-            className="inline-block w-3 h-3 rounded-sm border border-black/20"
-            style={{ backgroundColor: swatchColor || 'transparent' }}
-            aria-hidden
-          />
-        )}
-        <span>{label}</span>
-      </span>
-      <select
-        value={value ?? ''}
-        onChange={(e) => onChange(e.target.value || null)}
-        disabled={disabled || options.length === 0}
-        className="w-full px-3 py-2 rounded-md bg-bambu-dark border border-bambu-dark-tertiary text-white text-sm focus:outline-none focus:border-bambu-gray disabled:opacity-50"
-      >
-        <option value="">
-          {options.length === 0
-            ? t('slice.noPresetsForSlot')
-            : t('slice.selectPreset')}
-        </option>
-        {options.map((name) => (
-          <option key={name} value={name}>
-            {name}
-          </option>
-        ))}
-      </select>
-    </label>
-  );
-}

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio