Browse Source

Merge pull request #1383 from maziggy/0.2.4.1

  **Bambuddy v0.2.4.1**

⚠ **Upgrade Notes — Read Before Updating**

Almost everyone is upgrading from 0.2.4. 0.2.4.1 is a patch release: stability and correctness fixes built on the same code base as 0.2.4, no schema breaks, no Docker entrypoint changes, no Vite/proxy quirks. The in-app Apply Update button in Settings → System → Updates resolves to the latest stable tag and works for all users — no flags needed.

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 — none of the entrypoint, volume, or env-var conventions changed since 0.2.4.

**Native install — recommended path**

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

**Native install — manual path**

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

**Behaviour changes to know about**

- Reprint stats are now per-event, not per-archive. Re-printing a file no longer overwrites the source archive's totals; Quick Stats and the per-archive Print Log gain an orange N prints badge with a per-run breakdown (successful + failed). If you already had a reprint that overwrote stats in 0.2.4, the existing archive row keeps its current numbers — but every new print event from 0.2.4.1 onward writes a separate PrintLogEntry, so totals start adding correctly again. (#1378)

- Pending queue items for soft-deleted archives are now auto-cancelled. Soft-deleting an archive (default delete path) removes its files from disk, which makes any pending queue item pointing at it un-dispatchable. From 0.2.4.1 those items get status=cancelled + waiting_reason="Source archive deleted" so you see why the queue item disappeared from pending instead of finding it silently stuck. (#1348 follow-up)

- i18n parity check now blocks English-leak in non-English locale files. The build's parity step (npm run build) now fails CI when a non-English locale entry equals the English source unless explicitly allow-listed as a cognate. 2,377 accumulated English fallbacks across 7 locales were translated in this cycle as the underlying cleanup. No user-visible change today — just no more "Advanced" buttons in your German UI from new keys going forward.

---
  
**Highlights**

0.2.4.1 closes correctness gaps that hit power-users running queues, reprints, and Obico fault detection at the same time. The three biggest are: per-event stats aggregation so reprints add to Quick Stats instead of overwriting (#1378), camera stream no longer freezes when Obico polls the same printer (#1348, reported by @SL666), and multi-color archive cost now charges untracked AMS slots at the default rate instead of reporting near-zero (#1344, reported by @nicktags). Around them: AMS slot configuration that survives Reset Slot on A1 Mini BMCU / P1S Standard AMS, MakerWorld URL import, queue/VP-dispatched prints finally getting layer timelapse, plate detection respecting the external camera setting, firmware checks staying alive when bambulab.com Cloudflare-blocks the page, LDAP manual provisioning, and a narrow API-key permission for the Home Assistant dynamic-tariff integration.

Plus a deep i18n debt cleanup — 2,377 strings translated across 7 locales — and mechanical CI enforcement so the debt can't accumulate again.

---

**New Features**

- Manual LDAP user provisioning from the UI (#1298) — Add LDAP users into Bambuddy without waiting for their first login. Pre-create groups, permissions, and inventory ownership before the user even authenticates once.

- Build-plate override in the SliceModal (#1337) — Pick which build plate (Cool / Cool SuperTack / Engineering / High Temp / Textured PEI / Smooth PEI) to slice for, independent of the source 3MF's embedded plate. The slice respects the override end-to-end (sliced output is bound to the chosen plate, archive metadata records it, printer card thumbnail matches).

- API Keys: narrowly-scoped "Update electricity price" toggle (#1356) — New per-key permission flag exposes a single endpoint POST /api/v1/settings/electricity-price that accepts {"energy_cost_per_kwh": <float>}. Closes the gap where the wiki documented a Home Assistant dynamic-tariff rest_command example that was never deliverable (every key with general SETTINGS_UPDATE is hard-denied for security). The new flag does NOT widen general settings-write access — the broader PATCH /settings route remains denied. Wiki updated; existing keys default off.

- Per-archive Print Log view + clickable "N prints" badge (#1378 follow-up) — Every archive card with more than one print event shows an orange N prints badge with a hover-tooltip breakdown (successful vs failed). Click it (or use the context-menu entry) to open a print log dialog showing every print event for that archive — date, status, duration, filament, cost — with failure_reason text under failed runs. Also embedded as a section at the top of the Edit Archive modal so the history is one click away.

---

**Improved**

- Reset Slot on A1 Mini BMCU / P1S Standard AMS no longer deadlocks Assign Spool (#1322, reported by @RosdasHH) — The empty-detection that gated ams_filament_setting was too cautious; now only short-circuits on state ∈ {9, 10} (firmware's explicit "no spool" codes) so the post-Reset-Slot "spool inserted, state=3, tray_type empty" case fires MQTT and configures the slot. Same Reset Slot click that previously sat in pending state forever now lands cleanly.

- Multi-color archive cost now tops up untracked AMS slots at the default rate (#1344, reported by @nicktags) — A 110 g multi-color print with only one of four trays mapped to inventory used to show $0.01 instead of ~$1.10. Untracked slots now charge at the global default filament cost. Fully-tracked prints are unchanged.

- Plate-detection calibration captures from the configured external camera (#1359, reported by @Andlar94) — On printers with an external RTSP / go2rtc camera enabled, calibration was previously sourcing the reference frame from the built-in chamber camera while the runtime check used the external one — guaranteeing a "Build plate not empty" false-positive on every print. Both paths now share the same external-camera default, with a backend-side derivation so future callers can't drift again.

- Layer timelapse now starts for queue / VP-dispatched prints (#1353, reported by @Andlar94) — The timelapse start_session() call was only on the new-archive code paths. Queue dispatches and VP-dispatched reprints landed on the expected-archive branch and silently lost timelapse. The expected-archive branch now mirrors the same gate.

- Firmware update dialog survives Cloudflare-blocked / transient outages on bambulab.com (#1350, reported by @K1ngJony) — Adds honest browser-like Accept / Accept-Language headers alongside the existing Bambuddy/1.0 UA, persists the resolved buildId to disk (so a single bambulab.com 403 doesn't permanently break download-URL resolution for that session), retries once on 404 (Bambu rebuilt the page), and shows an honest error message when the download endpoint truly can't be reached.

- Subtype dropdown on the Add/Edit Spool form offers CF and GF (#1345) — Adding a third-party PETG-CF / PLA-CF spool no longer requires typing the variant by hand.

- Page-header visual style unified across the app (PR #1272 by @EdwardChamberlain) — Every page now uses the same icon-aligned heading shape.

- OIDC provider icons proxied server-side (PR #1342 by @netscout2001) — Icon fetches no longer expose the issuer's URL via browser request logs / DNS.

- Auto-print start G-code now fires after the printer reaches RUNNING, not before (#1304) — The first RUNNING transition after Bambuddy boots no longer fires an unrelated print-start; users with custom G-code injection get their snippets at the actual start of each print, not the boot of the daemon.

- i18n parity gate now enforces real translations in every locale, not English fallbacks — frontend/scripts/check-i18n-parity.mjs gains a new Check 4 that fails CI when a non-English leaf equals its English source unless explicitly allow-listed as a cognate. The 2,377 accumulated English fallbacks across the 7 non-English locales were translated as the underlying cleanup. Going forward, "English fallbacks per project convention" is not a thing — new keys must be translated in every locale or explicitly added to the per-locale IDENTICAL_TO_EN_ALLOWED cognate list.

- Support bundle records more application state — Adds OIDC providers + 2FA / API key / long-lived token counts, library / inventory / queue / maintenance totals, slicer-API CLI versions, GitHub backup status, per-printer Obico flag. Redacts two settings that were previously included in cleartext and fixes a reachability-check architecture bug. Future triage rarely needs a follow-up "can you also send X" round-trip.

---

**Fixed**

**Stats / Archives / Print Log**

- Reprints (including failed and cancelled ones) no longer overwrite the source archive's statistics (#1378, reported by @IndividualGhost1905) — Statistics are now event-based, not file-based. The existing PrintLogEntry table gains six columns (archive_id, cost, energy_kwh, energy_cost, failure_reason, created_by_id); /archives/stats and /metrics sum from it. Each print completion writes a new row with the run's actual filament / time / cost / energy / status. The cost overwrite at
  usage_tracker.py:633 and energy overwrite at main.py:3625 now both preserve the source archive's first-run values on reprints; the run's actuals are stored on the PrintLogEntry row instead.

- Partial prints record accurate run filament (#1378 follow-up) — Failed / cancelled / stopped reprints no longer record the source archive's slicer estimate verbatim. New _compute_run_filament_grams helper prefers sum of tracked spool deltas, then falls back to estimate × progress%, then None — captured in 14 unit tests across every combination of status × inventory-tracked.

- Print log no longer 404-storms thumbnails for entries whose archive was deleted or whose print failed before extraction (#1348 follow-up) — Two-part fix: route self-heals on first 404 (NULL the cached path on the entry so subsequent renders skip the request) + eager NULL at archive-delete time so future deletes don't fire the one-time storm.

- Soft-deleted archive no longer leaves linked queue items silently stuck in pending forever (#1348 follow-up) — Pending queue items pointing at soft-deleted archives are now cancelled at delete time with waiting_reason="Source archive deleted". Queue API also suppresses the cached archive thumbnail / name / metadata when deleted_at is set, and the queue page's /plates query is gated on a new archive_deleted flag — three 404s per orphaned queue row are now zero.

**Camera**

- Camera stream no longer freezes every ~30s when Obico fault detection is enabled on the same printer (#1348, reported by @SL666) — Obico's _capture_frame was reusing the fan-out broadcaster's buffered frame when available, but falling through to a competing RTSP socket in race windows where the buffer was momentarily empty (stream startup, mid-reconnect). On X1-class firmware that allows only one camera connection, that second socket kicked the live viewer. New is_stream_active() helper now gates the fresh-socket fallback independently of the buffer state — when a viewer is connected, Obico never opens a competing socket.

**AMS / Inventory**

- Bare-tray empty-slot signal on P1S / A1 Mini (#1322 follow-up) — Genuinely-empty AMS slots on these printers send {"id": N} only (no state, no tray_type). The AMS parser now promotes this shape to state=9 so the inventory route's state ∈ {9, 10} short-circuit fires and we don't waste an ams_filament_setting publish that firmware would silently drop.

- AMS slot configuration lands cleanly for spools with no k-profile — The "configure" call no longer 422s when the spool's filament has no calibration profile entry. Affects a long tail of third-party / generic-PLA spools.

- AMS slot configuration lands on firmwares that never report state=11 (#1322 follow-up) — Some older firmwares never report the literal state=11 for loaded; the configure path's gate was too strict. Now treats absence of explicit empty (state ∈ {9, 10}) as loaded.

- Spool removal from AMS on X1C firmware that reports power_on_flag=False while idle (#1365, reported by @an3k) — Empty-slot detection now narrows the skip to zero-bits + power_on_flag=False (the shutdown shape from #765) instead of any power_on_flag=False. Spool pulls between prints now register without a manual Reconnect.

- AssignSpoolModal sits above the mobile sidebar drawer (#1336) — z-index fix; clicking Assign on mobile no longer opens the modal behind the drawer. 

- Catalog color's gradient + effect now applied, not just hex (#1340) — Picking a Bambu Lab gradient or sparkle entry from the colour picker now copies all three colour properties.

- Storage location persists for internal spools (#1291) — Local-mode inventory now writes the storage_location field on save (was Spoolman-only).

**Spoolman**

- AMS-HT range allowed in slot-assignment table (#1274) — The ams_id upper bound was hardcoded at 4; AMS-HT extends the range. Now matches the parser's range.

- External-spool ams_filament_setting uses global tray_id (#1279) — Was sending the local slot ID for external spools; firmware rejects.

- Persist color_name edits without round-tripping the subtype synth fallback (#1319) — Editing the colour name on a Spoolman spool no longer reverts after the next AMS push.

- Restore Spoolman spool ID search + Unassign button (#1336) — Two regressions from the Spoolman inventory UI work that landed in 0.2.4.

- Resolve -1 in ams_mapping to external spool (#1276) — Bambu's multi-color slicer convention; the queue dispatcher now interprets it correctly.

- Per-print 3MF tracking is the only weight writer (#1119) — Removes a competing path that double-wrote weights in Spoolman mode.

- External library lookup filtered by Bambu Lab manufacturer (PR #1330 by @ojimpo) — Stops cross-manufacturer matches polluting the Bambu library picker.

**Virtual Printer / Slicer**

- VP cache preserves AMS / vt_tray / net.info across incremental push_status updates (#1371, reported by @Andlar94) — Slicer no longer needs a printer power-cycle to see AMS info on a queue-mode VP. The bridge's _latest_print_state cache now preserves a small set of sticky keys (ams, vt_tray, ams_extruder_map, mapping, net, ipcam, lights_report) when an incremental push omits them — mirrors what Bambuddy already does for its own internal state.raw_data.

- VP emits FINISH after FTP upload so Print-flow slicers un-wedge (#1280) — BambuStudio's Print-flow path waits for FINISH before clearing its upload progress UI.

- VP broadcasts archive_created so Archives page refreshes live (#1282) — Slicing through the VP now updates the open Archives page without a manual refresh.

- VP queue-mode honours workflow default print options (#1235) — VP-dispatched prints now pick up the user's "Auto-Print start gcode" / "Auto-Off" / etc. defaults consistently.

- Slicer bundle import logs the sidecar's reject reason (#1312 follow-up) — Failed .bbscfg imports now show the upstream error in the Bambuddy log so users can diagnose without curling the sidecar.

**Scheduler / Dispatch**

- Watchdogs no longer falsely treat FINISH → IDLE as "print landed" (#1370, reported by @Martinnygaard) — Queue items dispatched onto a printer that was in FINISH (un-dismissed "Print complete" prompt from a prior job) used to stay stuck at printing forever. The post-dispatch verifier now narrows the "command landed" check to an allow-list of active-print states (PREPARE / SLICING / RUNNING / PAUSE).

- First RUNNING after Bambuddy boots no longer fires a phantom print-start (#1304) — Cold-boot of Bambuddy onto a printer that's mid-print no longer creates a stray archive at the boot moment.

**Camera**

- Plate-detection UI uses the external camera when configured (#1359) — Above under Improved.

- Layer timelapse for queue/VP-dispatched prints (#1353) — Above under Improved.

- Camera fan-out broadcaster buffered frame shared with Obico + /camera/snapshot (#1271) — Reuse path landed in 0.2.4; this cycle's #1348 fix completes the race-free version. Listed for completeness.

**Notifications / Backups**

- Discord webhook accepts legacy discordapp.com URLs (#1363, reported by @mrfoureyed) — Discord's Copy Webhook URL button still emits the legacy hostname; validation now accepts either.

- Backup tab indicator dot for scheduled backups (PR #1338 by @chanakyan-arivumani) — Visual cue when a backup is queued.

**Auth / LDAP / OIDC**

- Manually-assigned groups preserved across LDAP logins (#1292) — LDAP user re-login no longer wipes admin-assigned group memberships.

- Orphan OIDC / MFA rows cleaned up when user is deleted (PR #1295 by @netscout2001) — Deleting a user now cascades to their OIDC binding + TOTP secret rows.

- Password rules shown in user-create form + FE/BE checks aligned (#1303) — Frontend rejected passwords the backend would accept and vice versa; now both apply the same rules and the form shows them.

- External-scan STL thumbnails deferred + Path coerced (#1299) — External library scans no longer block on STL thumbnail rendering; mountpoints expressed as strings work alongside Path objects.

- MakerWorld settings link points to /profiles (#1300) — The "Open Cloud settings" link from the MakerWorld page now goes to the right tab.

**UI / Misc**

- Smart-plug live wattage rounded to whole watts on the printer card (#1266, reported by @Carter3DP) — Plugs reporting fractional watts (ESPHome / HA-bridged) no longer overflow the card.

- Settings UI rendering fields exposed without requiring SETTINGS_READ (#1293) — Non-admin users with narrower scopes can now load the Settings UI; the rendering-only fields (theme, locale) are no longer gated on admin-tier read.

- Bed-jog Z direction inverted on A1 / A1 Mini bed-slingers (#1334) — Up was down on bed-slinger printers; now matches the physical motion.

- Usage tracker: skip remain% fallback for trays not used by the print (#1269, reported by @maugsburger) — Swapping spools in unrelated AMS slots mid-print no longer charges the original spool the full estimate.

- Soft-deleted archives keep their Quick Stats contribution (#1343) — Was already there for archive-level totals; this release locks it in via the new PrintLogEntry event aggregation (#1378) which references log entries by ON DELETE SET NULL, so the contribution survives even a hard delete.

- scan_timelapse picked stale video at false offset (#1278) — Resolved.

---

**Security**

- urllib3 floor raised to 2.7.0 to clear CVE-2026-44431 and CVE-2026-44432. urllib3 is a transitive dependency (none of Bambuddy's top-level deps require >=2.7.0 yet), so the resolver was silently keeping the vulnerable 2.6.x line. requirements.txt now carries an explicit urllib3>=2.7.0 pin.

- Bandit suppression syntax corrected on two verify=False calls in support.py — the two local-sidecar reachability probes used # noqa: S501 (ruff syntax, ignored by bandit) instead of # nosec B501. The probes themselves are unchanged (no payload, no secrets — health-check only) but the local security scan now passes cleanly without false-positive high-severity findings.

---

**Contributors**

Big thanks to everyone who shipped code or filed reproducible bug reports this cycle:

Code: @netscout2001, @EdwardChamberlain, @chanakyan-arivumani, @ojimpo, @maziggy

Reproducible bug reports: @IndividualGhost1905, @SL666, @nicktags, @Andlar94, @RosdasHH, @an3k, @K1ngJony, @Martinnygaard, @Fuechslein, @mrfoureyed, @maugsburger, @Carter3DP

(See CHANGELOG.md for the full per-fix detail.)
MartinNYHC 1 week ago
parent
commit
6ebd6734d0
100 changed files with 8718 additions and 645 deletions
  1. 3 0
      CHANGELOG.md
  2. 10 0
      Dockerfile
  3. 2 1
      README.md
  4. 74 0
      backend/app/api/routes/_oidc_helpers.py
  5. 13 18
      backend/app/api/routes/_spoolman_helpers.py
  6. 51 0
      backend/app/api/routes/_url_safety.py
  7. 4 0
      backend/app/api/routes/api_keys.py
  8. 340 158
      backend/app/api/routes/archives.py
  9. 188 6
      backend/app/api/routes/auth.py
  10. 76 4
      backend/app/api/routes/camera.py
  11. 3 2
      backend/app/api/routes/cloud.py
  12. 108 70
      backend/app/api/routes/inventory.py
  13. 103 10
      backend/app/api/routes/library.py
  14. 10 8
      backend/app/api/routes/metrics.py
  15. 256 11
      backend/app/api/routes/mfa.py
  16. 9 0
      backend/app/api/routes/print_log.py
  17. 30 18
      backend/app/api/routes/print_queue.py
  18. 22 3
      backend/app/api/routes/printers.py
  19. 101 9
      backend/app/api/routes/settings.py
  20. 15 1
      backend/app/api/routes/slicer_presets.py
  21. 10 4
      backend/app/api/routes/spoolman.py
  22. 22 24
      backend/app/api/routes/spoolman_inventory.py
  23. 427 1
      backend/app/api/routes/support.py
  24. 26 1
      backend/app/api/routes/users.py
  25. 82 0
      backend/app/core/auth.py
  26. 1 1
      backend/app/core/config.py
  27. 206 2
      backend/app/core/database.py
  28. 130 15
      backend/app/main.py
  29. 5 0
      backend/app/models/api_key.py
  30. 7 0
      backend/app/models/archive.py
  31. 50 2
      backend/app/models/oidc_provider.py
  32. 14 1
      backend/app/models/print_log.py
  33. 8 1
      backend/app/models/spoolman_slot_assignment.py
  34. 3 0
      backend/app/schemas/api_key.py
  35. 9 0
      backend/app/schemas/archive.py
  36. 48 1
      backend/app/schemas/auth.py
  37. 6 0
      backend/app/schemas/print_log.py
  38. 7 0
      backend/app/schemas/print_queue.py
  39. 11 0
      backend/app/schemas/slicer.py
  40. 5 0
      backend/app/schemas/spool.py
  41. 130 3
      backend/app/services/archive.py
  42. 4 1
      backend/app/services/archive_comparison.py
  43. 21 1
      backend/app/services/background_dispatch.py
  44. 38 15
      backend/app/services/bambu_cloud.py
  45. 33 12
      backend/app/services/bambu_mqtt.py
  46. 136 30
      backend/app/services/firmware_check.py
  47. 14 2
      backend/app/services/firmware_update.py
  48. 209 65
      backend/app/services/ldap_service.py
  49. 9 4
      backend/app/services/makerworld.py
  50. 4 1
      backend/app/services/notification_service.py
  51. 25 0
      backend/app/services/obico_detection.py
  52. 177 0
      backend/app/services/oidc_icon.py
  53. 12 0
      backend/app/services/print_log.py
  54. 55 7
      backend/app/services/print_scheduler.py
  55. 40 7
      backend/app/services/printer_manager.py
  56. 11 0
      backend/app/services/slicer_api.py
  57. 47 10
      backend/app/services/spoolman.py
  58. 20 10
      backend/app/services/spoolman_tracking.py
  59. 6 0
      backend/app/services/stl_thumbnail.py
  60. 62 3
      backend/app/services/usage_tracker.py
  61. 54 2
      backend/app/services/virtual_printer/manager.py
  62. 38 1
      backend/app/services/virtual_printer/mqtt_bridge.py
  63. 9 5
      backend/app/services/virtual_printer/tailscale.py
  64. 0 0
      backend/tests/_fixtures/__init__.py
  65. 122 0
      backend/tests/_fixtures/oidc_icon.py
  66. 38 1
      backend/tests/conftest.py
  67. 209 0
      backend/tests/integration/test_archives_api.py
  68. 26 2
      backend/tests/integration/test_auth_api.py
  69. 161 0
      backend/tests/integration/test_camera_api.py
  70. 5 1
      backend/tests/integration/test_cost_statistics.py
  71. 230 58
      backend/tests/integration/test_inventory_assign.py
  72. 196 0
      backend/tests/integration/test_ldap_group_sync.py
  73. 369 0
      backend/tests/integration/test_ldap_provision.py
  74. 158 0
      backend/tests/integration/test_library_slice_api.py
  75. 49 0
      backend/tests/integration/test_mfa_api.py
  76. 736 0
      backend/tests/integration/test_oidc_icon_api.py
  77. 151 0
      backend/tests/integration/test_oidc_icon_blob_roundtrip.py
  78. 95 0
      backend/tests/integration/test_oidc_icon_deferred_load.py
  79. 299 0
      backend/tests/integration/test_oidc_relogin.py
  80. 82 0
      backend/tests/integration/test_print_queue_api.py
  81. 26 3
      backend/tests/integration/test_security.py
  82. 33 0
      backend/tests/integration/test_security_headers.py
  83. 208 0
      backend/tests/integration/test_settings_electricity_price.py
  84. 119 0
      backend/tests/integration/test_settings_ui_preferences.py
  85. 36 0
      backend/tests/integration/test_spoolman_inventory_api.py
  86. 19 15
      backend/tests/integration/test_spoolman_slot_assignment_mqtt.py
  87. 26 0
      backend/tests/integration/test_spoolman_slot_assignments.py
  88. 289 0
      backend/tests/integration/test_users_auth_cleanup.py
  89. 62 0
      backend/tests/unit/services/test_background_dispatch_watchdog.py
  90. 19 5
      backend/tests/unit/services/test_bambu_cloud.py
  91. 251 0
      backend/tests/unit/services/test_bambu_mqtt.py
  92. 192 1
      backend/tests/unit/services/test_ldap_service.py
  93. 21 5
      backend/tests/unit/services/test_makerworld.py
  94. 52 0
      backend/tests/unit/services/test_notification_service.py
  95. 96 0
      backend/tests/unit/services/test_printer_manager.py
  96. 229 0
      backend/tests/unit/services/test_spoolman_service.py
  97. 79 0
      backend/tests/unit/services/test_spoolman_tracking.py
  98. 29 0
      backend/tests/unit/services/test_stl_thumbnail.py
  99. 63 3
      backend/tests/unit/services/test_usage_tracker.py
  100. 294 0
      backend/tests/unit/services/test_virtual_printer.py

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


+ 10 - 0
Dockerfile

@@ -121,6 +121,16 @@ ENV HOME=/app
 ENV USER=bambuddy
 ENV LOGNAME=bambuddy
 
+# Matplotlib (imported lazily by the STL thumbnail generator) tries to create
+# its font/style cache at $HOME/.config/matplotlib on first import. /app is
+# root-owned and not writable by the PUID:PGID the entrypoint drops to,
+# which trips an EPERM warning in everyone's logs and forces matplotlib
+# to fall back to a per-restart temp dir (paying the font-scan cost on
+# every container restart). Pinning the cache dir to /tmp/matplotlib
+# silences the warning and keeps the cache alive for the container's
+# lifetime. /tmp is writable by any uid, so this works regardless of PUID.
+ENV MPLCONFIGDIR=/tmp/matplotlib
+
 EXPOSE 322
 EXPOSE 990
 EXPOSE 3000

+ 2 - 1
README.md

@@ -76,7 +76,7 @@ Two leading 3D-printing publications independently concluded that Bambuddy's fea
 **Print from anywhere in the world** — Bambuddy's new Proxy Mode acts as a secure relay between your slicer and printer:
 
 - 🔒 **End-to-end TLS encryption** — FTP, file transfer, and camera are transparently proxied with the printer's real TLS certificate
-- 🛡️ **Optional Tailscale integration** — per-VP toggle + Docker socket mount surface the host's Tailscale IP on the VP card, so you know which `100.x.x.x` to paste into the slicer when you want a virtual printer reachable over your tailnet ([setup](https://wiki.bambuddy.cool/features/virtual-printer/)). Bambuddy's self-signed CA import is still required for the slicer side — the Bambu Studio / OrcaSlicer printer-MQTT trust path uses a bundled BBL CA, not the system trust store, so even a publicly-trusted cert wouldn't help. Tailscale's role is the private tunnel (reachability from anywhere, no port forwarding), not cert-import elimination.
+- 🛡️ **Optional Tailscale integration** — per-VP toggle + Docker socket mount surface the host's Tailscale IP on the VP card, so you know which `100.x.x.x` to paste into the slicer when you want a virtual printer reachable over your tailnet ([setup](https://wiki.bambuddy.cool/features/virtual-printer/)). Bambuddy's self-signed CA import is still required on the slicer side: Bambu Studio / OrcaSlicer validate printer TLS against a bundled BBL CA (not the system trust store), **and** their Add Printer dialog is IP-only (no hostname to match an LE cert against), so a publicly-trusted cert can't help on either dimension. Tailscale's role is the private tunnel (reachability from anywhere, no port forwarding), not cert-import elimination.
 - 🌍 **No cloud dependency** — Direct connection through your own Bambuddy server
 - 🔑 **Uses printer's access code** — No additional credentials needed
 - ⚡ **Full-speed printing** — Transparent TCP proxy, only MQTT is decrypted for IP rewriting
@@ -127,6 +127,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 - Plate thumbnail browsing for multi-plate archives (hover to navigate between plates)
 - Archive comparison (side-by-side diff)
 - Tag management (rename/delete across all archives)
+- **Per-archive print history** — Each archive card shows an `N prints` badge whenever a model has been printed more than once (reprint + failed retries all counted). Click the badge for the full per-archive Print Log — every individual run with date, status, duration, filament used, cost, and failure reason. Reprints contribute new rows so a failed retry never overwrites the source archive's data — the original 100 g successful print stays visible alongside the 10 g failed reprint, and Quick Stats add up to 110 g across both events.
 - **Print Log** — Chronological table view of all print activity with columns for date/time, print name, printer, user, status, duration, and filament. Filterable by search, printer, user, status, and date range. Pagination with configurable page size. Clear button removes log entries without affecting archives.
 
 ### 📊 Monitoring & Control

+ 74 - 0
backend/app/api/routes/_oidc_helpers.py

@@ -0,0 +1,74 @@
+"""Pure helper functions for OIDC routes.
+
+Hosts the SSRF guard for admin-supplied icon URLs. Stricter than
+``_spoolman_helpers.assert_safe_spoolman_url`` — Spoolman intentionally allows
+loopback/RFC-1918 (same-LAN topology) while OIDC icons must be reachable on
+the public internet (IdP-hosted), so private addresses there are SSRF probes.
+"""
+
+from __future__ import annotations
+
+import ipaddress
+from urllib.parse import urlparse
+
+from backend.app.api.routes._url_safety import CLOUD_METADATA_IPS, NUMERIC_IP_RE, unwrap_ipv4_mapped
+
+
+def assert_safe_public_https_url(url: str) -> None:
+    """Raise ValueError if *url* is unsafe to fetch as a public HTTPS resource.
+
+    Used for OIDC provider icon URLs (#1333). Stricter than the Spoolman SSRF
+    guard: also rejects loopback, private (RFC-1918), and link-local addresses
+    because an OIDC icon legitimately lives only on the public internet.
+
+    Checks performed:
+    - Scheme must be ``https`` (no ``http://``, ``file://``, ``gopher://``, …).
+    - Numeric-encoded IPv4 (decimal ``2130706433``, hex ``0x7f000001``) is
+      rejected — libc and browsers parse those as valid addresses while
+      Python's ``ipaddress`` raises ValueError, so they bypass the IP block
+      below if not caught first.
+    - Cloud-provider metadata endpoints (169.254.169.254, 100.100.100.200,
+      fd00:ec2::254) — classic SSRF credential-exfil targets.
+    - Loopback (127.0.0.0/8, ::1), private RFC-1918 (10/8, 172.16/12,
+      192.168/16) and link-local (169.254/16, fe80::/10) addresses.
+    - Multicast (224.0.0.0/4, ff00::/8) and unspecified (0.0.0.0, ::).
+    - IPv4-mapped IPv6 (``::ffff:127.0.0.1``) — unwrapped before the IP-class
+      check so an attacker can't bypass via IPv6 encoding.
+
+    Hostname-based addresses are accepted without DNS resolution (consistent
+    with ``_validate_issuer_url`` policy — the operator is trusted to
+    configure a sensible IdP host).
+    """
+    parsed = urlparse(url)
+    if parsed.scheme.lower() != "https":
+        raise ValueError("icon URL must use https://")
+
+    hostname = (parsed.hostname or "").lower()
+
+    if NUMERIC_IP_RE.match(hostname):
+        raise ValueError("icon URL must not use numeric-encoded IP addresses")
+
+    try:
+        addr = ipaddress.ip_address(hostname)
+    except ValueError:
+        return  # hostname — out of scope (no DNS check by design)
+
+    effective = unwrap_ipv4_mapped(addr)
+
+    if effective in CLOUD_METADATA_IPS:
+        raise ValueError("icon URL must not point to a cloud metadata endpoint")
+
+    # Order matters: 0.0.0.0 sets BOTH is_private and is_unspecified — check
+    # the more-specific is_unspecified first so the error message points at
+    # the actual misuse. Similarly 127.0.0.1 sets is_loopback and is_private
+    # (private under IANA's reservation); is_loopback first is clearer.
+    if effective.is_unspecified:
+        raise ValueError("icon URL must not point to an unspecified address")
+    if effective.is_loopback:
+        raise ValueError("icon URL must not point to a loopback address")
+    if effective.is_link_local:
+        raise ValueError("icon URL must not point to a link-local address")
+    if effective.is_multicast:
+        raise ValueError("icon URL must not point to a multicast address")
+    if effective.is_private:
+        raise ValueError("icon URL must not point to a private (RFC-1918) address")

+ 13 - 18
backend/app/api/routes/_spoolman_helpers.py

@@ -15,6 +15,8 @@ from urllib.parse import urlparse
 
 from typing_extensions import TypedDict
 
+from backend.app.api.routes._url_safety import CLOUD_METADATA_IPS, NUMERIC_IP_RE, unwrap_ipv4_mapped
+
 logger = logging.getLogger(__name__)
 
 
@@ -26,6 +28,7 @@ class MappedSpoolFields(TypedDict):
     subtype: str | None
     brand: str | None
     color_name: str | None
+    color_name_is_synthesized: bool
     rgba: str | None
     label_weight: int | None
     core_weight: int | None
@@ -74,18 +77,6 @@ class NormalizedFilament(TypedDict):
     vendor: NormalizedVendorRef | None
 
 
-_CLOUD_METADATA_IPS = frozenset(
-    {
-        # AWS / GCP / Azure / Oracle / DigitalOcean IMDS
-        ipaddress.ip_address("169.254.169.254"),
-        # Alibaba Cloud metadata
-        ipaddress.ip_address("100.100.100.200"),
-        # AWS IMDS IPv6
-        ipaddress.ip_address("fd00:ec2::254"),
-    }
-)
-
-
 def assert_safe_spoolman_url(url: str) -> None:
     """Raise ValueError if *url* should be blocked as an SSRF risk.
 
@@ -120,7 +111,7 @@ def assert_safe_spoolman_url(url: str) -> None:
     # Reject decimal- and hex-encoded IPs (e.g. http://2130706433/ or
     # http://0x7f000001/). These slip past ipaddress.ip_address() but libc
     # (and browsers) parse them as IPv4 — an obvious bypass if not caught.
-    if re.match(r"^(0x[0-9a-f]+|[0-9]+)$", hostname, re.I):
+    if NUMERIC_IP_RE.match(hostname):
         raise ValueError("Spoolman URL must not use numeric-encoded IP addresses; use standard dotted-decimal notation")
 
     try:
@@ -135,11 +126,9 @@ def assert_safe_spoolman_url(url: str) -> None:
 
     # Unwrap IPv4-mapped IPv6 (::ffff:169.254.169.254 etc.) so attackers can't
     # encode a blocked IPv4 into an IPv6 literal to bypass the check.
-    effective: ipaddress.IPv4Address | ipaddress.IPv6Address = addr
-    if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
-        effective = addr.ipv4_mapped
+    effective = unwrap_ipv4_mapped(addr)
 
-    if effective in _CLOUD_METADATA_IPS:
+    if effective in CLOUD_METADATA_IPS:
         raise ValueError("Spoolman URL must not point to a cloud metadata endpoint")
 
     if effective.is_multicast or effective.is_unspecified:
@@ -276,7 +265,12 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
     # prefix (the same string the `subtype` field already carries — typically
     # "Basic Red" / "PLA+ Black" / etc.) so the user can tell spools apart at
     # a glance even on Spoolman installs that don't fill color_name.
-    color_name: str | None = filament.get("color_name") or subtype or None
+    # color_name_is_synthesized: surfaced so the edit form can avoid prefilling
+    # the synth value back into the input, which would otherwise round-trip the
+    # subtype string as if it were a user-set color_name (#1319).
+    stored_color_name = filament.get("color_name") or None
+    color_name: str | None = stored_color_name or subtype or None
+    color_name_is_synthesized: bool = stored_color_name is None and color_name is not None
 
     nozzle_temp_raw = filament.get("settings_extruder_temp")
     nozzle_temp_min: int | None = _safe_int(nozzle_temp_raw, 0) or None
@@ -286,6 +280,7 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
         "material": material,
         "subtype": subtype,
         "color_name": color_name,
+        "color_name_is_synthesized": color_name_is_synthesized,
         "rgba": rgba,
         "brand": vendor.get("name") or None,
         "label_weight": label_weight,

+ 51 - 0
backend/app/api/routes/_url_safety.py

@@ -0,0 +1,51 @@
+"""Shared URL-safety primitives used by both SSRF guards in this package.
+
+The two top-level assertion functions —
+``_spoolman_helpers.assert_safe_spoolman_url`` (Spoolman, deliberately allows
+loopback/RFC-1918 because same-LAN deployment is the standard topology) and
+``_oidc_helpers.assert_safe_public_https_url`` (OIDC icons, must be reachable
+on the public internet, so loopback/private are rejected) — share the
+*data* (cloud-metadata IP set, numeric-encoded-IP regex) but not the
+*policy*. Only the data lives here. The functions stay in their respective
+modules with their distinct policies intact.
+"""
+
+from __future__ import annotations
+
+import ipaddress
+import re
+
+# Cloud-provider metadata endpoints — the classic SSRF credential-exfil
+# targets. Both guards reject these unconditionally.
+CLOUD_METADATA_IPS = frozenset(
+    {
+        # AWS / GCP / Azure / Oracle / DigitalOcean IMDS
+        ipaddress.ip_address("169.254.169.254"),
+        # Alibaba Cloud metadata
+        ipaddress.ip_address("100.100.100.200"),
+        # AWS IMDS IPv6
+        ipaddress.ip_address("fd00:ec2::254"),
+    }
+)
+
+
+# libc and browsers parse numeric-encoded IP forms (decimal ``2130706433``
+# for 127.0.0.1, hex ``0x7f000001``) but Python's ``ipaddress.ip_address``
+# raises ValueError on these, so they slip past the IP-class checks if
+# not caught first. Used by both guards to reject up-front.
+NUMERIC_IP_RE = re.compile(r"^(0x[0-9a-f]+|[0-9]+)$", re.I)
+
+
+def unwrap_ipv4_mapped(
+    addr: ipaddress.IPv4Address | ipaddress.IPv6Address,
+) -> ipaddress.IPv4Address | ipaddress.IPv6Address:
+    """Return the underlying IPv4 for an IPv4-mapped IPv6 address, else return *addr*.
+
+    ``::ffff:127.0.0.1`` and similar mapped forms must be unwrapped before
+    the per-class checks (``is_private``, ``is_loopback``, …) — otherwise
+    an attacker can encode a blocked IPv4 address as an IPv6 literal to
+    bypass the guard.
+    """
+    if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
+        return addr.ipv4_mapped
+    return addr

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

@@ -64,6 +64,7 @@ async def create_api_key(
         can_control_printer=data.can_control_printer,
         can_read_status=data.can_read_status,
         can_access_cloud=data.can_access_cloud,
+        can_update_energy_cost=data.can_update_energy_cost,
         printer_ids=data.printer_ids,
         expires_at=data.expires_at,
     )
@@ -82,6 +83,7 @@ async def create_api_key(
         can_control_printer=api_key.can_control_printer,
         can_read_status=api_key.can_read_status,
         can_access_cloud=api_key.can_access_cloud,
+        can_update_energy_cost=api_key.can_update_energy_cost,
         printer_ids=api_key.printer_ids,
         enabled=api_key.enabled,
         last_used=api_key.last_used,
@@ -138,6 +140,8 @@ async def update_api_key(
                 detail="can_access_cloud requires the API key to have an owner; recreate the key after upgrading",
             )
         api_key.can_access_cloud = data.can_access_cloud
+    if data.can_update_energy_cost is not None:
+        api_key.can_update_energy_cost = data.can_update_energy_cost
     if data.printer_ids is not None:
         api_key.printer_ids = data.printer_ids
     if data.enabled is not None:

+ 340 - 158
backend/app/api/routes/archives.py

@@ -1,15 +1,16 @@
 import io
 import json
 import logging
+import re as _re
 import zipfile
 from collections import defaultdict
-from datetime import date, datetime, time, timezone
+from datetime import date, datetime, time, timedelta, timezone
 from decimal import ROUND_HALF_UP, Decimal
 from pathlib import Path
 
 from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
 from fastapi.responses import FileResponse, Response
-from sqlalchemy import and_, func, or_, select
+from sqlalchemy import and_, case, func, or_, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import (
@@ -25,6 +26,7 @@ from backend.app.models.filament import Filament
 from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.models.user import User
 from backend.app.schemas.archive import ArchiveResponse, ArchiveSlim, ArchiveStats, ArchiveUpdate, ReprintRequest
+from backend.app.schemas.print_log import PrintLogResponse
 from backend.app.schemas.slicer import SliceRequest
 from backend.app.services.archive import ArchiveService
 from backend.app.utils.http import build_content_disposition
@@ -48,6 +50,74 @@ def _safe_filename(filename: str) -> str:
     return Path(filename.replace("\\", "/")).name
 
 
+_TIMELAPSE_FILENAME_TS_RE = _re.compile(r"(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})")
+_DEFAULT_TIMELAPSE_OFFSETS_HOURS: tuple[int, ...] = (0, 8, -8, 7, -7, 1, -1)
+_DEFAULT_TIMELAPSE_TOLERANCE = timedelta(hours=4)
+_DEFAULT_TIMELAPSE_AMBIGUITY_MARGIN = timedelta(minutes=15)
+
+
+def _match_timelapse_by_timestamp(
+    video_files: list[dict],
+    archive_start: datetime | None,
+    *,
+    tolerance: timedelta = _DEFAULT_TIMELAPSE_TOLERANCE,
+    ambiguity_margin: timedelta = _DEFAULT_TIMELAPSE_AMBIGUITY_MARGIN,
+    offsets_hours: tuple[int, ...] = _DEFAULT_TIMELAPSE_OFFSETS_HOURS,
+) -> tuple[dict | None, timedelta | None]:
+    """Pick the timelapse whose filename timestamp best matches the print start time.
+
+    Bambu timelapse filenames embed the printer-local START time (e.g.
+    "video_2026-05-08_09-41-29.mp4"). The printer's clock may be offset from the
+    server's — especially in LAN-Only mode where NTP is unreachable — so we try a
+    small set of common UTC offsets and keep the (video, offset) pair with the
+    smallest absolute distance from archive_start. We deliberately do NOT consider
+    archive_end here: the filename is start time, not end time, so comparing it to
+    completion is not a real signal (Strategy 3 handles end via file mtime).
+
+    Because the offset list densely covers a wide span, an unrelated video's
+    filename can coincidentally land near a later print's start at some offset.
+    To avoid that false positive, we require the best (video, offset) pair to
+    beat the next-best pair *from a different video* by at least `ambiguity_margin`.
+    When the top two candidates from different videos are too close to call,
+    we return None and let the caller fall back to manual selection.
+    """
+    if archive_start is None:
+        return None, None
+
+    # (diff, video) for every (video, offset) pair within tolerance.
+    candidates: list[tuple[timedelta, dict]] = []
+
+    for f in video_files:
+        fname = f.get("name", "")
+        m = _TIMELAPSE_FILENAME_TS_RE.search(fname)
+        if not m:
+            continue
+        try:
+            file_time = datetime.strptime(m.group(1), "%Y-%m-%d_%H-%M-%S")
+        except ValueError:
+            continue
+
+        for hour_offset in offsets_hours:
+            adjusted = file_time - timedelta(hours=hour_offset)
+            diff = abs(adjusted - archive_start)
+            if diff <= tolerance:
+                candidates.append((diff, f))
+
+    if not candidates:
+        return None, None
+
+    candidates.sort(key=lambda c: c[0])
+    best_diff, best_video = candidates[0]
+    best_name = best_video.get("name")
+
+    for diff, video in candidates[1:]:
+        if video.get("name") != best_name and (diff - best_diff) < ambiguity_margin:
+            # Another video matches almost as well — refuse to auto-pick.
+            return None, None
+
+    return best_video, best_diff
+
+
 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:
@@ -67,6 +137,17 @@ def _apply_user_filter(conditions: list, created_by_id: int | None):
             conditions.append(PrintArchive.created_by_id == created_by_id)
 
 
+def _apply_run_user_filter(conditions: list, created_by_id: int | None):
+    """Append created_by_id filter scoped to PrintLogEntry rows."""
+    from backend.app.models.print_log import PrintLogEntry
+
+    if created_by_id is not None:
+        if created_by_id == -1:
+            conditions.append(PrintLogEntry.created_by_id.is_(None))
+        else:
+            conditions.append(PrintLogEntry.created_by_id == created_by_id)
+
+
 def compute_time_accuracy(archive: PrintArchive) -> dict:
     """Compute actual print time and accuracy for an archive.
 
@@ -94,12 +175,48 @@ def compute_time_accuracy(archive: PrintArchive) -> dict:
     return result
 
 
+async def _load_run_aggregates(db: AsyncSession, archive_ids: list[int]) -> dict[int, dict]:
+    """Batch-load per-archive run aggregates from PrintLogEntry.
+
+    Returns ``{archive_id: {run_count, last_run_at, total_filament_actual_grams,
+    successful_run_count, failed_run_count}}``. Archives with no logged runs are
+    absent from the map; callers should treat that as zero/none.
+    """
+    from backend.app.models.print_log import PrintLogEntry
+
+    if not archive_ids:
+        return {}
+    rows = await db.execute(
+        select(
+            PrintLogEntry.archive_id,
+            func.count(PrintLogEntry.id).label("run_count"),
+            func.max(PrintLogEntry.started_at).label("last_run_at"),
+            func.coalesce(func.sum(PrintLogEntry.filament_used_grams), 0).label("total_filament"),
+            func.sum(case((PrintLogEntry.status == "completed", 1), else_=0)).label("successful"),
+            func.sum(case((PrintLogEntry.status == "failed", 1), else_=0)).label("failed"),
+        )
+        .where(PrintLogEntry.archive_id.in_(archive_ids))
+        .group_by(PrintLogEntry.archive_id)
+    )
+    aggregates: dict[int, dict] = {}
+    for archive_id, run_count, last_run_at, total_filament, successful, failed in rows.all():
+        aggregates[archive_id] = {
+            "run_count": int(run_count or 0),
+            "last_run_at": last_run_at,
+            "total_filament_actual_grams": float(total_filament) if total_filament else None,
+            "successful_run_count": int(successful or 0),
+            "failed_run_count": int(failed or 0),
+        }
+    return aggregates
+
+
 def archive_to_response(
     archive: PrintArchive,
     duplicates: list[dict] | None = None,
     duplicate_count: int = 0,
     duplicate_sequence: int = 0,
     original_archive_id: int | None = None,
+    run_aggregate: dict | None = None,
 ) -> dict:
     """Convert archive model to response dict with computed fields."""
     data = {
@@ -157,6 +274,13 @@ def archive_to_response(
     accuracy_data = compute_time_accuracy(archive)
     data.update(accuracy_data)
 
+    if run_aggregate:
+        data["run_count"] = run_aggregate.get("run_count", 0)
+        data["last_run_at"] = run_aggregate.get("last_run_at")
+        data["total_filament_actual_grams"] = run_aggregate.get("total_filament_actual_grams")
+        data["successful_run_count"] = run_aggregate.get("successful_run_count", 0)
+        data["failed_run_count"] = run_aggregate.get("failed_run_count", 0)
+
     return data
 
 
@@ -214,7 +338,7 @@ async def list_archives(
                 PrintArchive.created_at,
                 PrintArchive.content_hash,
                 func.lower(PrintArchive.print_name).label("print_name_lower"),
-            ).where(or_(*duplicate_group_conditions))
+            ).where(or_(*duplicate_group_conditions), PrintArchive.deleted_at.is_(None))
         )
 
         duplicate_groups_by_hash: dict[str, list[tuple[int, datetime]]] = defaultdict(list)
@@ -249,6 +373,8 @@ async def list_archives(
             for sequence, (archive_id, _) in enumerate(group):
                 duplicate_meta_by_archive_id.setdefault(archive_id, (sequence, original_id, duplicate_count))
 
+    run_aggregates = await _load_run_aggregates(db, [a.id for a in archives])
+
     # Build response with duplicate sequence and original archive ID pre-computed
     result = []
     for a in archives:
@@ -273,6 +399,7 @@ async def list_archives(
                 duplicate_count=duplicate_count,
                 duplicate_sequence=duplicate_sequence,
                 original_archive_id=original_archive_id,
+                run_aggregate=run_aggregates.get(a.id),
             )
         )
     return result
@@ -415,12 +542,15 @@ async def search_archives(
             select(PrintArchive)
             .options(selectinload(PrintArchive.project))
             .where(
-                (PrintArchive.print_name.ilike(like_pattern))
-                | (PrintArchive.filename.ilike(like_pattern))
-                | (PrintArchive.tags.ilike(like_pattern))
-                | (PrintArchive.notes.ilike(like_pattern))
-                | (PrintArchive.designer.ilike(like_pattern))
-                | (PrintArchive.filament_type.ilike(like_pattern))
+                (
+                    (PrintArchive.print_name.ilike(like_pattern))
+                    | (PrintArchive.filename.ilike(like_pattern))
+                    | (PrintArchive.tags.ilike(like_pattern))
+                    | (PrintArchive.notes.ilike(like_pattern))
+                    | (PrintArchive.designer.ilike(like_pattern))
+                    | (PrintArchive.filament_type.ilike(like_pattern))
+                ),
+                PrintArchive.deleted_at.is_(None),
             )
             .order_by(PrintArchive.created_at.desc())
         )
@@ -440,8 +570,12 @@ async def search_archives(
     if not matched_ids:
         return []
 
-    # Fetch full archive records for matched IDs
-    query = select(PrintArchive).options(selectinload(PrintArchive.project)).where(PrintArchive.id.in_(matched_ids))
+    # Fetch full archive records for matched IDs (excluding soft-deleted, #1343)
+    query = (
+        select(PrintArchive)
+        .options(selectinload(PrintArchive.project))
+        .where(PrintArchive.id.in_(matched_ids), PrintArchive.deleted_at.is_(None))
+    )
 
     # Apply additional filters
     if printer_id:
@@ -686,69 +820,75 @@ async def get_archive_stats(
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
 ):
-    """Get statistics across all archives."""
+    """Get statistics across all archives.
+
+    Stats aggregate over PrintLogEntry (one row per print event), not over
+    PrintArchive (one row per file). A reprint contributes a new PrintLogEntry
+    so its filament/cost/time/energy add to the totals instead of overwriting
+    the source archive's first-run values (#1378).
+    """
+    from backend.app.models.print_log import PrintLogEntry
+
     _validate_user_filter_permission(current_user, created_by_id)
 
-    # Build date filter conditions
+    # Build date filter conditions scoped to PrintLogEntry (event-time).
     base_conditions = []
     if date_from:
         dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
-        base_conditions.append(PrintArchive.created_at >= dt_from)
+        base_conditions.append(PrintLogEntry.created_at >= dt_from)
     if date_to:
         dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
-        base_conditions.append(PrintArchive.created_at <= dt_to)
-    _apply_user_filter(base_conditions, created_by_id)
+        base_conditions.append(PrintLogEntry.created_at <= dt_to)
+    _apply_run_user_filter(base_conditions, created_by_id)
 
-    # Total counts
-    total_result = await db.execute(select(func.count(PrintArchive.id)).where(*base_conditions))
+    # Total counts (one row per print event).
+    total_result = await db.execute(select(func.count(PrintLogEntry.id)).where(*base_conditions))
     total_prints = total_result.scalar() or 0
 
     successful_result = await db.execute(
-        select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed", *base_conditions)
+        select(func.count(PrintLogEntry.id)).where(PrintLogEntry.status == "completed", *base_conditions)
     )
     successful_prints = successful_result.scalar() or 0
 
     failed_result = await db.execute(
-        select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed", *base_conditions)
+        select(func.count(PrintLogEntry.id)).where(PrintLogEntry.status == "failed", *base_conditions)
     )
     failed_prints = failed_result.scalar() or 0
 
-    # Totals - use actual print time from timestamps (not slicer estimates)
-    # For archives with both started_at and completed_at, calculate actual duration
-    # Fall back to print_time_seconds only for archives without timestamps
-    archives_for_time = await db.execute(
-        select(PrintArchive.started_at, PrintArchive.completed_at, PrintArchive.print_time_seconds).where(
-            *base_conditions
-        )
+    # Total elapsed time — PrintLogEntry stores duration_seconds directly so we
+    # can sum it server-side. Rows missing duration fall back to the slicer
+    # estimate from the archive (joined for that case only).
+    time_rows = await db.execute(
+        select(
+            PrintLogEntry.duration_seconds,
+            PrintLogEntry.started_at,
+            PrintLogEntry.completed_at,
+        ).where(*base_conditions)
     )
     total_seconds = 0
-    for started_at, completed_at, print_time_seconds in archives_for_time.all():
-        if started_at and completed_at:
-            # Use actual elapsed time
-            actual_seconds = (completed_at - started_at).total_seconds()
-            if actual_seconds > 0:
-                total_seconds += actual_seconds
-        elif print_time_seconds:
-            # Fallback to estimate only if no timestamps
-            total_seconds += print_time_seconds
+    for duration_seconds, started_at, completed_at in time_rows.all():
+        if duration_seconds:
+            total_seconds += duration_seconds
+        elif started_at and completed_at:
+            elapsed = (completed_at - started_at).total_seconds()
+            if elapsed > 0:
+                total_seconds += int(elapsed)
     total_time = total_seconds / 3600  # Convert to hours
 
-    # Sum filament directly - filament_used_grams already contains the total for the print job
     filament_result = await db.execute(
-        select(func.coalesce(func.sum(PrintArchive.filament_used_grams), 0)).where(*base_conditions)
+        select(func.coalesce(func.sum(PrintLogEntry.filament_used_grams), 0)).where(*base_conditions)
     )
     total_filament = filament_result.scalar() or 0
 
-    cost_result = await db.execute(select(func.sum(PrintArchive.cost)).where(*base_conditions))
+    cost_result = await db.execute(select(func.sum(PrintLogEntry.cost)).where(*base_conditions))
     total_cost = cost_result.scalar() or 0
 
     # By filament type (split comma-separated values for multi-material prints)
     filament_type_result = await db.execute(
-        select(PrintArchive.filament_type).where(PrintArchive.filament_type.isnot(None), *base_conditions)
+        select(PrintLogEntry.filament_type).where(PrintLogEntry.filament_type.isnot(None), *base_conditions)
     )
     prints_by_filament: dict[str, int] = {}
     for (filament_types,) in filament_type_result.all():
-        # Split by comma and count each type
         for ftype in filament_types.split(","):
             ftype = ftype.strip()
             if ftype:
@@ -756,47 +896,49 @@ async def get_archive_stats(
 
     # By printer
     printer_result = await db.execute(
-        select(PrintArchive.printer_id, func.count(PrintArchive.id))
+        select(PrintLogEntry.printer_id, func.count(PrintLogEntry.id))
         .where(*base_conditions)
-        .group_by(PrintArchive.printer_id)
+        .group_by(PrintLogEntry.printer_id)
     )
     prints_by_printer = {str(k): v for k, v in printer_result.all()}
 
-    # Time accuracy statistics
-    # Get all completed archives with both estimated and actual times
-    accuracy_result = await db.execute(
-        select(PrintArchive)
-        .where(PrintArchive.status == "completed", *base_conditions)
-        .where(PrintArchive.print_time_seconds.isnot(None))
-        .where(PrintArchive.started_at.isnot(None))
-        .where(PrintArchive.completed_at.isnot(None))
+    # Time accuracy — compare each completed run's actual duration to the
+    # slicer's estimate on the linked archive. Runs without a linked archive
+    # (NULL archive_id) or without an estimate are excluded.
+    accuracy_rows = await db.execute(
+        select(
+            PrintLogEntry.duration_seconds,
+            PrintLogEntry.started_at,
+            PrintLogEntry.completed_at,
+            PrintLogEntry.printer_id,
+            PrintArchive.print_time_seconds,
+        )
+        .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
+        .where(
+            PrintLogEntry.status == "completed",
+            PrintArchive.print_time_seconds.isnot(None),
+            *base_conditions,
+        )
     )
-    archives_with_times = list(accuracy_result.scalars().all())
-
     average_accuracy = None
     accuracy_by_printer: dict[str, float] = {}
-
-    if archives_with_times:
-        accuracies = []
-        printer_accuracies: dict[str, list[float]] = {}
-
-        for archive in archives_with_times:
-            acc_data = compute_time_accuracy(archive)
-            if acc_data["time_accuracy"] is not None:
-                accuracies.append(acc_data["time_accuracy"])
-
-                # Group by printer
-                printer_key = str(archive.printer_id) if archive.printer_id else "unknown"
-                if printer_key not in printer_accuracies:
-                    printer_accuracies[printer_key] = []
-                printer_accuracies[printer_key].append(acc_data["time_accuracy"])
-
-        if accuracies:
-            average_accuracy = round(sum(accuracies) / len(accuracies), 1)
-
-        # Calculate per-printer averages
-        for printer_key, accs in printer_accuracies.items():
-            accuracy_by_printer[printer_key] = round(sum(accs) / len(accs), 1)
+    accuracies: list[float] = []
+    printer_accuracies: dict[str, list[float]] = {}
+    for duration_seconds, started_at, completed_at, run_printer_id, estimate_seconds in accuracy_rows.all():
+        actual_seconds = duration_seconds
+        if not actual_seconds and started_at and completed_at:
+            elapsed = (completed_at - started_at).total_seconds()
+            actual_seconds = int(elapsed) if elapsed > 0 else None
+        if not actual_seconds or not estimate_seconds:
+            continue
+        accuracy = (estimate_seconds / actual_seconds) * 100
+        accuracies.append(accuracy)
+        printer_key = str(run_printer_id) if run_printer_id else "unknown"
+        printer_accuracies.setdefault(printer_key, []).append(accuracy)
+    if accuracies:
+        average_accuracy = round(sum(accuracies) / len(accuracies), 1)
+    for printer_key, accs in printer_accuracies.items():
+        accuracy_by_printer[printer_key] = round(sum(accs) / len(accs), 1)
 
     # Energy totals - check which mode to use
     from backend.app.api.routes.settings import get_setting
@@ -823,11 +965,11 @@ async def get_archive_stats(
         )
         total_energy_cost = total_energy_kwh * energy_cost_per_kwh
     else:
-        # Per-print mode: sum the per-print energy column directly.
-        energy_kwh_result = await db.execute(select(func.sum(PrintArchive.energy_kwh)).where(*base_conditions))
+        # Per-print mode: sum the per-run energy column from PrintLogEntry.
+        energy_kwh_result = await db.execute(select(func.sum(PrintLogEntry.energy_kwh)).where(*base_conditions))
         total_energy_kwh = energy_kwh_result.scalar() or 0
 
-        energy_cost_result = await db.execute(select(func.sum(PrintArchive.energy_cost)).where(*base_conditions))
+        energy_cost_result = await db.execute(select(func.sum(PrintLogEntry.energy_cost)).where(*base_conditions))
         total_energy_cost = energy_cost_result.scalar() or 0
 
     return ArchiveStats(
@@ -977,7 +1119,9 @@ async def get_all_tags(
     Returns a list of tags sorted by count (descending), then by name.
     """
     # Query all archives with non-null tags
-    result = await db.execute(select(PrintArchive.tags).where(PrintArchive.tags.isnot(None)))
+    result = await db.execute(
+        select(PrintArchive.tags).where(PrintArchive.tags.isnot(None), PrintArchive.deleted_at.is_(None))
+    )
     all_tags_rows = result.all()
 
     # Count occurrences of each tag
@@ -1018,7 +1162,9 @@ async def rename_tag(
         return {"affected": 0}
 
     # Find all archives containing the old tag
-    result = await db.execute(select(PrintArchive).where(PrintArchive.tags.isnot(None)))
+    result = await db.execute(
+        select(PrintArchive).where(PrintArchive.tags.isnot(None), PrintArchive.deleted_at.is_(None))
+    )
     archives = list(result.scalars().all())
 
     affected = 0
@@ -1054,7 +1200,9 @@ async def delete_tag(
     Returns the count of affected archives.
     """
     # Find all archives containing the tag
-    result = await db.execute(select(PrintArchive).where(PrintArchive.tags.isnot(None)))
+    result = await db.execute(
+        select(PrintArchive).where(PrintArchive.tags.isnot(None), PrintArchive.deleted_at.is_(None))
+    )
     archives = list(result.scalars().all())
 
     affected = 0
@@ -1081,7 +1229,11 @@ async def get_archive(
     """Get a specific archive."""
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
-    if not archive:
+    # 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")
 
     # Find duplicates
@@ -1092,7 +1244,35 @@ async def get_archive(
         print_name=archive.print_name,
         makerworld_model_id=makerworld_id,
     )
-    return archive_to_response(archive, duplicates)
+    run_aggregates = await _load_run_aggregates(db, [archive.id])
+    return archive_to_response(archive, duplicates, run_aggregate=run_aggregates.get(archive.id))
+
+
+@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),
+):
+    """List PrintLogEntry rows for this archive — one per print event.
+
+    Newest first. Drives the per-archive "Print Log" view (#1378).
+    """
+    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")
+
+    rows = await db.execute(
+        select(PrintLogEntry)
+        .where(PrintLogEntry.archive_id == archive_id)
+        .order_by(PrintLogEntry.started_at.desc().nulls_last(), PrintLogEntry.id.desc())
+    )
+    entries = list(rows.scalars().all())
+    items = [PrintLogEntrySchema.model_validate(e, from_attributes=True) for e in entries]
+    return PrintLogResponse(items=items, total=len(items))
 
 
 @router.get("/{archive_id}/similar")
@@ -1230,15 +1410,29 @@ async def rescan_archive(
     if metadata.get("designer"):
         archive.designer = metadata["designer"]
 
-    # Calculate cost: prefer spool-based cost if available, else catalog-based
+    # Calculate cost: prefer spool-based cost if available, else catalog-based.
+    # When spool-based costs exist but don't cover every filament gram used
+    # (#1344), fall back to the global default rate for the untracked weight
+    # so the displayed cost still reflects the whole print.
 
     if archive.filament_used_grams and archive.filament_type:
+        default_cost_setting = await get_setting(db, "default_filament_cost")
+        default_cost_per_kg = float(default_cost_setting) if default_cost_setting else 25.0
         usage_result = await db.execute(
-            select(func.sum(SpoolUsageHistory.cost)).where(SpoolUsageHistory.archive_id == archive.id)
+            select(
+                func.sum(SpoolUsageHistory.cost),
+                func.sum(SpoolUsageHistory.weight_used),
+            ).where(SpoolUsageHistory.archive_id == archive.id)
         )
-        usage_cost = usage_result.scalar()
+        usage_cost_row = usage_result.one()
+        usage_cost = usage_cost_row[0]
+        tracked_grams = float(usage_cost_row[1] or 0)
         if usage_cost is not None and usage_cost > 0:
-            archive.cost = float(Decimal(str(usage_cost)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
+            total_cost = float(usage_cost)
+            untracked_grams = max(0.0, archive.filament_used_grams - tracked_grams)
+            if untracked_grams > 0 and default_cost_per_kg > 0:
+                total_cost += (untracked_grams / 1000.0) * default_cost_per_kg
+            archive.cost = float(Decimal(str(total_cost)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
         else:
             primary_type = archive.filament_type.split(",")[0].strip()
             filament_result = await db.execute(select(Filament).where(Filament.type == primary_type).limit(1))
@@ -1250,9 +1444,6 @@ async def rescan_archive(
                     )
                 )
             else:
-                # Use default filament cost from settings
-                default_cost_setting = await get_setting(db, "default_filament_cost")
-                default_cost_per_kg = float(default_cost_setting) if default_cost_setting else 25.0
                 archive.cost = float(
                     Decimal(str((archive.filament_used_grams / 1000) * default_cost_per_kg)).quantize(
                         Decimal("0.01"), rounding=ROUND_HALF_UP
@@ -1284,18 +1475,34 @@ async def recalculate_all_costs(
     default_cost_setting = await get_setting(db, "default_filament_cost")
     default_cost_per_kg = float(default_cost_setting) if default_cost_setting else 25.0
 
-    # Pre-fetch all usage costs by archive_id
+    # Pre-fetch all usage costs and tracked weight by archive_id.
+    # Tracked weight is used to top-up the cost at the default rate for any
+    # filament grams not covered by an inventory spool (#1344).
     usage_costs_result = await db.execute(
-        select(SpoolUsageHistory.archive_id, func.sum(SpoolUsageHistory.cost)).group_by(SpoolUsageHistory.archive_id)
+        select(
+            SpoolUsageHistory.archive_id,
+            func.sum(SpoolUsageHistory.cost),
+            func.sum(SpoolUsageHistory.weight_used),
+        ).group_by(SpoolUsageHistory.archive_id)
     )
     usage_costs = usage_costs_result.fetchall()
-    cost_map = {row[0]: row[1] for row in usage_costs if row[0] is not None and row[1] is not None and row[1] > 0}
+    cost_map = {
+        row[0]: (row[1], float(row[2] or 0))
+        for row in usage_costs
+        if row[0] is not None and row[1] is not None and row[1] > 0
+    }
 
     updated = 0
     for archive in archives:
-        usage_cost = cost_map.get(archive.id)
-        if usage_cost is not None:
-            new_cost = round(usage_cost, 2)
+        usage = cost_map.get(archive.id)
+        if usage is not None:
+            usage_cost, tracked_grams = usage
+            total_cost = float(usage_cost)
+            archive_grams = float(archive.filament_used_grams or 0)
+            untracked_grams = max(0.0, archive_grams - tracked_grams)
+            if untracked_grams > 0 and default_cost_per_kg > 0:
+                total_cost += (untracked_grams / 1000.0) * default_cost_per_kg
+            new_cost = round(total_cost, 2)
         else:
             # Fallback: sum costs for old records by print_name
             usage_result = await db.execute(
@@ -1425,6 +1632,15 @@ async def backfill_content_hashes(
 @router.delete("/{archive_id}")
 async def delete_archive(
     archive_id: int,
+    purge_stats: bool = Query(
+        False,
+        description=(
+            "When false (default) the archive is soft-deleted — files removed "
+            "from disk, row hidden from listings, but its filament / energy / "
+            "time / cost contribution stays in Quick Stats. Set true to also "
+            "drop the row from statistics (#1343)."
+        ),
+    ),
     db: AsyncSession = Depends(get_db),
     auth_result: tuple[User | None, bool] = Depends(
         require_ownership_permission(
@@ -1433,7 +1649,7 @@ async def delete_archive(
         )
     ),
 ):
-    """Delete an archive."""
+    """Delete an archive (soft by default; ``?purge_stats=true`` to hard-delete)."""
     user, can_modify_all = auth_result
 
     # Get archive first to check ownership
@@ -1448,9 +1664,25 @@ async def delete_archive(
             raise HTTPException(403, "You can only delete your own archives")
 
     service = ArchiveService(db)
-    if not await service.delete_archive(archive_id):
+    if purge_stats:
+        # Hard-delete the linked PrintLogEntry rows first so their filament /
+        # cost / count contributions disappear from /archives/stats. The FK is
+        # ON DELETE SET NULL, so without this delete the runs would survive
+        # the archive row and keep showing up in totals (#1343 / #1378).
+        from sqlalchemy import delete as sa_delete
+
+        from backend.app.models.print_log import PrintLogEntry
+
+        await db.execute(sa_delete(PrintLogEntry).where(PrintLogEntry.archive_id == archive_id))
+        await db.commit()
+
+        if not await service.delete_archive(archive_id):
+            raise HTTPException(404, "Archive not found")
+        return {"status": "deleted", "purged_from_stats": True}
+
+    if not await service.soft_delete_archive(archive_id):
         raise HTTPException(404, "Archive not found")
-    return {"status": "deleted"}
+    return {"status": "deleted", "purged_from_stats": False}
 
 
 @router.get("/{archive_id}/download")
@@ -1721,65 +1953,15 @@ async def scan_timelapse(
             matching_file = f
             break
 
-    # Strategy 2: Match by timestamp proximity
-    # Bambu timelapse filename uses the print START time (when recording began)
-    if not matching_file and (archive.started_at or archive.completed_at or archive.created_at):
-        import re
-        from datetime import datetime, timedelta
-
-        # Prefer started_at since video filename is the print start time
-        # Fall back to completed_at or created_at if started_at is not available
-        archive_start = archive.started_at
-        archive_end = archive.completed_at or archive.created_at
-        best_match = None
-        best_diff = timedelta(hours=24)  # Max 24 hour difference
-
-        for f in video_files:
-            fname = f.get("name", "")
-            # Parse timestamp from filename like "video_2025-11-24_03-17-40.mp4"
-            match = re.search(r"(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})", fname)
-            if match:
-                try:
-                    file_time = datetime.strptime(match.group(1), "%Y-%m-%d_%H-%M-%S")
-
-                    # Try multiple timezone offsets since printer timezone can vary
-                    # Common cases: local time (0), CST/UTC+8 (+8), or UTC (-local offset)
-                    for hour_offset in [0, 8, -8, 7, -7, 1, -1]:
-                        adjusted_file_time = file_time - timedelta(hours=hour_offset)
-
-                        # Check against start time (video filename = print start)
-                        if archive_start:
-                            diff = abs(adjusted_file_time - archive_start)
-                            if diff < best_diff:
-                                best_diff = diff
-                                best_match = f
-                                logger.debug(
-                                    f"Timelapse match candidate: {fname} with offset {hour_offset}h, "
-                                    f"diff from start: {diff}"
-                                )
-
-                        # Also check against end time with a buffer
-                        # (video timestamp should be BEFORE completion time)
-                        if archive_end:
-                            # The video timestamp should be within the print duration before completion
-                            if adjusted_file_time < archive_end:
-                                diff = archive_end - adjusted_file_time
-                                # Reasonable print duration: up to 48 hours
-                                if diff < timedelta(hours=48) and diff < best_diff:
-                                    best_diff = diff
-                                    best_match = f
-                                    logger.debug(
-                                        f"Timelapse match candidate (from end): {fname} with offset {hour_offset}h, "
-                                        f"diff: {diff}"
-                                    )
-
-                except ValueError:
-                    continue
-
-        # Accept match within 4 hours (more lenient for timezone issues)
-        if best_match and best_diff < timedelta(hours=4):
-            matching_file = best_match
-            logger.info("Matched timelapse by timestamp: %s (diff: %s)", best_match.get("name"), best_diff)
+    # Strategy 2: Match by timestamp proximity against print START time.
+    # Bambu timelapse filename embeds the print start time in printer-local clock.
+    # See _match_timelapse_by_timestamp for the offset-search rationale and why we
+    # intentionally don't try to match filename against end time here.
+    if not matching_file and archive.started_at:
+        candidate, diff = _match_timelapse_by_timestamp(video_files, archive.started_at)
+        if candidate is not None:
+            matching_file = candidate
+            logger.info("Matched timelapse by timestamp: %s (diff: %s)", candidate.get("name"), diff)
 
     # Strategy 3: Use file modification time from FTP listing
     # This handles cases where printer's filename timestamp is wrong but file mtime is correct

+ 188 - 6
backend/app/api/routes/auth.py

@@ -46,6 +46,8 @@ from backend.app.schemas.auth import (
     ForgotPasswordRequest,
     ForgotPasswordResponse,
     GroupBrief,
+    LDAPProvisionRequest,
+    LDAPSearchResultResponse,
     LoginRequest,
     LoginResponse,
     ResetPasswordRequest,
@@ -1185,7 +1187,15 @@ async def _provision_ldap_user(db: AsyncSession, ldap_user, ldap_config) -> User
 
 
 async def _sync_ldap_user(db: AsyncSession, user: User, ldap_user, ldap_config) -> None:
-    """Sync LDAP user attributes (email, groups) on each login."""
+    """Sync LDAP user attributes (email, groups) on each login.
+
+    Group sync only touches BamBuddy groups that LDAP is configured to manage —
+    that is, the values of `group_mapping` plus `default_group`. Any group
+    outside that set is assumed to be a manual admin assignment and is
+    preserved across logins (#1292). Manual assignments to a BamBuddy group
+    that IS LDAP-managed are still overridden by LDAP truth, because revoking
+    access in LDAP must propagate to BamBuddy on next login.
+    """
     import logging
 
     from backend.app.services.ldap_service import resolve_group_mapping
@@ -1199,9 +1209,13 @@ async def _sync_ldap_user(db: AsyncSession, user: User, ldap_user, ldap_config)
         user.email = ldap_user.email
         changed = True
 
-    # Sync group mappings — always update to match LDAP state (including revocation).
-    # Fall back to the configured default group when the user has no mapped groups,
-    # so authenticated LDAP users are never left permission-less.
+    # Compute the set of BamBuddy groups LDAP is allowed to manage. Anything
+    # outside this set is left alone so manual admin assignments survive logins.
+    ldap_managed_names: set[str] = set(ldap_config.group_mapping.values())
+    if ldap_config.default_group:
+        ldap_managed_names.add(ldap_config.default_group)
+
+    # Resolve what LDAP says the user should currently be in.
     mapped_group_names = resolve_group_mapping(ldap_user.groups, ldap_config.group_mapping)
     if not mapped_group_names and ldap_config.default_group:
         mapped_group_names = [ldap_config.default_group]
@@ -1210,11 +1224,18 @@ async def _sync_ldap_user(db: AsyncSession, user: User, ldap_user, ldap_config)
             user.username,
             ldap_config.default_group,
         )
+
     if mapped_group_names:
         groups_result = await db.execute(select(Group).where(Group.name.in_(mapped_group_names)))
-        new_groups = list(groups_result.scalars().all())
+        new_ldap_groups = list(groups_result.scalars().all())
     else:
-        new_groups = []
+        new_ldap_groups = []
+
+    # Preserve manual assignments to non-LDAP-managed groups; replace only
+    # the LDAP-managed slice with the resolved set.
+    preserved_manual_groups = [g for g in user.groups if g.name not in ldap_managed_names]
+    new_groups = preserved_manual_groups + new_ldap_groups
+
     current_group_ids = {g.id for g in user.groups}
     new_group_ids = {g.id for g in new_groups}
     if current_group_ids != new_group_ids:
@@ -1282,6 +1303,167 @@ async def get_ldap_status(db: AsyncSession = Depends(get_db)):
     }
 
 
+# =============================================================================
+# Manual LDAP user provisioning (#1298)
+# =============================================================================
+# Admins can search the directory and provision users directly from the UI
+# without enabling auto-provision on login. The two endpoints below pair with
+# the new "LDAP" tab in the user-create modal.
+
+
+@router.get("/ldap/search", response_model=list[LDAPSearchResultResponse])
+async def search_ldap_directory(
+    q: str,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Search the LDAP directory for users matching `q`.
+
+    Returns up to 25 candidates. The query is matched (case-insensitively, with
+    wildcards on both sides) against sAMAccountName, uid, mail, displayName,
+    and cn — covering both AD and OpenLDAP layouts. Each result is annotated
+    with `already_provisioned` so the UI can grey out usernames that already
+    exist as BamBuddy users.
+
+    Requires USERS_CREATE permission. Minimum query length is 2 characters.
+    """
+    from sqlalchemy import func as sa_func
+
+    from backend.app.services.ldap_service import parse_ldap_config, search_ldap_users
+
+    query = q.strip()
+    if len(query) < 2:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Query must be at least 2 characters",
+        )
+
+    ldap_settings = await _get_ldap_settings(db)
+    if not ldap_settings:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="LDAP is not enabled",
+        )
+
+    config = parse_ldap_config(ldap_settings)
+    if not config:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="LDAP server URL is not configured",
+        )
+
+    try:
+        results = search_ldap_users(config, query, limit=25)
+    except Exception as e:
+        _logger.exception("LDAP directory search failed")
+        # Admin-only endpoint — surface the underlying reason so the operator
+        # can fix it (auth_middleware already restricted access to USERS_CREATE).
+        raise HTTPException(
+            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+            detail=f"LDAP search failed: {type(e).__name__}: {e}",
+        )
+
+    if not results:
+        return []
+
+    # Annotate `already_provisioned` so the SPA can dim/disable rows that map
+    # to an existing local row. Case-insensitive lookup mirrors create_user.
+    usernames_lower = [r.username.lower() for r in results]
+    existing_query = await db.execute(select(User.username).where(sa_func.lower(User.username).in_(usernames_lower)))
+    existing_lower = {str(name).lower() for name in existing_query.scalars().all()}
+
+    return [
+        LDAPSearchResultResponse(
+            username=r.username,
+            email=r.email,
+            display_name=r.display_name,
+            dn=r.dn,
+            already_provisioned=r.username.lower() in existing_lower,
+        )
+        for r in results
+    ]
+
+
+@router.post("/ldap/provision", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
+async def provision_ldap_user(
+    payload: LDAPProvisionRequest,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Provision a BamBuddy user from an existing LDAP directory entry.
+
+    Re-resolves the username via the service-account bind (rather than trusting
+    the request body) so group mappings and email come from a fresh LDAP read.
+    Applies the same group-mapping / default-group logic as the auto-provision
+    login path (`_provision_ldap_user`), so behavior stays identical regardless
+    of whether the user was created here or on first login.
+
+    Requires USERS_CREATE.
+    """
+    from sqlalchemy import func as sa_func
+
+    from backend.app.services.ldap_service import lookup_ldap_user, parse_ldap_config
+
+    username = payload.username.strip()
+    if not username:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Username is required",
+        )
+
+    ldap_settings = await _get_ldap_settings(db)
+    if not ldap_settings:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="LDAP is not enabled",
+        )
+
+    config = parse_ldap_config(ldap_settings)
+    if not config:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="LDAP server URL is not configured",
+        )
+
+    # Look up via service bind. Service-bind failures bubble up as 503; missing
+    # entries surface as 404 to distinguish "directory unreachable" from
+    # "username doesn't exist in the directory" in the UI.
+    try:
+        ldap_user = lookup_ldap_user(config, username)
+    except Exception as e:
+        _logger.exception("LDAP lookup failed during provision")
+        raise HTTPException(
+            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+            detail=f"LDAP lookup failed: {type(e).__name__}: {e}",
+        )
+
+    if ldap_user is None:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail=f"User '{username}' not found in LDAP directory",
+        )
+
+    # Reject duplicates — the canonical username from LDAP is what gets stored,
+    # so the conflict check uses that rather than the request payload.
+    existing = await db.execute(select(User).where(sa_func.lower(User.username) == sa_func.lower(ldap_user.username)))
+    existing_user = existing.scalar_one_or_none()
+    if existing_user is not None:
+        if existing_user.auth_source == "ldap":
+            detail = f"LDAP user '{ldap_user.username}' is already provisioned"
+        else:
+            detail = f"A local user with the username '{ldap_user.username}' already exists"
+        raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail)
+
+    new_user = await _provision_ldap_user(db, ldap_user, config)
+
+    # Reload with groups eagerly loaded so _user_to_response can serialize them
+    # without lazy-load warnings (matches create_user / list_users pattern).
+    result = await db.execute(select(User).where(User.id == new_user.id).options(selectinload(User.groups)))
+    new_user = result.scalar_one()
+    _logger.info("Manually provisioned LDAP user %s (id=%d)", new_user.username, new_user.id)
+    return _user_to_response(new_user)
+
+
 # =============================================================================
 # Long-lived camera-stream tokens (#1108)
 # =============================================================================

+ 76 - 4
backend/app/api/routes/camera.py

@@ -79,6 +79,46 @@ def get_buffered_frame(printer_id: int) -> bytes | None:
     return _last_frames.get(printer_id)
 
 
+def is_stream_active(printer_id: int) -> bool:
+    """Return True iff a fan-out camera stream is currently registered for this printer.
+
+    Snapshot callers (Obico polling, manual /camera/snapshot) MUST NOT open a
+    second concurrent RTSP/chamber-image socket while a viewer is attached:
+    most Bambu firmwares allow only one camera connection, so the competing
+    socket either kicks the live viewer off or gets refused itself, and the
+    resulting reconnect storm tears down the fan-out broadcaster (see #1348).
+
+    Callers should consult this BEFORE trying to open a fresh socket and skip
+    the capture cycle when it returns True — even if try_get_active_buffered_frame
+    returns None (the stream may be running but the first frame hasn't landed
+    in the buffer yet, or the upstream is mid-reconnect).
+    """
+    return any(k.startswith(f"{printer_id}-") for k in _active_streams) or any(
+        k.startswith(f"{printer_id}-") for k in _active_chamber_streams
+    )
+
+
+def try_get_active_buffered_frame(printer_id: int) -> bytes | None:
+    """Return a buffered frame iff a stream is currently running for this printer.
+
+    Snapshot callers (Obico polling, manual /camera/snapshot) tap the fan-out
+    broadcaster's running upstream instead of opening a second concurrent
+    RTSP/chamber-image socket. Critical for printers that allow only one
+    camera connection (e.g. X2D firmware 01.01.00.00; see #1271).
+
+    Returns None when no broadcaster is active for this printer, so callers
+    fall through to their existing fresh-socket path unchanged.
+
+    NB: returning None does NOT mean "safe to open a fresh socket" — it also
+    fires when the stream is registered but no frame has been buffered yet
+    (startup race, mid-reconnect). Callers that must avoid competing sockets
+    should consult is_stream_active() first; see #1348.
+    """
+    if not is_stream_active(printer_id):
+        return None
+    return _last_frames.get(printer_id)
+
+
 async def get_printer_or_404(printer_id: int, db: AsyncSession) -> Printer:
     """Get printer by ID or raise 404."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -812,6 +852,21 @@ async def camera_snapshot(
             },
         )
 
+    # Reuse the fan-out broadcaster's buffered frame when a viewer is already
+    # watching — avoids opening a second concurrent RTSP socket on printers
+    # that allow only one camera connection (e.g. X2D firmware 01.01.00.00;
+    # see #1271). Buffered frame is <1s old while a viewer is connected.
+    buffered = try_get_active_buffered_frame(printer_id)
+    if buffered:
+        return Response(
+            content=buffered,
+            media_type="image/jpeg",
+            headers={
+                "Cache-Control": "no-cache, no-store, must-revalidate",
+                "Content-Disposition": f'inline; filename="snapshot_{printer_id}.jpg"',
+            },
+        )
+
     # Create temporary file for the snapshot (0600 so only the app user can read it)
     fd, tmp_name = tempfile.mkstemp(suffix=".jpg")
     os.close(fd)
@@ -967,7 +1022,7 @@ async def test_external_camera(
 async def check_plate_empty(
     printer_id: int,
     plate_type: str | None = None,
-    use_external: bool = False,
+    use_external: bool | None = None,
     include_debug_image: bool = False,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
@@ -982,7 +1037,11 @@ async def check_plate_empty(
     Args:
         printer_id: Printer ID
         plate_type: Type of build plate (e.g., "High Temp Plate") for calibration lookup
-        use_external: If True, prefer external camera over built-in
+        use_external: If True, prefer external camera over built-in. When omitted
+            (None), defaults to the printer's external_camera_enabled setting —
+            mirroring the runtime auto-check at print start (main.py). Without
+            this default the UI's manual check would always use the built-in
+            camera, mismatching the reference saved during calibration (#1359).
         include_debug_image: If True, return URL to annotated debug image
 
     Returns:
@@ -1003,6 +1062,11 @@ async def check_plate_empty(
     # Check printer exists first (before OpenCV check)
     printer = await get_printer_or_404(printer_id, db)
 
+    if use_external is None:
+        use_external = bool(
+            printer.external_camera_enabled and printer.external_camera_url and printer.external_camera_type
+        )
+
     if not is_plate_detection_available():
         raise HTTPException(
             status_code=503,
@@ -1077,7 +1141,7 @@ async def check_plate_empty(
 async def calibrate_plate_detection(
     printer_id: int,
     label: str | None = None,
-    use_external: bool = False,
+    use_external: bool | None = None,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
@@ -1094,7 +1158,10 @@ async def calibrate_plate_detection(
     Args:
         printer_id: Printer ID
         label: Optional label for this reference (e.g., "High Temp Plate", "Wham Bam")
-        use_external: If True, prefer external camera over built-in
+        use_external: If True, prefer external camera over built-in. When omitted
+            (None), defaults to the printer's external_camera_enabled setting so
+            calibration captures from the same source the runtime auto-check
+            uses at print start (#1359).
 
     Returns:
         Dict with:
@@ -1111,6 +1178,11 @@ async def calibrate_plate_detection(
     # Check printer exists first (before OpenCV check)
     printer = await get_printer_or_404(printer_id, db)
 
+    if use_external is None:
+        use_external = bool(
+            printer.external_camera_enabled and printer.external_camera_url and printer.external_camera_type
+        )
+
     if not is_plate_detection_available():
         raise HTTPException(
             status_code=503,

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

@@ -42,6 +42,7 @@ from backend.app.schemas.cloud import (
     SlicerSettingUpdate,
 )
 from backend.app.services.bambu_cloud import (
+    _SLICER_API_VERSION,
     BambuCloudAuthError,
     BambuCloudError,
     BambuCloudService,
@@ -445,7 +446,7 @@ async def logout(
 
 @router.get("/settings", response_model=SlicerSettingsResponse)
 async def get_slicer_settings(
-    version: str = "02.04.00.70",
+    version: str = _SLICER_API_VERSION,
     db: AsyncSession = Depends(get_db),
     current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
@@ -543,7 +544,7 @@ async def get_setting_detail(
 
 @router.get("/filaments", response_model=list[SlicerSetting])
 async def get_filament_presets(
-    version: str = "02.04.00.70",
+    version: str = _SLICER_API_VERSION,
     db: AsyncSession = Depends(get_db),
     current_user: User | None = cloud_caller(Permission.FILAMENTS_READ),
 ):

+ 108 - 70
backend/app/api/routes/inventory.py

@@ -165,12 +165,29 @@ async def apply_spool_to_slot_via_mqtt(
                 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:
-                    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 ""
-                    )
+                    # 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):
@@ -187,10 +204,32 @@ async def apply_spool_to_slot_via_mqtt(
                     setting_id = filament_id_to_setting_id(fid)
                     break
 
+    # Defend against tray_info_idx values the slicer cannot resolve. Two
+    # 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.
+    # Valid tray_info_idx values: "GF" + letter + digits (Bambu official) or
+    # "P" followed by hex (user/local presets, NOT "PFUS").
+    _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")):
+        tray_info_idx = ""
+        setting_id = ""
+
     if not tray_info_idx:
         if (
             current_tray_info_idx
             and current_tray_info_idx not in _generic_id_values
+            and not current_tray_info_idx.startswith("PFUS")
+            and current_tray_info_idx.upper() not in _known_materials
             and current_tray_type
             and current_tray_type.upper() == tray_type.upper()
         ):
@@ -205,6 +244,14 @@ async def apply_spool_to_slot_via_mqtt(
             if generic:
                 tray_info_idx = generic
 
+    # Ensure setting_id is always derivable from tray_info_idx. The local-preset
+    # path above sets tray_info_idx to a generic ID (e.g. "GFL99") but leaves
+    # setting_id empty — without this fallback the slicer gets a half-configured
+    # slot (filament id without setting id) and shows empty fields in the slot
+    # detail modal.
+    if tray_info_idx and not setting_id:
+        setting_id = filament_id_to_setting_id(tray_info_idx)
+
     temp_min, temp_max = MATERIAL_TEMPS.get((spool.material or "").upper(), (200, 240))
     if spool.nozzle_temp_min is not None:
         temp_min = spool.nozzle_temp_min
@@ -301,48 +348,24 @@ async def apply_spool_to_slot_via_mqtt(
             nozzle_diameter=nozzle_diameter,
         )
     else:
-        # No stored K-profile for this slot — preserve the slot's current live
-        # cali_idx if the printer has one. cali_idx is read from state.raw_data
-        # using the same idiom as the route's `current_tray_info_idx` lookup.
-        # Negative values (e.g. -1) mean "no calibration recorded" and must not
-        # be sent.
-        live_cali_idx: int | None = None
-        if state and getattr(state, "raw_data", None):
-            if ams_id == 255:
-                for vt in state.raw_data.get("vt_tray") or []:
-                    if isinstance(vt, dict) and int(vt.get("id", 254)) == (tray_id + 254):
-                        raw = vt.get("cali_idx")
-                        if isinstance(raw, int):
-                            live_cali_idx = raw
-                        break
-            else:
-                ams_section = state.raw_data.get("ams", {})
-                ams_list = (
-                    ams_section.get("ams", [])
-                    if isinstance(ams_section, dict)
-                    else ams_section
-                    if isinstance(ams_section, list)
-                    else []
-                )
-                tray_dict = _find_tray_in_ams_data(ams_list, ams_id, tray_id)
-                if tray_dict:
-                    raw = tray_dict.get("cali_idx")
-                    if isinstance(raw, int):
-                        live_cali_idx = raw
-        if live_cali_idx is not None and live_cali_idx >= 0:
-            cali_filament_id = spool.slicer_filament or effective_tray_info_idx
-            client.extrusion_cali_sel(
-                ams_id=ams_id,
-                tray_id=tray_id,
-                cali_idx=live_cali_idx,
-                filament_id=cali_filament_id,
-                nozzle_diameter=nozzle_diameter,
-            )
-            logger.info(
-                "No stored K-profile for spool %d — preserved live cali_idx=%d",
-                spool.id,
-                live_cali_idx,
-            )
+        # No stored K-profile for this spool — always reset the slot to Default
+        # K (cali_idx=-1). The live cali_idx on the slot belongs to whatever
+        # filament was there before, so preserving it would apply the wrong
+        # filament's calibration to the new spool. Default K is the firmware's
+        # documented "no specific profile" value (see BambuClient.extrusion_cali_sel
+        # docstring).
+        cali_filament_id = spool.slicer_filament or effective_tray_info_idx
+        client.extrusion_cali_sel(
+            ams_id=ams_id,
+            tray_id=tray_id,
+            cali_idx=-1,
+            filament_id=cali_filament_id,
+            nozzle_diameter=nozzle_diameter,
+        )
+        logger.info(
+            "No stored K-profile for spool %d — reset slot to Default K (cali_idx=-1)",
+            spool.id,
+        )
 
     # Persist slot preset mapping for UI display (preset_name on hover card).
     try:
@@ -1247,30 +1270,40 @@ async def assign_spool(
 
     # 4. Auto-configure AMS slot via MQTT.
     #
-    # Skip the publish entirely when the target slot is empty: Bambu firmware
-    # silently drops ams_filament_setting / extrusion_cali_sel for unloaded
-    # slots (there is no filament context for the cali_idx to attach to). The
-    # SpoolAssignment row is preserved with an empty fingerprint_type, which
-    # acts as the "pending config" marker — when the spool is physically
-    # inserted later, on_ams_change re-fires the full configuration. This is
-    # the SpoolBuddy primary workflow: weigh-then-assign before insertion.
+    # Only suppress the publish when the firmware's *explicit* empty signal
+    # (state ∈ {9, 10}) is set — "no spool" / "spool present but no feed".
+    # Every other state, including state=3 (the default idle on A1 Mini BMCU /
+    # P1S Standard AMS for both loaded and unconfigured slots) and missing
+    # state (older firmwares), is treated as the user's assertion that a
+    # spool is in the slot and we attempt the MQTT push.
     #
-    # Empty-detection: prefer the printer's tray.state when it's reported
-    # (11=loaded, 9=empty, 10=spool present but filament not in feeder).
-    # tray_type alone is wrong post-"Reset slot" — that flow clears tray_type
-    # to "" while leaving filament physically loaded, and the old check
-    # would then mark it as a pending-config SpoolBuddy assignment, skip
-    # MQTT, and the slot would stay unconfigured forever because the
-    # on_ams_change replay only fires on an empty→loaded transition that
-    # never comes (the slot is already loaded). When state is not reported
-    # (older firmware), fall back to the tray_type heuristic.
-    if tray_state is not None:
-        slot_is_empty = tray_state != 11
-    else:
-        slot_is_empty = not (fingerprint_type and fingerprint_type.strip())
+    # The pre-existing "skip when slot looks empty" guard read state=3 +
+    # tray_type="" as "empty" and skipped MQTT. On these firmwares that
+    # combination is the post-"Reset Slot" state with the spool still
+    # physically inserted — there is NO AMS signal that distinguishes it
+    # from a truly-empty slot, so the guard created a deadlock: MQTT never
+    # fired, the AMS never reported any change (because nothing changed
+    # physically), and on_ams_change replay therefore never re-fired the
+    # config either. Reporter (#1322 follow-up by @RosdasHH) verified
+    # empirically that removing the guard makes the slot configure
+    # correctly because Bambu firmware DOES accept the push for a
+    # physically-loaded slot, even when tray_type is "" and state is 3.
+    #
+    # Trade-off for the truly-empty slot case: firmware drops the push
+    # silently (per Bambu's documented behavior), the SpoolAssignment row
+    # still has empty fingerprint_type because nothing in the assign path
+    # updates that column, and on_ams_change at main.py:1031-1054 still
+    # fires the deferred config when a spool eventually appears. So the
+    # SpoolBuddy weigh-then-assign-before-insert workflow continues to
+    # work — just without the optimization of skipping a no-op MQTT call.
+    #
+    # state ∈ {9, 10} stays as an explicit short-circuit so we don't churn
+    # a doomed MQTT push when the firmware has positively confirmed "no
+    # spool" — and to keep the on_ams_change replay path as the single
+    # source of truth for those slots.
+    slot_is_definitely_empty = tray_state == 9 or tray_state == 10
     configured = False
-    pending_config = slot_is_empty
-    if not slot_is_empty:
+    if not slot_is_definitely_empty:
         try:
             configured = await apply_spool_to_slot_via_mqtt(
                 db=db,
@@ -1284,6 +1317,11 @@ async def assign_spool(
             )
         except Exception as e:
             logger.warning("MQTT auto-configure failed for spool %d: %s", spool.id, e)
+    # pending_config is the "config not landed yet" UI marker. True when the
+    # firmware said empty, OR when MQTT couldn't actually publish (printer
+    # offline, no client, transient failure). on_ams_change replay re-fires
+    # the config in either case once the AMS reports a non-empty fingerprint.
+    pending_config = slot_is_definitely_empty or not configured
 
     # Return assignment with spool data
     result = await db.execute(

+ 103 - 10
backend/app/api/routes/library.py

@@ -1,5 +1,6 @@
 """API routes for File Manager (Library) functionality."""
 
+import asyncio
 import base64
 import binascii
 import contextlib
@@ -27,7 +28,7 @@ from backend.app.core.auth import (
     require_permission_if_auth_enabled,
 )
 from backend.app.core.config import settings as app_settings
-from backend.app.core.database import get_db
+from backend.app.core.database import async_session, get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile, LibraryFolder
@@ -512,6 +513,55 @@ def create_image_thumbnail(file_path: Path, thumbnails_dir: Path, max_size: int
 IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif"}
 
 
+async def _backfill_external_stl_thumbnails(folder_ids: list[int]) -> None:
+    """Generate STL thumbnails for an external folder tree in the background.
+
+    Spawned via ``asyncio.create_task`` from ``scan_external_folder`` so the
+    HTTP request can return as soon as the filesystem walk + folder/file rows
+    are committed. Thumbnails for thousands of STL files would otherwise hold
+    the request open for many minutes (each file triggers a ``trimesh.load``
+    + matplotlib render, ~1-5s each) and the FE modal times out before the
+    final ``db.commit()`` runs — causing the original symptom in #1299 where
+    subdirectories never showed up because nothing got committed.
+
+    Opens its own session because the request session is closed by the time
+    this task starts running. Commits per-file so a worker restart mid-run
+    only loses the in-flight file. Caps STL load to a single file at a time
+    to avoid memory pressure on systems with many huge STLs.
+    """
+    if not folder_ids:
+        return
+    thumbnails_dir = get_library_thumbnails_dir()
+    async with async_session() as db:
+        result = await db.execute(
+            LibraryFile.active().where(
+                LibraryFile.folder_id.in_(folder_ids),
+                LibraryFile.file_type == "stl",
+                LibraryFile.thumbnail_path.is_(None),
+            )
+        )
+        stl_files = result.scalars().all()
+        if not stl_files:
+            return
+        logger.info(
+            "Backfilling STL thumbnails: %d file(s) across %d folder(s)",
+            len(stl_files),
+            len(folder_ids),
+        )
+        for stl_file in stl_files:
+            abs_path = to_absolute_path(stl_file.file_path)
+            if not abs_path or not abs_path.exists():
+                continue
+            try:
+                thumb_path = generate_stl_thumbnail(abs_path, thumbnails_dir)
+            except Exception as exc:  # noqa: BLE001 — never let one bad STL kill the rest
+                logger.debug("STL thumbnail backfill skipped %s: %s", abs_path, exc)
+                continue
+            if thumb_path:
+                stl_file.thumbnail_path = to_relative_path(Path(thumb_path))
+                await db.commit()
+
+
 # ============ Folder Endpoints ============
 
 
@@ -1249,15 +1299,10 @@ async def scan_external_folder(
                 except Exception as e:
                     logger.debug("Failed to extract metadata from external 3mf %s: %s", filepath, e)
 
-            # Generate thumbnail for STL files
-            if file_type == "stl" and thumbnail_path is None:
-                try:
-                    thumb_dir = get_library_thumbnails_dir()
-                    thumb_result = generate_stl_thumbnail(str(filepath), str(thumb_dir))
-                    if thumb_result:
-                        thumbnail_path = to_relative_path(Path(thumb_result))
-                except Exception as e:
-                    logger.debug("Failed to generate STL thumbnail for external %s: %s", filepath, e)
+            # STL thumbnails are deferred to a background task spawned after
+            # the scan's db.commit() — see _backfill_external_stl_thumbnails.
+            # Doing them inline would block the HTTP request for minutes on a
+            # large NAS mount (#1299).
 
             # Extract gcode thumbnail
             if file_type == "gcode" and thumbnail_path is None:
@@ -1330,6 +1375,19 @@ async def scan_external_folder(
 
     await db.commit()
 
+    # Spawn STL thumbnail backfill in the background — the scan endpoint
+    # returns immediately so the FE modal closes and subdirectories are
+    # visible right away; thumbnails fill in over the following seconds /
+    # minutes as the task processes each STL file. Survives FE refresh —
+    # the task lives in the FastAPI event loop, not the request scope.
+    # folder_cache.values() covers the root + every pre-existing subfolder
+    # + every subfolder created during this scan. all_folder_ids on its own
+    # would miss the newly-created ones (it's snapshotted before the walk).
+    asyncio.create_task(
+        _backfill_external_stl_thumbnails(list(set(folder_cache.values()))),
+        name=f"stl-backfill-folder-{folder_id}",
+    )
+
     return {"status": "success", "added": added, "removed": removed}
 
 
@@ -2748,6 +2806,29 @@ def _sanitize_project_settings_sentinels(zip_bytes: bytes) -> bytes:
         return zip_bytes
 
 
+def _patch_process_bed_type(process_json: str, bed_type: str) -> str:
+    """Overwrite ``curr_bed_type`` in a process-profile JSON before forwarding
+    to the slicer sidecar.
+
+    The slicer CLI reads the build-plate type from the process profile's
+    ``curr_bed_type`` field. When the user picks a non-default plate in the
+    SliceModal (#1337), we patch the resolved JSON in place rather than
+    asking them to clone the preset just to switch a plate. Returns the
+    original string unchanged when the JSON can't be parsed or isn't a
+    dict — the slicer will then run with whatever the preset originally
+    specified, which is the safe fall-back path.
+    """
+    try:
+        profile = json.loads(process_json)
+    except json.JSONDecodeError:
+        logger.warning("Bed-type override skipped: process profile is not valid JSON")
+        return process_json
+    if not isinstance(profile, dict):
+        return process_json
+    profile["curr_bed_type"] = bed_type
+    return json.dumps(profile)
+
+
 async def _run_slicer_with_fallback(
     db: AsyncSession,
     *,
@@ -2814,6 +2895,17 @@ async def _run_slicer_with_fallback(
             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)
+
     # Slicer routing — pick the sidecar URL by preferred_slicer.
     # The per-install URL setting (Settings UI → Slicer card) wins; an
     # empty value falls back to the SLICER_API_URL / BAMBU_STUDIO_API_URL
@@ -2893,6 +2985,7 @@ async def _run_slicer_with_fallback(
                     filament_names=request.bundle.filament_names,
                     plate=request.plate,
                     export_3mf=request.export_3mf,
+                    bed_type=request.bed_type,
                     request_id=progress_request_id,
                     on_progress=progress_callback,
                 )

+ 10 - 8
backend/app/api/routes/metrics.py

@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.config import APP_VERSION
 from backend.app.core.database import get_db
-from backend.app.models.archive import PrintArchive
+from backend.app.models.print_log import PrintLogEntry
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
@@ -352,11 +352,13 @@ async def get_metrics(
     # Print statistics (from database)
     # =========================================================================
 
-    # Total prints by status
+    # Total prints by status — count print events from PrintLogEntry so
+    # reprints contribute new rows instead of overwriting the source archive
+    # (#1378).
     lines.append("")
     lines.append("# HELP bambuddy_prints_total Total number of prints by result")
     lines.append("# TYPE bambuddy_prints_total counter")
-    result = await db.execute(select(PrintArchive.status, func.count(PrintArchive.id)).group_by(PrintArchive.status))
+    result = await db.execute(select(PrintLogEntry.status, func.count(PrintLogEntry.id)).group_by(PrintLogEntry.status))
     for print_result, count in result.all():
         result_label = print_result or "unknown"
         labels = format_labels(result=result_label)
@@ -367,7 +369,7 @@ async def get_metrics(
     lines.append("# HELP bambuddy_printer_prints_total Total prints per printer")
     lines.append("# TYPE bambuddy_printer_prints_total counter")
     result = await db.execute(
-        select(PrintArchive.printer_id, func.count(PrintArchive.id)).group_by(PrintArchive.printer_id)
+        select(PrintLogEntry.printer_id, func.count(PrintLogEntry.id)).group_by(PrintLogEntry.printer_id)
     )
     for printer_id, count in result.all():
         if printer_id and printer_id in printer_info:
@@ -379,19 +381,19 @@ async def get_metrics(
             )
             lines.append(f"bambuddy_printer_prints_total{labels} {count}")
 
-    # Total filament used - filament_used_grams already contains the total for each print job
+    # Total filament used — sum per-run actuals from PrintLogEntry.
     lines.append("")
     lines.append("# HELP bambuddy_filament_used_grams Total filament used in grams")
     lines.append("# TYPE bambuddy_filament_used_grams counter")
-    result = await db.execute(select(func.coalesce(func.sum(PrintArchive.filament_used_grams), 0)))
+    result = await db.execute(select(func.coalesce(func.sum(PrintLogEntry.filament_used_grams), 0)))
     total_filament = result.scalar() or 0
     lines.append(f"bambuddy_filament_used_grams {total_filament:.1f}")
 
-    # Total print time
+    # Total print time — sum per-run elapsed durations.
     lines.append("")
     lines.append("# HELP bambuddy_print_time_seconds Total print time in seconds")
     lines.append("# TYPE bambuddy_print_time_seconds counter")
-    result = await db.execute(select(func.coalesce(func.sum(PrintArchive.print_time_seconds), 0)))
+    result = await db.execute(select(func.coalesce(func.sum(PrintLogEntry.duration_seconds), 0)))
     total_time = result.scalar() or 0
     lines.append(f"bambuddy_print_time_seconds {total_time}")
 

+ 256 - 11
backend/app/api/routes/mfa.py

@@ -30,14 +30,15 @@ from datetime import datetime, timedelta, timezone
 import httpx
 import jwt
 import pyotp
-from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, Response, status
+from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Request, Response, status
 from fastapi.responses import RedirectResponse
 from jwt import PyJWKClient
 from passlib.context import CryptContext
 from sqlalchemy import delete, select
 from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy.orm import selectinload
+from sqlalchemy.orm import selectinload, undefer
 
+from backend.app.api.routes._oidc_helpers import assert_safe_public_https_url
 from backend.app.api.routes.settings import get_setting, set_setting
 from backend.app.core.auth import (
     ACCESS_TOKEN_EXPIRE_MINUTES,
@@ -83,10 +84,78 @@ from backend.app.schemas.auth import (
     UserResponse,
 )
 from backend.app.services.email_service import get_smtp_settings, send_email
+from backend.app.services.oidc_icon import OIDCIconError, fetch_icon
 
 logger = logging.getLogger(__name__)
 
 
+def _redact_url_for_log(url: str) -> str:
+    """Return ``scheme://host/path`` with query string and fragment stripped.
+
+    Admin-supplied icon URLs are usually CDN paths, but nothing stops an
+    admin from pasting a presigned URL whose query string carries an
+    ``X-Amz-Signature`` / OAuth token / etc. Operators need a forensic
+    trail without those secrets ending up in log files.
+    """
+    try:
+        parsed = urllib.parse.urlparse(url)
+    except ValueError:
+        return "<unparseable>"
+    netloc = parsed.netloc or "<no-host>"
+    return f"{parsed.scheme}://{netloc}{parsed.path}"
+
+
+async def _fetch_icon_or_400(icon_url: str) -> tuple[bytes, str, str]:
+    """Validate URL + fetch icon, mapping any failure to HTTPException(400).
+
+    Centralises the SSRF guard + fetcher invocation so create/update/refresh
+    all behave identically — admin always gets a 400 with a precise reason,
+    never a 500 / opaque server error.
+
+    Both failure paths log at WARNING so operators have a forensic trail
+    later — without these log lines the admin's UI toast was the only
+    record of the failure (#1333 review).
+    """
+    try:
+        assert_safe_public_https_url(icon_url)
+    except ValueError as exc:
+        logger.warning("OIDC icon URL rejected by SSRF guard: url=%s reason=%s", _redact_url_for_log(icon_url), exc)
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
+    try:
+        return await fetch_icon(icon_url)
+    except OIDCIconError as exc:
+        logger.warning("OIDC icon fetch failed: url=%s reason=%s", _redact_url_for_log(icon_url), exc)
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
+
+
+def _build_provider_response(provider: OIDCProvider) -> OIDCProviderResponse:
+    """Build OIDCProviderResponse via ``from_attributes``. The required
+    ``has_icon`` field is supplied by ``OIDCProvider.has_icon`` (a property
+    reading the non-deferred ``icon_content_type`` column)."""
+    return OIDCProviderResponse.model_validate(provider)
+
+
+def _etag_matches(if_none_match: str | None, etag_raw: str | None) -> bool:
+    """RFC 7232 §3.2 If-None-Match comparison.
+
+    Supports:
+    * ``*`` wildcard — matches any current representation when the resource
+      exists (and it does here; we wouldn't have an etag otherwise).
+    * Multiple comma-separated tokens.
+    * Weak-validator prefix ``W/`` (RFC 7232 §2.3) — accepted on GET since
+      cached representations of a static byte-blob are byte-identical.
+
+    Returns False on missing header or missing stored etag.
+    """
+    if not if_none_match or not etag_raw:
+        return False
+    quoted = f'"{etag_raw}"'
+    tokens = [t.strip() for t in if_none_match.split(",")]
+    if "*" in tokens:
+        return True
+    return any(tok.removeprefix("W/") == quoted for tok in tokens)
+
+
 def _as_utc(dt: datetime) -> datetime:
     """Return *dt* with UTC timezone attached.
 
@@ -1204,10 +1273,14 @@ async def admin_disable_2fa(
 async def list_oidc_providers(
     db: AsyncSession = Depends(get_db),
 ) -> list[OIDCProviderResponse]:
-    """List all enabled OIDC providers (public)."""
+    """List all enabled OIDC providers (public).
+
+    The login page renders icons via /oidc/providers/{id}/icon — `icon_data`
+    stays deferred so this list query never pulls the BLOB.
+    """
     result = await db.execute(select(OIDCProvider).where(OIDCProvider.is_enabled.is_(True)))
     providers = result.scalars().all()
-    return [OIDCProviderResponse.model_validate(p) for p in providers]
+    return [_build_provider_response(p) for p in providers]
 
 
 @router.get("/oidc/providers/all", response_model=list[OIDCProviderResponse])
@@ -1218,7 +1291,7 @@ async def list_all_oidc_providers(
     """List ALL OIDC providers including disabled ones (admin only)."""
     result2 = await db.execute(select(OIDCProvider))
     providers = result2.scalars().all()
-    return [OIDCProviderResponse.model_validate(p) for p in providers]
+    return [_build_provider_response(p) for p in providers]
 
 
 @router.post("/oidc/providers", response_model=OIDCProviderResponse, status_code=status.HTTP_201_CREATED)
@@ -1227,7 +1300,12 @@ async def create_oidc_provider(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ) -> OIDCProviderResponse:
-    """Create a new OIDC provider (admin only)."""
+    """Create a new OIDC provider (admin only).
+
+    If `icon_url` is supplied, the icon is fetched server-side and cached in
+    the BLOB columns (#1333). A fetch failure aborts the create with 400 —
+    no half-configured provider is left in the DB.
+    """
     if body.default_group_id is not None:
         grp_chk = await db.execute(select(Group).where(Group.id == body.default_group_id))
         if not grp_chk.scalar_one_or_none():
@@ -1235,6 +1313,14 @@ async def create_oidc_provider(
                 status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
                 detail="default_group_id references a non-existent group",
             )
+
+    # Fetch the icon BEFORE creating the row so a failure leaves the DB clean.
+    icon_data: bytes | None = None
+    icon_content_type: str | None = None
+    icon_etag: str | None = None
+    if body.icon_url:
+        icon_data, icon_content_type, icon_etag = await _fetch_icon_or_400(body.icon_url)
+
     provider = OIDCProvider(
         name=body.name,
         issuer_url=body.issuer_url.rstrip("/"),
@@ -1247,6 +1333,9 @@ async def create_oidc_provider(
         email_claim=body.email_claim,
         require_email_verified=body.require_email_verified,
         icon_url=body.icon_url,
+        icon_data=icon_data,
+        icon_content_type=icon_content_type,
+        icon_etag=icon_etag,
         default_group_id=body.default_group_id,
     )
     # SEC-1 + SEC-6: runtime guard mirrors the OIDCProviderCreate model_validator in schemas/auth.py.
@@ -1255,7 +1344,7 @@ async def create_oidc_provider(
     db.add(provider)
     await db.commit()
     await db.refresh(provider)
-    return OIDCProviderResponse.model_validate(provider)
+    return _build_provider_response(provider)
 
 
 @router.put("/oidc/providers/{provider_id}", response_model=OIDCProviderResponse)
@@ -1265,7 +1354,17 @@ async def update_oidc_provider(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ) -> OIDCProviderResponse:
-    """Update an existing OIDC provider (admin only)."""
+    """Update an existing OIDC provider (admin only).
+
+    Icon refetch fires when:
+    1. The submitted `icon_url` differs from the stored one (URL changed), OR
+    2. The submitted `icon_url` equals the stored one AND `icon_content_type`
+       is NULL — this is the upgrade-path edge case: old providers carry
+       `icon_url` but no cached bytes until the admin first saves them.
+
+    On fetch failure the request aborts with 400 *before* commit, so the
+    existing cached bytes (if any) remain untouched.
+    """
     result2 = await db.execute(select(OIDCProvider).where(OIDCProvider.id == provider_id))
     provider = result2.scalar_one_or_none()
     if not provider:
@@ -1279,11 +1378,43 @@ async def update_oidc_provider(
                 detail="default_group_id references a non-existent group",
             )
 
-    for field, value in body.model_dump(exclude_none=True).items():
+    dumped = body.model_dump(exclude_none=True)
+
+    # Decide whether an icon refetch is needed BEFORE mutating the ORM object,
+    # so the comparison sees provider.icon_url / icon_content_type as they are
+    # in the database.
+    new_icon_url = dumped.get("icon_url")
+    needs_icon_refetch = new_icon_url is not None and (
+        new_icon_url != provider.icon_url or provider.icon_content_type is None
+    )
+
+    # Fetch FIRST. If the upstream is unreachable or SSRF-blocked, _fetch_icon_or_400
+    # raises HTTPException(400) here — provider attributes are still untouched, so
+    # the in-memory ORM object stays consistent on the way out (and the DB row is
+    # safe regardless via get_db()'s rollback).
+    fetched_icon: tuple[bytes, str, str] | None = None
+    if needs_icon_refetch:
+        fetched_icon = await _fetch_icon_or_400(new_icon_url)
+
+    # Explicit `icon_url: null` in the PUT body means "clear the icon".
+    # The exclude_none=True dump above drops None values, which would
+    # otherwise silently ignore this request. Check model_fields_set on
+    # the unfiltered body to distinguish "client cleared it" from "client
+    # didn't include this field at all".
+    if "icon_url" in body.model_fields_set and body.icon_url is None:
+        provider.icon_url = None
+        provider.icon_data = None
+        provider.icon_content_type = None
+        provider.icon_etag = None
+
+    for field, value in dumped.items():
         if field == "issuer_url" and value:
             value = value.rstrip("/")
         setattr(provider, field, value)
 
+    if fetched_icon is not None:
+        provider.icon_data, provider.icon_content_type, provider.icon_etag = fetched_icon
+
     # SEC-1 + SEC-6: Combined-State-Guard after setattr loop.
     # Checks the final in-memory state (DB values + newly set values combined) to catch
     # partial updates that each pass schema validation individually but are unsafe together.
@@ -1291,7 +1422,7 @@ async def update_oidc_provider(
 
     await db.commit()
     await db.refresh(provider)
-    return OIDCProviderResponse.model_validate(provider)
+    return _build_provider_response(provider)
 
 
 @router.delete("/oidc/providers/{provider_id}")
@@ -1311,6 +1442,115 @@ async def delete_oidc_provider(
     return {"message": "Provider deleted"}
 
 
+# ---------------------------------------------------------------------------
+# OIDC provider icon proxy (#1333)
+# ---------------------------------------------------------------------------
+
+
+@router.get("/oidc/providers/{provider_id}/icon")
+async def get_oidc_provider_icon(
+    provider_id: int,
+    if_none_match: str | None = Header(default=None, alias="If-None-Match"),
+    db: AsyncSession = Depends(get_db),
+) -> Response:
+    """Serve the cached icon for an enabled OIDC provider (public, no auth).
+
+    Unauthenticated because ``<img>`` tags cannot send Authorization headers
+    and the login page renders these icons before the user is signed in — the
+    same justification as ``/api/v1/makerworld/thumbnail``. The SSRF guard
+    runs at admin-config time (create/update/refresh), not here.
+
+    Disabled providers respond 404 to avoid leaking their existence to
+    anonymous callers (mirrors ``GET /oidc/providers`` which filters on
+    ``is_enabled``).
+    """
+    result = await db.execute(
+        select(OIDCProvider)
+        .options(undefer(OIDCProvider.icon_data))
+        .where(OIDCProvider.id == provider_id, OIDCProvider.is_enabled.is_(True))
+    )
+    provider = result.scalar_one_or_none()
+    if provider is None or provider.icon_content_type is None or provider.icon_data is None:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Icon not found")
+
+    etag_value = f'"{provider.icon_etag}"'
+    cache_headers = {"ETag": etag_value, "Cache-Control": "public, max-age=3600"}
+
+    if _etag_matches(if_none_match, provider.icon_etag):
+        return Response(status_code=status.HTTP_304_NOT_MODIFIED, headers=cache_headers)
+
+    return Response(
+        content=provider.icon_data,
+        media_type=provider.icon_content_type,
+        headers=cache_headers,
+    )
+
+
+@router.delete("/oidc/providers/{provider_id}/icon", status_code=status.HTTP_204_NO_CONTENT)
+async def delete_oidc_provider_icon(
+    provider_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+) -> Response:
+    """Remove the icon entirely for a provider (admin only).
+
+    Clears all four icon columns — ``icon_url`` plus the three cached-bytes
+    columns. "Remove icon" means the whole record is gone, not just the
+    cache; without this the admin form would still show the URL while
+    the login page rendered a blank fallback (confusing half-state).
+    To re-add an icon the admin re-types the URL in the edit form.
+    """
+    result = await db.execute(select(OIDCProvider).where(OIDCProvider.id == provider_id))
+    provider = result.scalar_one_or_none()
+    if provider is None:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
+
+    # Setting deferred columns is safe — no read happens, just a write.
+    provider.icon_url = None
+    provider.icon_data = None
+    provider.icon_content_type = None
+    provider.icon_etag = None
+    await db.commit()
+    return Response(status_code=status.HTTP_204_NO_CONTENT)
+
+
+@router.post("/oidc/providers/{provider_id}/icon/refresh", response_model=OIDCProviderResponse)
+async def refresh_oidc_provider_icon(
+    provider_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+) -> OIDCProviderResponse:
+    """Refetch the icon from the stored `icon_url` (admin only).
+
+    Used when:
+    - The IdP changed its icon and the admin wants Bambuddy to pick up the
+      new bytes.
+    - An upgrade left the provider with an `icon_url` but no cached bytes
+      (covered automatically by `update_oidc_provider` too, but this gives
+      the UI an explicit "Refresh" button).
+
+    Failure to refetch returns 400 *before* commit, so the previously cached
+    bytes survive intact.
+    """
+    result = await db.execute(select(OIDCProvider).where(OIDCProvider.id == provider_id))
+    provider = result.scalar_one_or_none()
+    if provider is None:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
+    if not provider.icon_url:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Provider has no icon_url to refresh",
+        )
+
+    icon_data, icon_content_type, icon_etag = await _fetch_icon_or_400(provider.icon_url)
+    provider.icon_data = icon_data
+    provider.icon_content_type = icon_content_type
+    provider.icon_etag = icon_etag
+    await db.commit()
+    await db.refresh(provider)
+    return _build_provider_response(provider)
+
+
 @router.get("/oidc/authorize/{provider_id}", response_model=OIDCAuthorizeResponse)
 async def oidc_authorize(
     provider_id: int,
@@ -1864,11 +2104,16 @@ async def list_oidc_links(
         select(UserOIDCLink).where(UserOIDCLink.user_id == current_user.id).options(selectinload(UserOIDCLink.provider))
     )
     links = result.scalars().all()
+    # Defensive null-check on link.provider: on PostgreSQL the FK cascade
+    # ensures provider exists, but SQLite ships with FK enforcement off, so
+    # a deleted provider could in theory leave the link briefly orphan until
+    # the next init_db() cleanup runs. Returning "<deleted>" instead of
+    # crashing keeps the endpoint usable in that edge case (#1285 follow-up).
     return [
         OIDCLinkResponse(
             id=link.id,
             provider_id=link.provider_id,
-            provider_name=link.provider.name,
+            provider_name=link.provider.name if link.provider else "<deleted>",
             provider_email=link.provider_email,
             created_at=link.created_at.isoformat(),
         )

+ 9 - 0
backend/app/api/routes/print_log.py

@@ -97,6 +97,13 @@ async def get_print_log_thumbnail(
     """Get the thumbnail for a print log entry.
 
     Requires a stream token query param (?token=xxx) when auth is enabled.
+
+    Self-heals stale entries: when thumbnail_path points to a file that no
+    longer exists on disk (archive was deleted, or print failed before the
+    thumbnail was ever written), NULL the path on the entry so subsequent
+    page renders skip the request entirely. The frontend's <img> tag is
+    gated on entry.thumbnail_path being truthy, so the next fetch of the
+    log list will simply not request this thumbnail again.
     """
     entry = await db.get(PrintLogEntry, entry_id)
     if not entry or not entry.thumbnail_path:
@@ -104,6 +111,8 @@ async def get_print_log_thumbnail(
 
     thumb_path = settings.base_dir / entry.thumbnail_path
     if not thumb_path.exists():
+        entry.thumbnail_path = None
+        await db.commit()
         raise HTTPException(404, "Thumbnail file not found")
 
     return FileResponse(

+ 30 - 18
backend/app/api/routes/print_queue.py

@@ -222,24 +222,36 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
     }
     response = PrintQueueItemResponse(**item_dict)
     if item.archive:
-        response.archive_name = item.archive.print_name or item.archive.filename
-        response.archive_thumbnail = item.archive.thumbnail_path
-        response.print_time_seconds = item.archive.print_time_seconds
-        response.filament_used_grams = item.archive.filament_used_grams
-        response.filament_type = item.archive.filament_type
-        response.filament_color = item.archive.filament_color
-        response.layer_height = item.archive.layer_height
-        response.nozzle_diameter = item.archive.nozzle_diameter
-        response.sliced_for_model = item.archive.sliced_for_model
-        if item.plate_id:
-            archive_path = settings.base_dir / item.archive.file_path
-            if archive_path.exists():
-                plate_time = _extract_print_time_from_3mf(archive_path, item.plate_id)
-                plate_weight = sum(f["used_g"] for f in extract_filament_usage_from_3mf(archive_path, item.plate_id))
-                if plate_time is not None:
-                    response.print_time_seconds = plate_time
-                if plate_weight > 0:
-                    response.filament_used_grams = plate_weight
+        # Soft-deleted archive: files are gone from disk but the row stays
+        # (its filament/cost contribution still flows into stats per #1343).
+        # Suppress the archive-derived UI surface so the queue page doesn't
+        # 404-storm the thumbnail / plates / plate-thumbnail endpoints — the
+        # frontend's existing truthy gate on archive_thumbnail covers it
+        # (#1348 follow-up). The archive_deleted flag lets the UI render a
+        # "source deleted" badge on these rows.
+        if item.archive.deleted_at is not None:
+            response.archive_deleted = True
+        else:
+            response.archive_name = item.archive.print_name or item.archive.filename
+            response.archive_thumbnail = item.archive.thumbnail_path
+            response.print_time_seconds = item.archive.print_time_seconds
+            response.filament_used_grams = item.archive.filament_used_grams
+            response.filament_type = item.archive.filament_type
+            response.filament_color = item.archive.filament_color
+            response.layer_height = item.archive.layer_height
+            response.nozzle_diameter = item.archive.nozzle_diameter
+            response.sliced_for_model = item.archive.sliced_for_model
+            if item.plate_id:
+                archive_path = settings.base_dir / item.archive.file_path
+                if archive_path.exists():
+                    plate_time = _extract_print_time_from_3mf(archive_path, item.plate_id)
+                    plate_weight = sum(
+                        f["used_g"] for f in extract_filament_usage_from_3mf(archive_path, item.plate_id)
+                    )
+                    if plate_time is not None:
+                        response.print_time_seconds = plate_time
+                    if plate_weight > 0:
+                        response.filament_used_grams = plate_weight
     if item.library_file:
         response.library_file_name = (
             item.library_file.file_metadata.get("print_name") if item.library_file.file_metadata else None

+ 22 - 3
backend/app/api/routes/printers.py

@@ -2711,18 +2711,33 @@ async def set_chamber_light(
 async def bed_jog(
     printer_id: int,
     distance: float = Query(
-        ..., description="Relative Z distance in mm (positive = bed down / nozzle further away, negative = bed up)"
+        ...,
+        description=(
+            "Signed nozzle-bed gap adjustment in mm. Negative = decrease gap "
+            '("up" arrow in the UI: bed up on bed-on-Z models, toolhead down '
+            "on A1 bed-slingers). Positive = increase gap. The backend "
+            "translates this into the right G-code Z sign per printer model."
+        ),
     ),
     force: bool = Query(False, description="If true, bypass soft endstops via M211 (for use when Z is not homed)"),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
-    """Move the build plate along the Z axis by a relative distance.
+    """Adjust the nozzle-bed gap by a relative distance.
 
     Emits a short G-code sequence via MQTT. When ``force`` is true the soft
     endstops are disabled for the duration of the move, matching the
     "ignore and move anyway" option Bambu Studio offers when the printer
     is not homed.
+
+    Direction handling: on bed-on-Z printers (X1 / P1 / H2 family) the bed
+    is the Z-axis, and Bambu's home convention puts Z=0 at the top with
+    Z+ moving the bed down — so a frontend "Up" (decrease gap) maps
+    naturally to ``G1 Z-``. On bed-slingers (A1 / A1 Mini) the Z-axis is
+    the *toolhead*, and ``G1 Z-`` instead drives the nozzle DOWN into the
+    bed (#1334 reported exactly that crash). For those models we invert
+    the sign before emitting the G-code, so the UI semantics stay the
+    same regardless of which part physically moves.
     """
     if distance == 0 or abs(distance) > 200:
         raise HTTPException(400, "Distance must be non-zero and ≤ 200 mm")
@@ -2736,10 +2751,14 @@ async def bed_jog(
     if not client:
         raise HTTPException(400, "Printer not connected")
 
+    from backend.app.services.printer_manager import is_bed_slinger
+
+    gcode_distance = -distance if is_bed_slinger(printer.model) else distance
+
     lines = []
     if force:
         lines.append("M211 S0")
-    lines += ["G91", f"G1 Z{distance:.2f} F600", "G90"]
+    lines += ["G91", f"G1 Z{gcode_distance:.2f} F600", "G90"]
     if force:
         lines.append("M211 S1")
 

+ 101 - 9
backend/app/api/routes/settings.py

@@ -7,10 +7,11 @@ from pathlib import Path
 
 from fastapi import APIRouter, Depends, File, UploadFile
 from fastapi.responses import FileResponse, JSONResponse
+from pydantic import BaseModel, Field
 from sqlalchemy import delete, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled, caller_is_api_key
+from backend.app.core.auth import RequirePermissionIfAuthEnabled, caller_is_api_key, require_energy_cost_update
 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
@@ -34,6 +35,32 @@ _SENSITIVE_FIELDS_FOR_API_KEY = (
 )
 
 
+def _sqlalchemy_type_to_sqlite_type(type_repr: str) -> str:
+    """Map a SQLAlchemy column type's ``str()`` to a SQLite-native column type.
+
+    Used by ``create_backup_zip`` to reconstruct a portable SQLite database
+    file from PostgreSQL data. Falling through to TEXT for binary columns
+    corrupts non-UTF8 bytes — the BLOB branch is the #1333 regression guard
+    for OIDC icon BLOBs.
+
+    Extracted as a pure helper so it can be unit-tested without spinning up
+    the full FastAPI app + backup pipeline.
+    """
+    type_str = type_repr.upper()
+    if "INT" in type_str:
+        return "INTEGER"
+    if "FLOAT" in type_str or "REAL" in type_str or "NUMERIC" in type_str:
+        return "REAL"
+    if "BOOL" in type_str:
+        return "BOOLEAN"
+    if "BLOB" in type_str or "BYTEA" in type_str or "BINARY" in type_str:
+        # OIDC icon BLOB column (#1333) — without this branch the column
+        # was created as TEXT and non-UTF8 bytes were corrupted during the
+        # PG→SQLite-ZIP backup round trip.
+        return "BLOB"
+    return "TEXT"
+
+
 async def get_setting(db: AsyncSession, key: str) -> str | None:
     """Get a single setting value by key."""
     result = await db.execute(select(Settings).where(Settings.key == key))
@@ -231,6 +258,40 @@ async def patch_settings(
     return await update_settings(settings_update, db, _)
 
 
+class ElectricityPriceUpdate(BaseModel):
+    """Payload for ``POST /settings/electricity-price`` (#1356).
+
+    Mirrors the field name documented in ``wiki/features/energy.md`` so the
+    Home Assistant ``rest_command`` example needs only a URL change, not a
+    payload change. Plain non-negative float; tariffs can go as low as 0.0 in
+    some markets (e.g. free hours).
+    """
+
+    energy_cost_per_kwh: float = Field(ge=0)
+
+
+@router.post("/electricity-price", response_model=AppSettings)
+async def update_electricity_price(
+    payload: ElectricityPriceUpdate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_energy_cost_update()),
+    _is_api_key: bool = Depends(caller_is_api_key),
+):
+    """Update the per-kWh electricity cost used by the energy-tracking pipeline.
+
+    This is the only settings field writable via API key, gated by the
+    ``can_update_energy_cost`` toggle on the key. JWT users still need the
+    standard ``SETTINGS_UPDATE`` permission. See #1356 for the rationale —
+    the general ``PATCH /settings`` route remains denied for API keys because
+    it can rewrite SMTP/LDAP/MQTT credentials, which is a much wider surface
+    than the documented dynamic-tariff use case requires.
+    """
+    await set_setting(db, "energy_cost_per_kwh", str(payload.energy_cost_per_kwh))
+    await db.commit()
+    db.expire_all()
+    return await _build_settings_response(db, is_api_key=_is_api_key)
+
+
 @router.post("/reset", response_model=AppSettings)
 async def reset_settings(
     db: AsyncSession = Depends(get_db),
@@ -261,6 +322,44 @@ async def get_default_sidebar_order(
     return {"default_sidebar_order": value or ""}
 
 
+# Fields exposed via /ui-preferences without SETTINGS_READ. Each entry MUST be
+# non-sensitive (no credentials, no PII, no secret tokens) — granting SETTINGS_READ
+# also grants visibility of SMTP/LDAP/MQTT passwords and similar, so the goal of
+# this endpoint is exactly to NOT require that permission for UI rendering hints.
+# When adding a field here, confirm it doesn't carry anything sensitive.
+_UI_PREFERENCE_FIELDS: tuple[str, ...] = (
+    "require_plate_clear",
+    "check_printer_firmware",
+    "camera_view_mode",
+    "time_format",
+    "date_format",
+    "drying_presets",
+    "ams_humidity_good",
+    "ams_humidity_fair",
+    "ams_temp_good",
+    "ams_temp_fair",
+    "bed_cooled_threshold",
+)
+
+
+@router.get("/ui-preferences")
+async def get_ui_preferences(db: AsyncSession = Depends(get_db)):
+    """Get the curated subset of settings that any page needs to render correctly.
+
+    Intentionally not gated on SETTINGS_READ — every authenticated user (and
+    every page that loads for them) needs these fields, but granting SETTINGS_READ
+    would also grant visibility of secrets (SMTP/LDAP/MQTT credentials, etc.).
+    Same pattern as /default-sidebar-order (#1293).
+
+    Reuses _build_settings_response so the typed values match what /settings
+    returns for fields with the same name — bool/int/float/str types stay in
+    sync without a separate type-coercion path.
+    """
+    full = await _build_settings_response(db, is_api_key=False)
+    dumped = full.model_dump()
+    return {key: dumped[key] for key in _UI_PREFERENCE_FIELDS if key in dumped}
+
+
 @router.get("/check-ffmpeg")
 async def check_ffmpeg():
     """Check if ffmpeg is installed and available."""
@@ -414,14 +513,7 @@ async def create_backup_zip(output_path: Path | None = None) -> tuple[Path, str]
                 cols = []
                 pk_cols = [col.name for col in table.columns if col.primary_key]
                 for col in table.columns:
-                    col_type = "TEXT"  # Default
-                    type_str = str(col.type).upper()
-                    if "INT" in type_str:
-                        col_type = "INTEGER"
-                    elif "FLOAT" in type_str or "REAL" in type_str or "NUMERIC" in type_str:
-                        col_type = "REAL"
-                    elif "BOOL" in type_str:
-                        col_type = "BOOLEAN"
+                    col_type = _sqlalchemy_type_to_sqlite_type(str(col.type))
                     # Only inline PRIMARY KEY for single-column PKs
                     pk = " PRIMARY KEY" if col.primary_key and len(pk_cols) == 1 else ""
                     cols.append(f"{col.name} {col_type}{pk}")

+ 15 - 1
backend/app/api/routes/slicer_presets.py

@@ -427,11 +427,25 @@ async def import_slicer_bundle(
     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.
-        # Surface verbatim so the user sees the actual reason in the toast.
+        # 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.

+ 10 - 4
backend/app/api/routes/spoolman.py

@@ -178,8 +178,9 @@ async def sync_printer_ams(
 ):
     """Sync AMS data from a specific printer to Spoolman."""
     # Check if Spoolman is enabled and connected
+    # disable_weight_sync is deprecated (#1119); weight comes from per-print tracking.
     sm = await get_spoolman_settings(db)
-    enabled, url, disable_weight_sync = sm["enabled"], sm["url"], sm["disable_weight_sync"]
+    enabled, url = sm["enabled"], sm["url"]
     if not enabled:
         raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
 
@@ -318,7 +319,9 @@ async def sync_printer_ams(
                 sync_result = await client.sync_ams_tray(
                     tray,
                     printer.name,
-                    disable_weight_sync=disable_weight_sync,
+                    # Per-print tracking owns weight updates (#1119); manual sync
+                    # only refreshes spool metadata + slot assignments here.
+                    disable_weight_sync=True,
                     cached_spools=cached_spools,
                     inventory_remaining=inv_remaining,
                     spoolman_spool_id_hint=hint,
@@ -394,8 +397,9 @@ async def sync_all_printers(
 ):
     """Sync AMS data from all connected printers to Spoolman."""
     # Check if Spoolman is enabled
+    # disable_weight_sync is deprecated (#1119); weight comes from per-print tracking.
     sm = await get_spoolman_settings(db)
-    enabled, url, disable_weight_sync = sm["enabled"], sm["url"], sm["disable_weight_sync"]
+    enabled, url = sm["enabled"], sm["url"]
     if not enabled:
         raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
 
@@ -517,7 +521,9 @@ async def sync_all_printers(
                     sync_result = await client.sync_ams_tray(
                         tray,
                         printer.name,
-                        disable_weight_sync=disable_weight_sync,
+                        # Per-print tracking owns weight updates (#1119); manual
+                        # sync-all only refreshes spool metadata + slot assignments.
+                        disable_weight_sync=True,
                         cached_spools=cached_spools,
                         inventory_remaining=inv_remaining,
                         spoolman_spool_id_hint=hint,

+ 22 - 24
backend/app/api/routes/spoolman_inventory.py

@@ -564,7 +564,13 @@ async def update_spool(
     material = data.material if data.material is not None else cur_mat
     subtype = data.subtype if data.subtype is not None else cur_subtype
     brand = data.brand if data.brand is not None else (cur_vendor.get("name") or None)
-    color_name = data.color_name if data.color_name is not None else (cur_filament.get("color_name") or None)
+    # color_name uses model_fields_set so explicit null (clear) is distinguishable
+    # from "field omitted" (don't touch). find_or_create_filament's convention:
+    # None = don't touch, "" = explicit clear, "value" = set.
+    if "color_name" in data.model_fields_set:
+        color_name = data.color_name if data.color_name is not None else ""
+    else:
+        color_name = cur_filament.get("color_name") or None
     cur_color = (cur_filament.get("color_hex") or "808080").upper().removeprefix("#")
     rgba = data.rgba if data.rgba is not None else (cur_color + "FF")
     label_weight = data.label_weight if data.label_weight is not None else int(cur_filament.get("weight") or 1000)
@@ -1201,29 +1207,21 @@ async def assign_spoolman_slot(
                     body.tray_id,
                 )
             else:
-                # No stored K-profile: preserve the slot's current live cali_idx
-                from backend.app.api.routes.inventory import _find_tray_in_ams_data
-
-                live_tray = None
-                if state and state.raw_data:
-                    ams_raw = state.raw_data.get("ams", [])
-                    if isinstance(ams_raw, dict):
-                        ams_raw = ams_raw.get("ams", [])
-                    live_tray = _find_tray_in_ams_data(ams_raw, body.ams_id, body.tray_id)
-                live_cali_idx = (live_tray or {}).get("cali_idx")
-                if live_cali_idx is not None and live_cali_idx >= 0:
-                    mqtt_client.extrusion_cali_sel(
-                        ams_id=body.ams_id,
-                        tray_id=body.tray_id,
-                        cali_idx=live_cali_idx,
-                        filament_id=effective_tray_info_idx,
-                        nozzle_diameter=nozzle_diameter,
-                    )
-                    logger.info(
-                        "No stored K-profile for Spoolman spool %d — preserved live cali_idx=%d",
-                        body.spoolman_spool_id,
-                        live_cali_idx,
-                    )
+                # No stored K-profile for this spool — always reset the slot to
+                # Default K (cali_idx=-1). The live cali_idx belongs to whatever
+                # filament was there before, so preserving it would apply the
+                # wrong filament's calibration to the new spool.
+                mqtt_client.extrusion_cali_sel(
+                    ams_id=body.ams_id,
+                    tray_id=body.tray_id,
+                    cali_idx=-1,
+                    filament_id=effective_tray_info_idx,
+                    nozzle_diameter=nozzle_diameter,
+                )
+                logger.info(
+                    "No stored K-profile for Spoolman spool %d — reset slot to Default K (cali_idx=-1)",
+                    body.spoolman_spool_id,
+                )
 
             logger.info(
                 "Auto-configured AMS slot ams=%d tray=%d for Spoolman spool %d on printer %d",

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

@@ -415,6 +415,359 @@ def _format_bytes(size_bytes: int) -> str:
     return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
 
 
+async def _collect_auth_info(db: AsyncSession) -> dict:
+    """Auth-related configuration that's stored OUTSIDE the settings table.
+
+    The settings-table passthrough already captures `ldap_*`, `advanced_auth_enabled`,
+    etc. The blocks below come from dedicated tables that the support bundle did
+    not previously surface — every recent SSO / 2FA / group bug needed this data
+    to triage.
+    """
+    from backend.app.models.api_key import APIKey
+    from backend.app.models.group import Group
+    from backend.app.models.long_lived_token import LongLivedToken
+    from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
+    from backend.app.models.user_otp_code import UserOTPCode
+    from backend.app.models.user_totp import UserTOTP
+
+    now = datetime.now(timezone.utc)
+    auth: dict = {}
+
+    # OIDC providers — names are public (login-button labels), no secrets.
+    providers_result = await db.execute(select(OIDCProvider).order_by(OIDCProvider.id))
+    providers = providers_result.scalars().all()
+    oidc_list = []
+    for p in providers:
+        # Count linked users per provider — separate query so failure on one
+        # provider doesn't blank the whole list.
+        try:
+            link_count = (
+                await db.execute(select(func.count(UserOIDCLink.id)).where(UserOIDCLink.provider_id == p.id))
+            ).scalar() or 0
+        except Exception:
+            link_count = None
+        oidc_list.append(
+            {
+                "name": p.name,
+                "is_enabled": p.is_enabled,
+                "scopes": p.scopes,
+                "email_claim": p.email_claim,
+                "require_email_verified": p.require_email_verified,
+                "auto_create_users": p.auto_create_users,
+                "auto_link_existing_accounts": p.auto_link_existing_accounts,
+                "has_default_group": p.default_group_id is not None,
+                # Derive from icon_content_type (non-deferred) rather than
+                # icon_data (deferred BLOB) to avoid an async lazy-load.
+                # Falls back to icon_url for pre-#1333 rows that have a URL
+                # configured but no cached bytes yet.
+                "has_icon": bool(p.icon_content_type) or bool(p.icon_url),
+                "linked_user_count": link_count,
+            }
+        )
+    auth["oidc_providers"] = oidc_list
+
+    # 2FA enrollment — counts only, no per-user data.
+    totp_enabled = (
+        await db.execute(select(func.count(UserTOTP.id)).where(UserTOTP.is_enabled.is_(True)))
+    ).scalar() or 0
+    auth["users_with_totp"] = totp_enabled
+    # Active (not-yet-expired, not-yet-used) email OTP codes — bounded count;
+    # spikes here would point at someone hammering the email OTP flow.
+    email_otp_pending = (
+        await db.execute(
+            select(func.count(UserOTPCode.id)).where(
+                UserOTPCode.used.is_(False),
+                UserOTPCode.expires_at > now,
+            )
+        )
+    ).scalar() or 0
+    auth["email_otp_codes_pending"] = email_otp_pending
+
+    # API keys
+    api_keys_total = (await db.execute(select(func.count(APIKey.id)))).scalar() or 0
+    api_keys_enabled = (await db.execute(select(func.count(APIKey.id)).where(APIKey.enabled.is_(True)))).scalar() or 0
+    api_keys_expired = (
+        await db.execute(
+            select(func.count(APIKey.id)).where(
+                APIKey.expires_at.is_not(None),
+                APIKey.expires_at < now,
+            )
+        )
+    ).scalar() or 0
+    auth["api_keys_total"] = api_keys_total
+    auth["api_keys_enabled"] = api_keys_enabled
+    auth["api_keys_expired"] = api_keys_expired
+
+    # Long-lived tokens (camera-stream tokens used by kiosks etc.)
+    llt_total = (await db.execute(select(func.count(LongLivedToken.id)))).scalar() or 0
+    llt_active = (
+        await db.execute(
+            select(func.count(LongLivedToken.id)).where(
+                LongLivedToken.revoked_at.is_(None),
+                LongLivedToken.expires_at > now,
+            )
+        )
+    ).scalar() or 0
+    auth["long_lived_tokens_total"] = llt_total
+    auth["long_lived_tokens_active"] = llt_active
+
+    # Groups — system vs custom split matters for permission triage.
+    groups_system = (await db.execute(select(func.count(Group.id)).where(Group.is_system.is_(True)))).scalar() or 0
+    groups_custom = (await db.execute(select(func.count(Group.id)).where(Group.is_system.is_(False)))).scalar() or 0
+    auth["groups_system"] = groups_system
+    auth["groups_custom"] = groups_custom
+
+    return auth
+
+
+async def _collect_library_info(db: AsyncSession) -> dict:
+    """Library file / folder totals, including external-link and trash counts."""
+    from backend.app.models.external_link import ExternalLink
+    from backend.app.models.library import LibraryFile, LibraryFolder
+
+    info: dict = {}
+    info["library_files_total"] = (
+        await db.execute(select(func.count(LibraryFile.id)).where(LibraryFile.deleted_at.is_(None)))
+    ).scalar() or 0
+    info["library_files_in_trash"] = (
+        await db.execute(select(func.count(LibraryFile.id)).where(LibraryFile.deleted_at.is_not(None)))
+    ).scalar() or 0
+    info["library_folders_total"] = (await db.execute(select(func.count(LibraryFolder.id)))).scalar() or 0
+    info["external_folders_total"] = (
+        await db.execute(select(func.count(LibraryFolder.id)).where(LibraryFolder.is_external.is_(True)))
+    ).scalar() or 0
+    info["external_links_total"] = (await db.execute(select(func.count(ExternalLink.id)))).scalar() or 0
+    # MakerWorld imports — counted here because they're LibraryFile rows with
+    # source_type='makerworld' (the import path doesn't have its own table).
+    info["makerworld_imports_total"] = (
+        await db.execute(
+            select(func.count(LibraryFile.id)).where(
+                LibraryFile.deleted_at.is_(None),
+                LibraryFile.source_type == "makerworld",
+            )
+        )
+    ).scalar() or 0
+    return info
+
+
+async def _collect_inventory_info(db: AsyncSession) -> dict:
+    """Spool / k-profile totals from the inventory feature."""
+    from backend.app.models.spool import Spool
+    from backend.app.models.spool_k_profile import SpoolKProfile
+    from backend.app.models.spoolman_k_profile import SpoolmanKProfile
+
+    info: dict = {}
+    info["spools_internal"] = (await db.execute(select(func.count(Spool.id)))).scalar() or 0
+    info["k_profiles_internal"] = (await db.execute(select(func.count(SpoolKProfile.id)))).scalar() or 0
+    info["k_profiles_spoolman"] = (await db.execute(select(func.count(SpoolmanKProfile.id)))).scalar() or 0
+    return info
+
+
+async def _collect_queue_info(db: AsyncSession) -> dict:
+    """Print-queue health: pending count + oldest pending age."""
+    from backend.app.models.print_queue import PrintQueueItem
+
+    info: dict = {}
+    info["pending_total"] = (
+        await db.execute(select(func.count(PrintQueueItem.id)).where(PrintQueueItem.status == "pending"))
+    ).scalar() or 0
+    info["manual_start_pending"] = (
+        await db.execute(
+            select(func.count(PrintQueueItem.id)).where(
+                PrintQueueItem.status == "pending",
+                PrintQueueItem.manual_start.is_(True),
+            )
+        )
+    ).scalar() or 0
+    # Oldest pending item — derived from created_at to detect items stuck in queue
+    # (target printer offline, missing filament match, etc.).
+    oldest_row = (
+        await db.execute(
+            select(PrintQueueItem.created_at)
+            .where(PrintQueueItem.status == "pending")
+            .order_by(PrintQueueItem.created_at)
+            .limit(1)
+        )
+    ).scalar_one_or_none()
+    if oldest_row is not None:
+        # created_at is naive in this codebase (server_default=func.now()); compare
+        # against naive utc-now to get the actual age without TZ-conversion surprises.
+        age = (datetime.now() - oldest_row).total_seconds()
+        info["oldest_pending_age_seconds"] = int(age)
+    else:
+        info["oldest_pending_age_seconds"] = None
+    return info
+
+
+async def _collect_maintenance_info(db: AsyncSession) -> dict:
+    """Maintenance schedule totals: enabled items count + last-serviced-never count."""
+    from backend.app.models.maintenance import PrinterMaintenance
+
+    info: dict = {}
+    info["items_total"] = (await db.execute(select(func.count(PrinterMaintenance.id)))).scalar() or 0
+    info["items_enabled"] = (
+        await db.execute(select(func.count(PrinterMaintenance.id)).where(PrinterMaintenance.enabled.is_(True)))
+    ).scalar() or 0
+    return info
+
+
+async def _collect_github_backup_info(db: AsyncSession) -> dict:
+    """GitHub-backup configs: count per provider + recent-failure indicator."""
+    from backend.app.models.github_backup import GitHubBackupConfig
+
+    rows = (await db.execute(select(GitHubBackupConfig))).scalars().all()
+    providers_used: dict[str, int] = {}
+    last_failure_count = 0
+    schedule_enabled_count = 0
+    for cfg in rows:
+        providers_used[cfg.provider] = providers_used.get(cfg.provider, 0) + 1
+        if cfg.last_backup_status == "failed":
+            last_failure_count += 1
+        if cfg.schedule_enabled:
+            schedule_enabled_count += 1
+    return {
+        "configs_total": len(rows),
+        "providers_used": providers_used,
+        "schedule_enabled_count": schedule_enabled_count,
+        "last_failure_count": last_failure_count,
+    }
+
+
+async def _check_url_reachable(url: str, timeout: float = 2.0) -> bool | None:
+    """Single HEAD/GET ping with a short timeout. Returns None if URL is empty."""
+    if not url or not url.strip():
+        return None
+    try:
+        import httpx
+
+        async with httpx.AsyncClient(timeout=timeout, verify=False) as client:  # nosec B501 — local sidecars often use self-signed; this is a reachability/health probe only, no secrets are sent
+            r = await client.get(url, follow_redirects=False)
+            # Anything that returned a status code counts as reachable, even 404
+            # (the API server is up, just the path was wrong) — separates network
+            # failure from configuration mistakes for the user.
+            return r.status_code is not None
+    except Exception:
+        return False
+
+
+async def _fetch_slicer_health(url: str, timeout: float = 2.0) -> dict | None:
+    """Fetch ``/health`` from a slicer sidecar and extract the CLI version.
+
+    Returns ``None`` when ``url`` is empty (so the caller can distinguish
+    "not configured" from "unreachable"). On any failure to fetch or parse,
+    returns ``{"reachable": False, "version": None}``. The slicer-API wrapper
+    labels both sidecars' CLI under ``checks.orcaslicer`` regardless of which
+    slicer is actually bundled (cosmetic wrapper bug), so we read the version
+    from whichever non-``dataPath`` child key exists rather than hardcoding
+    one. This lets the bundle reviewer answer "is the user running the image
+    they think they are?" without a separate curl round-trip.
+    """
+    if not url or not url.strip():
+        return None
+    health_url = url.rstrip("/") + "/health"
+    try:
+        import httpx
+
+        async with httpx.AsyncClient(timeout=timeout, verify=False) as client:  # nosec B501 — local sidecars often use self-signed; this is a reachability/health probe only, no secrets are sent
+            r = await client.get(health_url, follow_redirects=False)
+            if r.status_code != 200:
+                return {"reachable": True, "version": None}
+            try:
+                data = r.json()
+            except Exception:
+                return {"reachable": True, "version": None}
+            checks = data.get("checks") if isinstance(data, dict) else None
+            if not isinstance(checks, dict):
+                return {"reachable": True, "version": None}
+            for key, value in checks.items():
+                if key == "dataPath":
+                    continue
+                if isinstance(value, dict) and "version" in value:
+                    return {"reachable": True, "version": value.get("version")}
+            return {"reachable": True, "version": None}
+    except Exception:
+        return {"reachable": False, "version": None}
+
+
+async def _collect_slicer_api_info() -> dict:
+    """Reachability check for configured slicer-API sidecars.
+
+    Mirrors the URL-resolution precedence used by the real slicer routes
+    (``archives.py:_slice_for_archive`` and ``library.py``) — DB setting first,
+    falling back to ``app_settings.bambu_studio_api_url`` / ``slicer_api_url``
+    which themselves respect the ``BAMBU_STUDIO_API_URL`` / ``SLICER_API_URL``
+    env vars and default to ``http://localhost:3001`` / ``http://localhost:3003``.
+    A bundle-time reachability check that only looked at the DB setting would
+    return ``null`` for every user who runs the sidecar via env var or on the
+    default port — i.e. most users.
+
+    Also reads URLs directly from ``Settings.value`` rather than from
+    ``info["settings"]``, which has already been redacted by the time the
+    integrations block runs (``bambu_studio_api_url`` matches the ``url``
+    keyword filter, so its value there is ``"[REDACTED]"`` and pinging that
+    crashes httpx).
+    """
+    async with async_session() as db:
+        keys_we_need = (
+            "use_slicer_api",
+            "preferred_slicer",
+            "bambu_studio_api_url",
+            "orcaslicer_api_url",
+        )
+        rows = (await db.execute(select(Settings).where(Settings.key.in_(keys_we_need)))).scalars().all()
+        raw = {s.key: (s.value or "") for s in rows}
+
+    # Resolve with the same DB-then-env-then-default precedence as the route
+    # that the slicer-API client actually uses, so the bundle reflects what
+    # the running app would resolve at request time.
+    bs_db = raw.get("bambu_studio_api_url", "").strip()
+    oc_db = raw.get("orcaslicer_api_url", "").strip()
+    bs_url = bs_db or (settings.bambu_studio_api_url or "").strip()
+    oc_url = oc_db or (settings.slicer_api_url or "").strip()
+
+    info: dict = {
+        "enabled": (raw.get("use_slicer_api", "false") or "false").lower() == "true",
+        "preferred": raw.get("preferred_slicer", ""),
+        # Layer accounting helps triage: was the URL set in the DB, or are
+        # we falling through to the env-var / default? "Reachable but no
+        # DB setting" is the env-var case.
+        "bambu_studio_url_set_in_db": bool(bs_db),
+        "orcaslicer_url_set_in_db": bool(oc_db),
+        # Effective URL is the resolved one — kept as a host-portion-only
+        # echo so we can confirm it's the expected sidecar without leaking
+        # the full URL (which `url` keyword would have redacted anyway).
+        "bambu_studio_url_source": ("db" if bs_db else ("env_or_default" if bs_url else "unset")),
+        "orcaslicer_url_source": ("db" if oc_db else ("env_or_default" if oc_url else "unset")),
+    }
+    if info["enabled"]:
+        bs_health, oc_health = await asyncio.gather(
+            _fetch_slicer_health(bs_url),
+            _fetch_slicer_health(oc_url),
+        )
+        info["bambu_studio_reachable"] = (bs_health or {}).get("reachable") if bs_health is not None else None
+        info["bambu_studio_version"] = (bs_health or {}).get("version") if bs_health is not None else None
+        info["orcaslicer_reachable"] = (oc_health or {}).get("reachable") if oc_health is not None else None
+        info["orcaslicer_version"] = (oc_health or {}).get("version") if oc_health is not None else None
+    return info
+
+
+def _parse_obico_enabled_printers(raw: str) -> set[int]:
+    """Parse the comma-separated `obico_enabled_printers` setting. Same shape as
+    obico_detection.py uses but tolerant of legacy formats."""
+    if not raw or not raw.strip():
+        return set()
+    result: set[int] = set()
+    for token in raw.split(","):
+        token = token.strip()
+        if not token:
+            continue
+        try:
+            result.add(int(token))
+        except ValueError:
+            continue
+    return result
+
+
 async def _collect_support_info() -> dict:
     """Collect all support information."""
     in_docker = is_running_in_docker()
@@ -480,6 +833,19 @@ async def _collect_support_info() -> dict:
         printers = result.scalars().all()
         statuses = printer_manager.get_all_statuses()
 
+        # Pre-load the obico per-printer enabled-list. Settings are loaded later
+        # in this function (and would overwrite this key in info["settings"]),
+        # so do a targeted query here for the per-printer flag below.
+        obico_enabled_set: set[int] = set()
+        try:
+            obico_row = (
+                await db.execute(select(Settings).where(Settings.key == "obico_enabled_printers"))
+            ).scalar_one_or_none()
+            if obico_row is not None:
+                obico_enabled_set = _parse_obico_enabled_printers(obico_row.value)
+        except Exception:
+            logger.debug("Failed to load obico_enabled_printers", exc_info=True)
+
         # Check reachability in parallel
         reachability_tasks = [_check_port(p.ip_address, 8883) for p in printers]
         reachable_results = await asyncio.gather(*reachability_tasks, return_exceptions=True)
@@ -522,6 +888,7 @@ async def _collect_support_info() -> dict:
                     "has_vt_tray": has_vt_tray,
                     "external_camera_configured": bool(printer.external_camera_url),
                     "plate_detection_enabled": printer.plate_detection_enabled,
+                    "obico_enabled": printer.id in obico_enabled_set,
                     "hms_error_count": len(state.hms_errors) if state else 0,
                     "developer_mode": state.developer_mode if state else None,
                     "nozzle_rack_count": len(state.nozzle_rack) if state else 0,
@@ -568,6 +935,7 @@ async def _collect_support_info() -> dict:
             "token",
             "secret",
             "api_key",
+            "auth_key",  # Tailscale auth keys: virtual_printer_tailscale_auth_key
             "installation_id",
             "cloud_token",
             "mqtt_password",
@@ -582,11 +950,20 @@ async def _collect_support_info() -> dict:
             "config",  # URLs may contain IPs, configs may have embedded secrets
             "_ip",  # IP address fields (e.g. virtual_printer_remote_interface_ip)
             "host",
+            "broker",  # MQTT broker hostname / IP — network exposure
             "credential",
         }
+        # Value-based safety net: redact anything whose value carries an
+        # unambiguous secret prefix, even if the key name didn't match.
+        # `tskey-` is the Tailscale auth-key prefix — future Tailscale settings
+        # with unexpected names won't leak just because we forgot to add them.
+        sensitive_value_prefixes = ("tskey-",)
         for s in all_settings:
             key_lower = s.key.lower()
-            if any(sensitive in key_lower for sensitive in sensitive_keys):
+            value = s.value or ""
+            if any(sensitive in key_lower for sensitive in sensitive_keys) or any(
+                value.startswith(prefix) for prefix in sensitive_value_prefixes
+            ):
                 # Preserve shape: mark presence without leaking the value
                 info["settings"][s.key] = "[REDACTED]" if s.value else ""
             else:
@@ -644,6 +1021,42 @@ async def _collect_support_info() -> dict:
         except Exception:
             logger.debug("Failed to collect database health info", exc_info=True)
 
+    # Auth section — OIDC, 2FA, API keys, long-lived tokens, groups.
+    # Stored in dedicated tables that the settings-table passthrough doesn't see.
+    try:
+        async with async_session() as auth_db:
+            info["auth"] = await _collect_auth_info(auth_db)
+    except Exception:
+        logger.debug("Failed to collect auth info", exc_info=True)
+
+    # Library + folder + makerworld import totals
+    try:
+        async with async_session() as lib_db:
+            info["library"] = await _collect_library_info(lib_db)
+    except Exception:
+        logger.debug("Failed to collect library info", exc_info=True)
+
+    # Spool / k-profile totals (inventory feature)
+    try:
+        async with async_session() as inv_db:
+            info["inventory"] = await _collect_inventory_info(inv_db)
+    except Exception:
+        logger.debug("Failed to collect inventory info", exc_info=True)
+
+    # Print queue health
+    try:
+        async with async_session() as q_db:
+            info["queue"] = await _collect_queue_info(q_db)
+    except Exception:
+        logger.debug("Failed to collect queue info", exc_info=True)
+
+    # Maintenance schedules
+    try:
+        async with async_session() as m_db:
+            info["maintenance"] = await _collect_maintenance_info(m_db)
+    except Exception:
+        logger.debug("Failed to collect maintenance info", exc_info=True)
+
     # Integrations (lazy imports to avoid circular dependencies)
     info.setdefault("integrations", {})
 
@@ -721,6 +1134,19 @@ async def _collect_support_info() -> dict:
     except Exception:
         logger.debug("Failed to collect Home Assistant info", exc_info=True)
 
+    # GitHub backup — providers + recent-failure counts from github_backup_config.
+    try:
+        async with async_session() as gb_db:
+            info["integrations"]["github_backup"] = await _collect_github_backup_info(gb_db)
+    except Exception:
+        logger.debug("Failed to collect GitHub backup info", exc_info=True)
+
+    # Slicer-API sidecar reachability (#X1C-investigation-style triage)
+    try:
+        info["integrations"]["slicer_api"] = await _collect_slicer_api_info()
+    except Exception:
+        logger.debug("Failed to collect slicer-API info", exc_info=True)
+
     # Dependencies
     try:
         dep_packages = [

+ 26 - 1
backend/app/api/routes/users.py

@@ -25,9 +25,14 @@ from backend.app.models.api_key import APIKey
 from backend.app.models.archive import PrintArchive
 from backend.app.models.group import Group
 from backend.app.models.library import LibraryFile
+from backend.app.models.long_lived_token import LongLivedToken
+from backend.app.models.oidc_provider import UserOIDCLink
+from backend.app.models.print_batch import PrintBatch
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
+from backend.app.models.user_otp_code import UserOTPCode
+from backend.app.models.user_totp import UserTOTP
 from backend.app.schemas.auth import ChangePasswordRequest, GroupBrief, UserCreate, UserResponse, UserUpdate
 from backend.app.services.email_service import (
     create_welcome_email_from_template,
@@ -395,9 +400,12 @@ async def delete_user(
         await db.execute(delete(PrintArchive).where(PrintArchive.created_by_id == user_id))
         await db.execute(delete(PrintQueueItem).where(PrintQueueItem.created_by_id == user_id))
         await db.execute(delete(LibraryFile).where(LibraryFile.created_by_id == user_id))
+        await db.execute(delete(PrintBatch).where(PrintBatch.created_by_id == user_id))
     else:
         # Explicitly set created_by_id to NULL for all items (ensures consistent behavior
-        # across different database backends, including SQLite without foreign key support)
+        # across different database backends, including SQLite without foreign key support).
+        # PrintBatch carries the same created_by_id FK with ondelete=SET NULL — admin-deleted
+        # users would otherwise leave dangling created_by_id on SQLite (#1295 review nit).
         from sqlalchemy import update
 
         await db.execute(update(PrintArchive).where(PrintArchive.created_by_id == user_id).values(created_by_id=None))
@@ -405,6 +413,7 @@ async def delete_user(
             update(PrintQueueItem).where(PrintQueueItem.created_by_id == user_id).values(created_by_id=None)
         )
         await db.execute(update(LibraryFile).where(LibraryFile.created_by_id == user_id).values(created_by_id=None))
+        await db.execute(update(PrintBatch).where(PrintBatch.created_by_id == user_id).values(created_by_id=None))
 
     # Drop API keys owned by this user. The model declares ON DELETE CASCADE
     # so Postgres handles this automatically, but SQLite ships with FK
@@ -417,6 +426,22 @@ async def delete_user(
     # exactly the orphan-key state the CASCADE was meant to prevent).
     await db.execute(delete(APIKey).where(APIKey.user_id == user_id))
 
+    # Drop OIDC links, MFA state, and long-lived camera-stream tokens
+    # owned by this user. Same SQLite/FK pattern as APIKey above. Without
+    # these, deleting a user on SQLite leaves:
+    #   - UserOIDCLink: the OIDC callback finds the orphan link, fails to
+    #     resolve the (now missing) user, and falls through to
+    #     "account_inactive" instead of triggering auto_create (#1285).
+    #   - UserTOTP: MFA secrets persist in the DB after the owning user.
+    #   - UserOTPCode: pending email OTP codes linger.
+    #   - LongLivedToken: per-user camera-stream tokens whose secret_hash
+    #     is still valid — verify() would happily match them by lookup
+    #     prefix even though the user is gone.
+    await db.execute(delete(UserOIDCLink).where(UserOIDCLink.user_id == user_id))
+    await db.execute(delete(UserTOTP).where(UserTOTP.user_id == user_id))
+    await db.execute(delete(UserOTPCode).where(UserOTPCode.user_id == user_id))
+    await db.execute(delete(LongLivedToken).where(LongLivedToken.user_id == user_id))
+
     await db.delete(user)
     await db.commit()
 

+ 82 - 0
backend/app/core/auth.py

@@ -60,6 +60,88 @@ def _check_apikey_permissions(perm_strings: list[str]) -> None:
         )
 
 
+def require_energy_cost_update():
+    """Dependency for ``POST /settings/electricity-price`` (#1356).
+
+    Bypasses the ``_APIKEY_DENIED_PERMISSIONS`` ``SETTINGS_UPDATE`` block for
+    API keys that explicitly opt into ``can_update_energy_cost``. Full
+    ``SETTINGS_UPDATE`` for API keys stays denied — this is a narrowly-scoped
+    door for the Home Assistant dynamic-tariff use case documented in
+    ``wiki/features/energy.md``, not a general settings-write capability.
+
+    Accepts:
+      * Auth disabled  → always allowed (matches other settings routes)
+      * JWT user with ``SETTINGS_UPDATE`` permission
+      * API key with ``can_update_energy_cost = True``
+    """
+
+    async def permission_checker(
+        credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+        x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
+    ) -> User | None:
+        async with async_session() as db:
+            if not await is_auth_enabled(db):
+                return None
+
+            credentials_exception = HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail="Could not validate credentials",
+                headers={"WWW-Authenticate": "Bearer"},
+            )
+
+            # API key path — X-API-Key header or Bearer bb_xxx
+            api_key_value: str | None = None
+            if x_api_key:
+                api_key_value = x_api_key
+            elif credentials is not None and credentials.credentials.startswith("bb_"):
+                api_key_value = credentials.credentials
+
+            if api_key_value is not None:
+                api_key = await _validate_api_key(db, api_key_value)
+                if api_key is None:
+                    raise HTTPException(
+                        status_code=status.HTTP_401_UNAUTHORIZED,
+                        detail="Invalid API key",
+                        headers={"WWW-Authenticate": "Bearer"},
+                    )
+                if not api_key.can_update_energy_cost:
+                    raise HTTPException(
+                        status_code=status.HTTP_403_FORBIDDEN,
+                        detail="API key does not have 'update_energy_cost' permission",
+                    )
+                return None
+
+            # JWT path
+            if credentials is None:
+                raise credentials_exception
+
+            try:
+                payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
+                username: str = payload.get("sub")
+                if username is None:
+                    raise credentials_exception
+                jti: str | None = payload.get("jti")
+                if not jti or await is_jti_revoked(jti):
+                    raise credentials_exception
+                iat: int | float | None = payload.get("iat")
+            except JWTError:
+                raise credentials_exception
+
+            user = await get_user_by_username(db, username)
+            if user is None or not user.is_active:
+                raise credentials_exception
+            if not _is_token_fresh(iat, user):
+                raise credentials_exception
+            if not user.has_all_permissions(Permission.SETTINGS_UPDATE.value):
+                raise HTTPException(
+                    status_code=status.HTTP_403_FORBIDDEN,
+                    detail=f"Missing required permissions: {Permission.SETTINGS_UPDATE.value}",
+                )
+            return user
+
+    return permission_checker
+
+
 # Password hashing
 # Use pbkdf2_sha256 instead of bcrypt to avoid 72-byte limit and passlib initialization issues
 # pbkdf2_sha256 is a secure password hashing algorithm without bcrypt's limitations

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

+ 206 - 2
backend/app/core/database.py

@@ -502,6 +502,103 @@ async def _migrate_update_auto_link_constraint(conn) -> None:
                 raise
 
 
+async def _migrate_widen_spoolman_slot_ams_id_range(conn) -> None:
+    """Widen ck_ams_id_range on spoolman_slot_assignments to admit AMS-HT (#1274).
+
+    Old formula: (ams_id >= 0 AND ams_id <= 7) OR ams_id = 255
+    New formula: (ams_id >= 0 AND ams_id <= 7) OR (ams_id >= 128 AND ams_id <= 191) OR ams_id = 255
+
+    The H2C/H2D AMS-HT reports ams_id 128+. The old constraint rejected every
+    AMS-HT slot link with `IntegrityError: CHECK constraint failed: ck_ams_id_range`.
+
+    PostgreSQL: DROP CONSTRAINT IF EXISTS + ADD new formula via _safe_execute.
+    SQLite: table recreation when the old (narrower) formula is detected in
+    sqlite_master. Fresh installs already have the widened constraint from
+    the CREATE TABLE migration above.
+    """
+    from sqlalchemy import text
+
+    _NEW_FORMULA = "(ams_id >= 0 AND ams_id <= 7) OR (ams_id >= 128 AND ams_id <= 191) OR ams_id = 255"
+    _CONSTRAINT_NAME = "ck_ams_id_range"
+
+    if not is_sqlite():
+        await _safe_execute(
+            conn,
+            f"ALTER TABLE spoolman_slot_assignments DROP CONSTRAINT IF EXISTS {_CONSTRAINT_NAME}",
+        )
+        await _safe_execute(
+            conn,
+            f"ALTER TABLE spoolman_slot_assignments ADD CONSTRAINT {_CONSTRAINT_NAME} CHECK ({_NEW_FORMULA})",
+        )
+        return
+
+    row = (
+        await conn.execute(
+            text("SELECT sql FROM sqlite_master WHERE type='table' AND name='spoolman_slot_assignments'")
+        )
+    ).fetchone()
+    if not row:
+        return
+    sql = row[0] or ""
+    # Already widened by an earlier run or by the fresh-install CREATE TABLE above.
+    if "ams_id >= 128" in sql:
+        return
+    # Pre-migration table without any CHECK constraint at all → leave alone;
+    # the app-level validation handles correctness and we don't risk a
+    # destructive table rebuild for a constraint that isn't blocking anyone.
+    if "ck_ams_id_range" not in sql and "ams_id <= 7" not in sql:
+        return
+
+    try:
+        async with conn.begin_nested():
+            await conn.execute(text("DROP TABLE IF EXISTS spoolman_slot_assignments_v2"))
+            await conn.execute(
+                text(
+                    "CREATE TABLE spoolman_slot_assignments_v2 ("
+                    "id INTEGER PRIMARY KEY AUTOINCREMENT, "
+                    "printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE, "
+                    f"ams_id INTEGER NOT NULL CHECK ({_NEW_FORMULA}), "
+                    "tray_id INTEGER NOT NULL CHECK (tray_id >= 0 AND tray_id <= 3), "
+                    "spoolman_spool_id INTEGER NOT NULL, "
+                    "assigned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "
+                    "CONSTRAINT uq_slot_assignment UNIQUE(printer_id, ams_id, tray_id)"
+                    ")"
+                )
+            )
+            await conn.execute(
+                text(
+                    "INSERT INTO spoolman_slot_assignments_v2 "
+                    "(id, printer_id, ams_id, tray_id, spoolman_spool_id, assigned_at) "
+                    "SELECT id, printer_id, ams_id, tray_id, spoolman_spool_id, assigned_at "
+                    "FROM spoolman_slot_assignments"
+                )
+            )
+            original = (await conn.execute(text("SELECT count(*) FROM spoolman_slot_assignments"))).scalar_one()
+            copied = (await conn.execute(text("SELECT count(*) FROM spoolman_slot_assignments_v2"))).scalar_one()
+            if copied != original:
+                raise RuntimeError(
+                    f"spoolman_slot_assignments migration: row count mismatch after copy "
+                    f"({original} in source, {copied} in copy)"
+                )
+            await conn.execute(text("DROP TABLE spoolman_slot_assignments"))
+            await conn.execute(text("ALTER TABLE spoolman_slot_assignments_v2 RENAME TO spoolman_slot_assignments"))
+            # The index sits on the renamed table; recreate it idempotently
+            # to handle older sqlite versions that don't auto-rename indexes.
+            await conn.execute(
+                text(
+                    "CREATE INDEX IF NOT EXISTS ix_slot_assignment_spool "
+                    "ON spoolman_slot_assignments (spoolman_spool_id)"
+                )
+            )
+    except Exception as exc:
+        logger.error(
+            "spoolman_slot_assignments ck_ams_id_range widening (SQLite table recreation) FAILED: %s",
+            exc,
+            exc_info=True,
+        )
+        raise
+
+
 async def run_migrations(conn):
     """Run all schema migrations and data backfills on startup.
 
@@ -1819,6 +1916,19 @@ async def run_migrations(conn):
     # rendered on archive cards.
     await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN bed_type VARCHAR(64)")
 
+    # Migration: Add deleted_at to print_archives (#1343)
+    # Soft-delete sentinel so deleting an archive entry from the UI no longer
+    # wipes its filament / time / cost contribution from Quick Stats. Listings
+    # hide rows where deleted_at IS NOT NULL; the stats endpoint counts them all.
+    # DATETIME on SQLite, TIMESTAMP on PostgreSQL (PG doesn't accept DATETIME on
+    # ALTER TABLE the same way it tolerates it inside CREATE TABLE).
+    _deleted_at_type = "DATETIME" if is_sqlite() else "TIMESTAMP"
+    await _safe_execute(conn, f"ALTER TABLE print_archives ADD COLUMN deleted_at {_deleted_at_type}")
+    await _safe_execute(
+        conn,
+        "CREATE INDEX IF NOT EXISTS ix_print_archives_deleted_at ON print_archives (deleted_at)",
+    )
+
     # Migration: Create smart_plug_energy_snapshots table (#941)
     # Hourly snapshots of each plug's lifetime counter, so date-range queries in
     # "total consumption" energy mode can compute (last - first) deltas.
@@ -1934,6 +2044,31 @@ async def run_migrations(conn):
         "ALTER TABLE oidc_providers ADD COLUMN default_group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL",
     )
 
+    # Migration: Add cached-icon columns to oidc_providers (#1333).
+    # SPA's strict CSP (img-src 'self' data: blob:) blocks hotlinking external
+    # icon hosts, so we proxy them: admin sets icon_url, backend fetches and
+    # caches the bytes here, the SPA renders <img src="/api/v1/auth/oidc/providers/{id}/icon">.
+    # Must run AFTER _migrate_update_auto_link_constraint for the same reason as
+    # default_group_id above (SQLite table recreation drops unknown columns).
+    # Dialect-conditional type: BLOB on SQLite, BYTEA on PostgreSQL.
+    _blob_type = "BLOB" if is_sqlite() else "BYTEA"
+    await _safe_execute(conn, f"ALTER TABLE oidc_providers ADD COLUMN icon_data {_blob_type}")
+    await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN icon_content_type VARCHAR(20)")
+    await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN icon_etag VARCHAR(64)")
+
+    # PostgreSQL-only: enforce the all-or-nothing triplet at the DB layer.
+    # SQLite cannot ADD CONSTRAINT to an existing table — fresh SQLite
+    # installs get the CHECK via metadata.create_all (model __table_args__);
+    # stale SQLite installs rely on the application layer, same trade-off
+    # as the default_group_id FK ON DELETE SET NULL above.
+    if not is_sqlite():
+        await _safe_execute(
+            conn,
+            "ALTER TABLE oidc_providers ADD CONSTRAINT ck_oidc_icon_triplet_co_null "
+            "CHECK ((icon_data IS NULL) = (icon_content_type IS NULL) "
+            "AND (icon_content_type IS NULL) = (icon_etag IS NULL))",
+        )
+
     # Migration: Add password_changed_at to users (M-R7-B)
     # Tracks the last time a user's password was changed/reset.  JWTs whose iat
     # predates this timestamp are rejected in all six auth validation paths.
@@ -1996,6 +2131,13 @@ async def run_migrations(conn):
         conn,
         "ALTER TABLE api_keys ADD COLUMN can_access_cloud BOOLEAN DEFAULT FALSE",
     )
+    # Narrowly-scoped settings-write toggle for the dynamic-tariff push case
+    # documented in wiki/features/energy.md (#1356). Defaults FALSE so existing
+    # keys never silently gain settings-write capability on upgrade.
+    await _safe_execute(
+        conn,
+        "ALTER TABLE api_keys ADD COLUMN can_update_energy_cost BOOLEAN DEFAULT FALSE",
+    )
 
     # Migration: Soft-delete column for trash bin (Issue #1008). Indexed so the
     # sweeper's "SELECT ... WHERE deleted_at < cutoff" and the trash list's
@@ -2039,13 +2181,14 @@ async def run_migrations(conn):
     # Migration: Create spoolman_slot_assignments table for local AMS-slot→Spoolman-spool mapping.
     # Replaces the pattern of writing spool.location in Spoolman (which polluted the
     # user-editable storage_location field in the UI).
+    # ck_ams_id_range formula was widened in #1274 to admit AMS-HT (ams_id 128-191).
     await _safe_execute(
         conn,
         """
         CREATE TABLE IF NOT EXISTS spoolman_slot_assignments (
             id INTEGER PRIMARY KEY AUTOINCREMENT,
             printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
-            ams_id INTEGER NOT NULL CHECK ((ams_id >= 0 AND ams_id <= 7) OR ams_id = 255),
+            ams_id INTEGER NOT NULL CHECK ((ams_id >= 0 AND ams_id <= 7) OR (ams_id >= 128 AND ams_id <= 191) OR ams_id = 255),
             tray_id INTEGER NOT NULL CHECK (tray_id >= 0 AND tray_id <= 3),
             spoolman_spool_id INTEGER NOT NULL,
             assigned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -2057,7 +2200,7 @@ async def run_migrations(conn):
         CREATE TABLE IF NOT EXISTS spoolman_slot_assignments (
             id SERIAL PRIMARY KEY,
             printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
-            ams_id INTEGER NOT NULL CHECK ((ams_id >= 0 AND ams_id <= 7) OR ams_id = 255),
+            ams_id INTEGER NOT NULL CHECK ((ams_id >= 0 AND ams_id <= 7) OR (ams_id >= 128 AND ams_id <= 191) OR ams_id = 255),
             tray_id INTEGER NOT NULL CHECK (tray_id >= 0 AND tray_id <= 3),
             spoolman_spool_id INTEGER NOT NULL,
             assigned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -2070,6 +2213,11 @@ async def run_migrations(conn):
         "CREATE INDEX IF NOT EXISTS ix_slot_assignment_spool ON spoolman_slot_assignments (spoolman_spool_id)",
     )
 
+    # Migration: widen ck_ams_id_range on spoolman_slot_assignments to allow
+    # AMS-HT ids (128-191). Existing installs created before #1274 carry the
+    # stale formula which rejects every AMS-HT slot link with a CHECK violation.
+    await _migrate_widen_spoolman_slot_ams_id_range(conn)
+
     # Migration: Create spoolman_k_profile table for K-value calibration profiles linked to Spoolman spools.
     await _safe_execute(
         conn,
@@ -2315,6 +2463,62 @@ async def run_migrations(conn):
             conn, "ALTER TABLE notification_providers ADD COLUMN on_stock_break_alert BOOLEAN DEFAULT false"
         )
 
+    # Migration: Heal orphan auth-related rows left behind by user-delete
+    # on SQLite. user_oidc_links, user_totp, user_otp_codes (introduced in
+    # PR #933) and long_lived_tokens (PR #1108) all declare ON DELETE
+    # CASCADE on user_id — both predate the explicit APIKey-cleanup
+    # pattern in PR #1182. PostgreSQL enforces the cascade, but SQLite
+    # ships with FK enforcement off, so rows pointing to a deleted user
+    # persisted — blocking SSO re-login (the OIDC callback finds the
+    # orphan link, fails to resolve the missing user, and falls through
+    # to "account_inactive" instead of triggering auto_create), leaking
+    # MFA secrets, and leaving camera-stream tokens whose secret_hash is
+    # still verify()-able by lookup_prefix. See issue #1285 (#1295 review
+    # extended the cleanup to long_lived_tokens). This migration is a
+    # no-op on PostgreSQL and idempotent on SQLite.
+    async with conn.begin_nested():
+        oidc_result = await conn.execute(
+            text("DELETE FROM user_oidc_links WHERE user_id NOT IN (SELECT id FROM users)")
+        )
+        totp_result = await conn.execute(text("DELETE FROM user_totp WHERE user_id NOT IN (SELECT id FROM users)"))
+        otp_result = await conn.execute(text("DELETE FROM user_otp_codes WHERE user_id NOT IN (SELECT id FROM users)"))
+        llt_result = await conn.execute(
+            text("DELETE FROM long_lived_tokens WHERE user_id NOT IN (SELECT id FROM users)")
+        )
+    oidc_n = oidc_result.rowcount or 0
+    totp_n = totp_result.rowcount or 0
+    otp_n = otp_result.rowcount or 0
+    llt_n = llt_result.rowcount or 0
+    if oidc_n or totp_n or otp_n or llt_n:
+        logger.info(
+            "Cleaned up orphan auth rows: %d OIDC links, %d TOTP, %d OTP codes, %d long-lived tokens",
+            oidc_n,
+            totp_n,
+            otp_n,
+            llt_n,
+        )
+
+    # Migration: extend print_log_entries with archive_id, cost, energy, failure_reason,
+    # created_by_id (#1378). Statistics queries shift from PrintArchive to PrintLogEntry
+    # so reprints contribute new rows instead of overwriting the source archive's data.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN archive_id INTEGER")
+        await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN cost REAL")
+        await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN energy_kwh REAL")
+        await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN energy_cost REAL")
+        await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN failure_reason VARCHAR(100)")
+        await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN created_by_id INTEGER")
+    else:
+        await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN IF NOT EXISTS archive_id INTEGER")
+        await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN IF NOT EXISTS cost DOUBLE PRECISION")
+        await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN IF NOT EXISTS energy_kwh DOUBLE PRECISION")
+        await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN IF NOT EXISTS energy_cost DOUBLE PRECISION")
+        await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN IF NOT EXISTS failure_reason VARCHAR(100)")
+        await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN IF NOT EXISTS created_by_id INTEGER")
+    await _safe_execute(
+        conn, "CREATE INDEX IF NOT EXISTS ix_print_log_entries_archive_id ON print_log_entries (archive_id)"
+    )
+
 
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""

+ 130 - 15
backend/app/main.py

@@ -587,6 +587,36 @@ def register_expected_print(
     )
 
 
+def _compute_run_filament_grams(
+    status: str,
+    archive_filament_used_grams: float | None,
+    progress: float | int | None,
+    usage_results: list[dict] | None,
+) -> float | None:
+    """Per-run filament for PrintLogEntry, partial-aware (#1378).
+
+    For ``completed``: returns the archive's slicer estimate (which approximates
+    actual since the print finished). For failed / cancelled / stopped:
+        1. Sum of tracked spool deltas in ``usage_results`` (most accurate
+           when inventory is configured for the print).
+        2. ``estimate * progress%`` (when no inventory delta available).
+        3. ``None`` (no signal at all — e.g. progress=0 and no spool data).
+    """
+    if status == "completed":
+        return archive_filament_used_grams
+
+    tracked_grams = sum(r.get("weight_used") or 0 for r in (usage_results or []))
+    if tracked_grams > 0:
+        return round(tracked_grams, 1)
+
+    if archive_filament_used_grams:
+        scale = max(0.0, min(((progress or 0) / 100.0), 1.0))
+        if scale > 0:
+            return round(archive_filament_used_grams * scale, 1)
+
+    return None
+
+
 def _get_start_ams_mapping(data: dict, archive_id: int | None) -> list[int] | None:
     """Resolve AMS mapping for print start without consuming stored queue/reprint state."""
     stored_ams_mapping = data.get("ams_mapping")
@@ -1018,12 +1048,18 @@ async def on_ams_change(printer_id: int, ams_data: list):
                     # MQTT was deferred). The moment any filament gets inserted
                     # — Bambu RFID, 3rd-party, or even an existing-but-now-
                     # reconfigured spool — fire the deferred configuration.
-                    # The "loaded" signal is `state == 11` (Bambu's "filament fed
-                    # to extruder" code), NOT tray_type — 3rd-party spools without
-                    # readable RFID report state=11 but tray_type="" because the
-                    # AMS sensor reads no filament metadata. Requiring a non-empty
-                    # tray_type would lock out the exact users this feature targets.
-                    if not fp_type.strip() and cur_state == 11 and assignment.spool:
+                    # The "loaded" signal is state == 11 (Bambu's "filament fed to
+                    # extruder" code) OR, on firmwares that don't use the state
+                    # enum meaningfully, a non-empty tray_type when state is
+                    # NOT one of the firmware's explicit empty signals (9, 10).
+                    # state-only was wrong for firmwares that never set 11 — A1
+                    # Mini BMCU 01.07.02.00 and P1S Standard AMS 00.00.06.75 both
+                    # always report state=3 — so the replay never fired for them
+                    # (#1322). The state ∉ {9,10} guard keeps the firmware's
+                    # explicit "empty" signals authoritative over any stale
+                    # tray_type that might survive the relay's auto-clearing.
+                    loaded = cur_state == 11 or (cur_state not in (9, 10) and cur_type.strip())
+                    if not fp_type.strip() and loaded and assignment.spool:
                         try:
                             from backend.app.api.routes.inventory import (
                                 apply_spool_to_slot_via_mqtt,
@@ -1342,9 +1378,10 @@ async def on_ams_change(printer_id: int, ams_data: list):
             if sync_mode and sync_mode != "auto":
                 return  # Only sync on auto mode
 
-            # Check if weight sync is disabled
-            disable_weight_sync_str = await get_setting(db, "spoolman_disable_weight_sync")
-            disable_weight_sync = disable_weight_sync_str and disable_weight_sync_str.lower() == "true"
+            # `spoolman_disable_weight_sync` is deprecated (#1119) — weight is now
+            # always owned by per-print tracking, never by AMS auto-sync. The
+            # setting is still read by the settings UI for backwards compat but
+            # has no effect on the sync path here.
 
             # Get Spoolman URL
             spoolman_url = await get_setting(db, "spoolman_url")
@@ -1450,7 +1487,10 @@ async def on_ams_change(printer_id: int, ams_data: list):
                         result = await client.sync_ams_tray(
                             tray,
                             printer_name,
-                            disable_weight_sync=disable_weight_sync,
+                            # Per-print tracking is the only weight writer (#1119).
+                            # AMS auto-sync still maintains spool metadata / slot
+                            # assignments but no longer touches remaining_weight.
+                            disable_weight_sync=True,
                             cached_spools=cached_spools,
                             inventory_remaining=inv_remaining,
                             spoolman_spool_id_hint=hint,
@@ -1981,6 +2021,24 @@ async def on_print_start(printer_id: int, data: dict):
                 if subtask_name:
                     _active_prints[(printer_id, f"{subtask_name}.3mf")] = archive.id
 
+                # Start timelapse session if external camera is enabled (#1353).
+                # The two new-archive paths below also call start_session, but
+                # queue / VP-dispatched prints land here in the expected-archive
+                # branch and used to skip it entirely — so the timelapse session
+                # never started, no frames were captured, and the post-print
+                # stitch silently returned None.
+                if printer.external_camera_enabled and printer.external_camera_url:
+                    from backend.app.services.layer_timelapse import start_session
+
+                    start_session(
+                        printer_id,
+                        archive.id,
+                        printer.external_camera_url,
+                        printer.external_camera_type or "mjpeg",
+                        snapshot_url=printer.external_camera_snapshot_url,
+                    )
+                    logger.info("Started layer timelapse for printer %s, expected archive %s", printer_id, archive.id)
+
                 # Inject ams_mapping into usage tracker session — the session was created
                 # before expected-print promotion, so it may have ams_mapping=None when
                 # the MQTT request topic subscription failed (common on P1S/A1).
@@ -3517,9 +3575,33 @@ async def on_print_complete(printer_id: int, data: dict):
                 if archive.created_by_id is None and _print_user_id is not None:
                     archive.created_by_id = _print_user_id
                 p_info = printer_manager.get_printer(printer_id)
+                # Per-run actuals — written to PrintLogEntry so stats reflect
+                # what THIS print actually used, not the source archive's
+                # first-run values (#1378). Helper handles the partial-print
+                # math (failed / cancelled / stopped get scaled to progress
+                # or to tracked spool deltas).
+                _run_status = data.get("status", "completed")
+                _run_grams = _compute_run_filament_grams(
+                    _run_status,
+                    archive.filament_used_grams,
+                    data.get("progress"),
+                    usage_results,
+                )
+
+                # Per-run cost — prefer usage_results sum. For partial prints
+                # we deliberately skip the topup-to-estimate logic in
+                # usage_tracker (which assumes the print completed); the raw
+                # tracked-spool sum is closer to what THIS run actually cost.
+                _run_cost: float | None = None
+                if usage_results:
+                    _run_cost = sum(r.get("cost") or 0 for r in usage_results) or None
+                if _run_cost is None and _run_status == "completed":
+                    _run_cost = archive.cost
+
                 await write_log_entry(
                     db,
-                    status=data.get("status", "completed"),
+                    archive_id=archive.id,
+                    status=_run_status,
                     print_name=archive.print_name,
                     printer_name=p_info.name if p_info else None,
                     printer_id=printer_id,
@@ -3527,8 +3609,11 @@ async def on_print_complete(printer_id: int, data: dict):
                     completed_at=archive.completed_at,
                     filament_type=archive.filament_type,
                     filament_color=archive.filament_color,
-                    filament_used_grams=archive.filament_used_grams,
+                    filament_used_grams=_run_grams,
+                    cost=_run_cost,
+                    failure_reason=archive.failure_reason,
                     thumbnail_path=archive.thumbnail_path,
+                    created_by_id=archive.created_by_id,
                     created_by_username=_print_user_info.get("username") if _print_user_info else None,
                 )
                 await db.commit()
@@ -3588,10 +3673,40 @@ async def on_print_complete(printer_id: int, data: dict):
 
                 energy_cost_per_kwh = await get_setting(db, "energy_cost_per_kwh")
                 cost_per_kwh = float(energy_cost_per_kwh) if energy_cost_per_kwh else 0.15
-                archive.energy_kwh = energy_used
-                archive.energy_cost = round(energy_used * cost_per_kwh, 3)
+                energy_cost_value = round(energy_used * cost_per_kwh, 3)
+
+                # First-run-only overwrite of archive.energy_kwh / energy_cost so a
+                # reprint doesn't visually clobber the source archive's energy data
+                # (#1378). Reprint energy lives in the matching PrintLogEntry below.
+                from sqlalchemy import func
+
+                from backend.app.models.print_log import PrintLogEntry
+
+                existing_runs = await db.scalar(
+                    select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == archive_id)
+                )
+                if (existing_runs or 0) <= 1:
+                    # 0 = legacy archive that pre-dates per-run logging; 1 = the row
+                    # we just wrote for THIS print. Either way it's the first run.
+                    archive.energy_kwh = energy_used
+                    archive.energy_cost = energy_cost_value
+
+                # Backfill the latest PrintLogEntry for this archive with energy
+                # (write_log_entry above ran before this background task completed,
+                # so energy fields are still NULL on that row).
+                latest_run = await db.execute(
+                    select(PrintLogEntry)
+                    .where(PrintLogEntry.archive_id == archive_id)
+                    .order_by(PrintLogEntry.id.desc())
+                    .limit(1)
+                )
+                run_row = latest_run.scalar_one_or_none()
+                if run_row is not None:
+                    run_row.energy_kwh = energy_used
+                    run_row.energy_cost = energy_cost_value
+
                 await db.commit()
-                logger.info("[ENERGY-BG] Saved: %s kWh, cost=%s", energy_used, archive.energy_cost)
+                logger.info("[ENERGY-BG] Saved: %s kWh, cost=%s", energy_used, energy_cost_value)
         except Exception as e:
             logger.warning("[ENERGY-BG] Failed: %s", e)
 

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

@@ -31,6 +31,11 @@ class APIKey(Base):
     can_control_printer: Mapped[bool] = mapped_column(Boolean, default=False)  # Start/stop/cancel
     can_read_status: Mapped[bool] = mapped_column(Boolean, default=True)  # Query status
     can_access_cloud: Mapped[bool] = mapped_column(Boolean, default=False)  # Read /cloud/* on the owner's behalf
+    # Narrowly-scoped settings write: only POST /settings/electricity-price.
+    # Lets HA/Tibber-style automations push dynamic tariff updates without
+    # granting full SETTINGS_UPDATE (which is denied for API keys because it
+    # could rewrite SMTP/LDAP/MQTT credentials).
+    can_update_energy_cost: Mapped[bool] = mapped_column(Boolean, default=False)
 
     # Optional scope limits
     printer_ids: Mapped[list | None] = mapped_column(JSON, nullable=True)  # null = all printers

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

@@ -78,6 +78,13 @@ class PrintArchive(Base):
 
     # Timestamps
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    # Soft-delete sentinel (#1343). When non-null, the UI hides this archive
+    # from listings (its files have already been removed from disk) but the
+    # stats endpoint keeps counting it — deleting nine of ten Benchies no
+    # longer wipes their filament / time / cost contribution from Quick Stats.
+    # The opt-in "Also remove from statistics" checkbox in the delete dialog
+    # bypasses the soft-delete path and hard-deletes the row.
+    deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None, index=True)
 
     # User tracking (who uploaded/created this archive)
     created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)

+ 50 - 2
backend/app/models/oidc_provider.py

@@ -2,7 +2,18 @@ from __future__ import annotations
 
 from datetime import datetime
 
-from sqlalchemy import Boolean, CheckConstraint, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
+from sqlalchemy import (
+    Boolean,
+    CheckConstraint,
+    DateTime,
+    ForeignKey,
+    Integer,
+    LargeBinary,
+    String,
+    Text,
+    UniqueConstraint,
+    func,
+)
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
@@ -29,6 +40,19 @@ class OIDCProvider(Base):
             "auto_link_existing_accounts = FALSE OR email_claim != 'email' OR require_email_verified = TRUE",
             name="ck_auto_link_requires_verified_email_claim",
         ),
+        # All-or-nothing icon-cache record (#1333). The application keeps the
+        # triplet consistent via _fetch_icon_or_400 + DELETE /icon, but a CHECK
+        # constraint at the DB layer prevents drift from raw SQL maintenance
+        # scripts, manual UPDATEs during incident recovery, etc.
+        # Fresh installs (SQLite + PostgreSQL) get this via metadata.create_all.
+        # Stale PostgreSQL installs get it via ALTER TABLE ADD CONSTRAINT in
+        # run_migrations. SQLite cannot ADD CONSTRAINT to an existing table —
+        # stale SQLite installs rely on the application layer, the same
+        # trade-off documented for the default_group_id FK ON DELETE SET NULL.
+        CheckConstraint(
+            "(icon_data IS NULL) = (icon_content_type IS NULL) AND (icon_content_type IS NULL) = (icon_etag IS NULL)",
+            name="ck_oidc_icon_triplet_co_null",
+        ),
     )
 
     id: Mapped[int] = mapped_column(primary_key=True)
@@ -79,8 +103,32 @@ class OIDCProvider(Base):
     default_group_id: Mapped[int | None] = mapped_column(
         Integer, ForeignKey("groups.id", ondelete="SET NULL"), nullable=True, default=None
     )
-    # Optional icon URL (SVG/PNG) shown on the login button
+    # Optional icon URL the admin entered. The actual image bytes are fetched
+    # server-side and cached in icon_data — the SPA never hotlinks this URL
+    # (would require loosening img-src CSP; see PR #1333 / issue #1333).
     icon_url: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
+    # Cached icon bytes (PNG/JPEG/WebP/GIF). Marked deferred=True so that
+    # list-style queries (`GET /oidc/providers`) don't pull the BLOB on every
+    # login-page render — only the GET /icon endpoint un-defers it via
+    # `select(...).options(undefer(...))`.
+    icon_data: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True, default=None, deferred=True)
+    # MIME type derived from the fetched icon (e.g. "image/png"). Also serves
+    # as the "has-icon" indicator — checked instead of icon_data so we never
+    # accidentally trigger an async lazy-load on the deferred BLOB column.
+    # Width 20 is plenty: the longest whitelisted value is "image/jpeg" (10
+    # chars). Tighter than 50 so the schema documents the intent.
+    icon_content_type: Mapped[str | None] = mapped_column(String(20), nullable=True, default=None)
+    # SHA-256 hex of icon_data, served as the ETag header so clients can
+    # revalidate via If-None-Match and receive 304 Not Modified.
+    icon_etag: Mapped[str | None] = mapped_column(String(64), nullable=True, default=None)
+
+    @property
+    def has_icon(self) -> bool:
+        """True when cached icon bytes exist. Reads the non-deferred
+        ``icon_content_type`` column so accessing this never triggers an
+        async lazy-load on the deferred ``icon_data`` BLOB."""
+        return self.icon_content_type is not None
+
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 

+ 14 - 1
backend/app/models/print_log.py

@@ -1,6 +1,6 @@
 from datetime import datetime
 
-from sqlalchemy import DateTime, Float, Integer, String, func
+from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, func
 from sqlalchemy.orm import Mapped, mapped_column
 
 from backend.app.core.database import Base
@@ -11,11 +11,19 @@ class PrintLogEntry(Base):
 
     This is a separate table from archives/queue — clearing the log
     never touches archives or queue items.
+
+    archive_id is a nullable FK so log entries survive archive deletion (ON
+    DELETE SET NULL). Aggregating runs per archive — for the per-archive
+    "Print Log" view and for statistics that should not double-count
+    overwritten archives (#1378) — is done via WHERE archive_id = X.
     """
 
     __tablename__ = "print_log_entries"
 
     id: Mapped[int] = mapped_column(primary_key=True)
+    archive_id: Mapped[int | None] = mapped_column(
+        ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True, index=True
+    )
     print_name: Mapped[str | None] = mapped_column(String(255))
     printer_name: Mapped[str | None] = mapped_column(String(255))
     printer_id: Mapped[int | None] = mapped_column(Integer)
@@ -26,6 +34,11 @@ class PrintLogEntry(Base):
     filament_type: Mapped[str | None] = mapped_column(String(50))
     filament_color: Mapped[str | None] = mapped_column(String(50))
     filament_used_grams: Mapped[float | None] = mapped_column(Float)
+    cost: Mapped[float | None] = mapped_column(Float)
+    energy_kwh: Mapped[float | None] = mapped_column(Float)
+    energy_cost: Mapped[float | None] = mapped_column(Float)
+    failure_reason: Mapped[str | None] = mapped_column(String(100))
     thumbnail_path: Mapped[str | None] = mapped_column(String(500))
+    created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
     created_by_username: Mapped[str | None] = mapped_column(String(100))
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

+ 8 - 1
backend/app/models/spoolman_slot_assignment.py

@@ -27,7 +27,14 @@ class SpoolmanSlotAssignment(Base):
 
     __table_args__ = (
         UniqueConstraint("printer_id", "ams_id", "tray_id", name="uq_slot_assignment"),
-        CheckConstraint("(ams_id >= 0 AND ams_id <= 7) OR ams_id = 255", name="ck_ams_id_range"),
+        # 0-7: standard AMS units. 128-191: AMS-HT (each unit uses ams_id 128+,
+        # single tray). 255: external / VT tray. Matches the value range the
+        # internal `spool_assignment` table accepts. See #1274 — H2C with
+        # AMS-HT on the left nozzle reports ams_id=128.
+        CheckConstraint(
+            "(ams_id >= 0 AND ams_id <= 7) OR (ams_id >= 128 AND ams_id <= 191) OR ams_id = 255",
+            name="ck_ams_id_range",
+        ),
         CheckConstraint("tray_id >= 0 AND tray_id <= 3", name="ck_tray_id_range"),
     )
 

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

@@ -11,6 +11,7 @@ class APIKeyCreate(BaseModel):
     can_control_printer: bool = False
     can_read_status: bool = True
     can_access_cloud: bool = False  # Read /cloud/* on the creator's behalf — default off (#1182)
+    can_update_energy_cost: bool = False  # POST /settings/electricity-price only (#1356)
     printer_ids: list[int] | None = None  # null = all printers
     expires_at: datetime | None = None
 
@@ -23,6 +24,7 @@ class APIKeyUpdate(BaseModel):
     can_control_printer: bool | None = None
     can_read_status: bool | None = None
     can_access_cloud: bool | None = None
+    can_update_energy_cost: bool | None = None
     printer_ids: list[int] | None = None
     enabled: bool | None = None
     expires_at: datetime | None = None
@@ -39,6 +41,7 @@ class APIKeyResponse(BaseModel):
     can_control_printer: bool
     can_read_status: bool
     can_access_cloud: bool
+    can_update_energy_cost: bool
     printer_ids: list[int] | None
     enabled: bool
     last_used: datetime | None

+ 9 - 0
backend/app/schemas/archive.py

@@ -100,6 +100,15 @@ class ArchiveResponse(BaseModel):
     created_by_id: int | None = None
     created_by_username: str | None = None
 
+    # Per-archive run aggregates (#1378). Computed from PrintLogEntry — one
+    # row per actual print event — so reprints contribute to these counters
+    # without overwriting the source archive's first-run data.
+    run_count: int = 0
+    last_run_at: datetime | None = None
+    total_filament_actual_grams: float | None = None
+    successful_run_count: int = 0
+    failed_run_count: int = 0
+
     @model_validator(mode="after")
     def compute_object_count(self) -> "ArchiveResponse":
         """Compute object_count from extra_data.printable_objects if not set."""

+ 48 - 1
backend/app/schemas/auth.py

@@ -93,6 +93,24 @@ class UserResponse(BaseModel):
         from_attributes = True
 
 
+class LDAPSearchResultResponse(BaseModel):
+    """One match from GET /auth/ldap/search — surfaced in the admin UI."""
+
+    username: str
+    email: str | None = None
+    display_name: str | None = None
+    dn: str
+    already_provisioned: bool = False  # True if this username already exists as a BamBuddy user
+
+
+class LDAPProvisionRequest(BaseModel):
+    """Body for POST /auth/ldap/provision. Username is re-resolved via the
+    service-account bind, so the request only carries the directory username
+    the admin picked from the search results."""
+
+    username: str = Field(..., max_length=150)
+
+
 class ChangePasswordRequest(BaseModel):
     current_password: str = Field(..., max_length=256)  # M-NEW-3: cap before pbkdf2
     new_password: str = Field(..., min_length=8, max_length=256)
@@ -310,11 +328,34 @@ def _validate_email_claim_name(v: str) -> str:
 
 
 def _validate_icon_url(v: str | None) -> str | None:
-    """Reject non-HTTPS icon URLs to prevent SSRF / mixed-content issues."""
+    """Reject non-HTTPS icon URLs and SSRF-unsafe hosts.
+
+    Delegates to the runtime SSRF guard ``assert_safe_public_https_url``
+    so the Pydantic layer enforces the same allowlist as the fetcher —
+    no policy drift between schema validation and SSRF check. Without
+    this delegation the validator covered only ``is_private | is_loopback
+    | is_link_local`` while the runtime additionally rejected numeric-
+    encoded IPs, cloud-metadata endpoints, multicast, unspecified, and
+    IPv4-mapped IPv6.
+
+    Lazy-imported because ``_oidc_helpers`` lives under ``api/routes/``
+    and schemas avoid top-level imports from that layer (matches the
+    existing pattern in ``_validate_issuer_url`` which lazy-imports
+    ``ipaddress``).
+    """
     if v is None:
         return v
     if not v.startswith("https://"):
+        # Surface the same wording the runtime guard would use, but pre-
+        # checked here so the user-facing error doesn't depend on the
+        # runtime call path.
         raise ValueError("icon_url must start with https://")
+    from backend.app.api.routes._oidc_helpers import assert_safe_public_https_url
+
+    try:
+        assert_safe_public_https_url(v)
+    except ValueError as exc:
+        raise ValueError(f"icon_url: {exc}") from exc
     return v
 
 
@@ -474,6 +515,12 @@ class OIDCProviderResponse(BaseModel):
     require_email_verified: bool = True
     icon_url: str | None = None
     default_group_id: int | None = None
+    # Set explicitly in the route handler from `icon_content_type is not None`
+    # rather than `@computed_field` (project policy) or `icon_data is not None`
+    # (would trigger an async lazy-load on the deferred BLOB column).
+    # Required (no default) so Pydantic fails loudly if any code path skips
+    # `_build_provider_response` and tries `model_validate(provider)` directly.
+    has_icon: bool
 
     class Config:
         from_attributes = True

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

@@ -5,6 +5,7 @@ from pydantic import BaseModel
 
 class PrintLogEntrySchema(BaseModel):
     id: int
+    archive_id: int | None = None
     print_name: str | None = None
     printer_name: str | None = None
     printer_id: int | None = None
@@ -15,7 +16,12 @@ class PrintLogEntrySchema(BaseModel):
     filament_type: str | None = None
     filament_color: str | None = None
     filament_used_grams: float | None = None
+    cost: float | None = None
+    energy_kwh: float | None = None
+    energy_cost: float | None = None
+    failure_reason: str | None = None
     thumbnail_path: str | None = None
+    created_by_id: int | None = None
     created_by_username: str | None = None
     created_at: datetime
 

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

@@ -104,6 +104,13 @@ class PrintQueueItemResponse(BaseModel):
     # Nested info for UI (populated in route)
     archive_name: str | None = None
     archive_thumbnail: str | None = None
+    # True when the linked archive has been soft-deleted (its files are gone
+    # from disk). In that case the *archive_name* / *archive_thumbnail* /
+    # downstream metadata fields are intentionally left None so the frontend
+    # doesn't 404-storm the now-missing thumbnail / plates / plate-thumbnail
+    # endpoints (#1348 follow-up). Frontends can render a "source deleted"
+    # badge based on this flag.
+    archive_deleted: bool = False
     library_file_name: str | None = None  # Name of library file (if library_file_id is set)
     library_file_thumbnail: str | None = None  # Thumbnail of library file
     printer_name: str | None = None

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

@@ -109,6 +109,17 @@ class SliceRequest(BaseModel):
         default=False,
         description="If true, request a 3MF response with embedded G-code instead of raw G-code.",
     )
+    bed_type: str | None = Field(
+        default=None,
+        max_length=64,
+        description=(
+            "Override the process preset's curr_bed_type for this slice. Canonical "
+            "BambuStudio / OrcaSlicer values: 'Cool Plate', 'Engineering Plate', "
+            "'High Temp Plate', 'Textured PEI Plate', 'Smooth PEI Plate', "
+            "'Cool Plate (SuperTack)', 'Supertack Plate'. None ⇒ inherit from the "
+            "process preset unchanged (#1337)."
+        ),
+    )
 
     @model_validator(mode="after")
     def normalise_preset_refs(self) -> "SliceRequest":

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

@@ -116,6 +116,10 @@ class SpoolBase(BaseModel):
     # User-defined category + per-spool low-stock threshold override (#729).
     category: str | None = Field(default=None, max_length=50)
     low_stock_threshold_pct: int | None = Field(default=None, ge=1, le=99)
+    # Free-text storage location, distinct from `location` (AMS slot
+    # assignment). Column has lived on the ORM since the inventory rework
+    # but was missing from this schema, so writes were silently dropped (#1291).
+    storage_location: str | None = Field(default=None, max_length=255)
 
 
 class SpoolCreate(SpoolBase):
@@ -164,6 +168,7 @@ class SpoolUpdate(BaseModel):
     # User-defined category + per-spool low-stock threshold override (#729).
     category: str | None = Field(default=None, max_length=50)
     low_stock_threshold_pct: int | None = Field(default=None, ge=1, le=99)
+    storage_location: str | None = Field(default=None, max_length=255)
 
 
 class SpoolKProfileBase(BaseModel):

+ 130 - 3
backend/app/services/archive.py

@@ -827,6 +827,48 @@ class ProjectPageParser:
             return False
 
 
+async def _null_print_log_thumbnail_paths(db: AsyncSession, archive_id: int) -> None:
+    """NULL thumbnail_path on PrintLogEntry rows linked to *archive_id*.
+
+    Called from both soft- and hard-delete paths before the archive's files
+    leave disk. The FK on PrintLogEntry.archive_id is ON DELETE SET NULL so
+    log rows survive the archive — without this clear, their cached
+    thumbnail_path would still point at a deleted file and the print-log
+    view would 404-storm on every render (#1348 follow-up). Lazy-NULL on
+    the GET route self-heals stragglers (e.g. failed prints that never had
+    a thumbnail written), but eager clear here avoids the one-time storm.
+    """
+    from sqlalchemy import update as sa_update
+
+    from backend.app.models.print_log import PrintLogEntry
+
+    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.
+    """
+    from sqlalchemy import update as sa_update
+
+    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")
+    )
+
+
 class ArchiveService:
     """Service for archiving print jobs."""
 
@@ -854,9 +896,13 @@ class ArchiveService:
         """
         from sqlalchemy import func
 
+        # Soft-deleted archives don't appear in the listing (#1343), so they
+        # mustn't influence the duplicate-group counts either — otherwise a
+        # group with 1 live + 4 soft-deleted would still be flagged as a
+        # duplicate even though the user only sees one row.
         result = await self.db.execute(
             select(PrintArchive.content_hash)
-            .where(PrintArchive.content_hash.isnot(None))
+            .where(PrintArchive.content_hash.isnot(None), PrintArchive.deleted_at.is_(None))
             .group_by(PrintArchive.content_hash)
             .having(func.count(PrintArchive.id) > 1)
         )
@@ -866,7 +912,11 @@ class ArchiveService:
         # This avoids marking different files with the same name as duplicates
         result = await self.db.execute(
             select(func.lower(PrintArchive.print_name), PrintArchive.content_hash)
-            .where(PrintArchive.print_name.isnot(None), PrintArchive.content_hash.isnot(None))
+            .where(
+                PrintArchive.print_name.isnot(None),
+                PrintArchive.content_hash.isnot(None),
+                PrintArchive.deleted_at.is_(None),
+            )
             .group_by(func.lower(PrintArchive.print_name), PrintArchive.content_hash)
             .having(func.count(PrintArchive.id) > 1)
         )
@@ -895,6 +945,7 @@ class ArchiveService:
                     and_(
                         PrintArchive.content_hash == content_hash,
                         PrintArchive.id != archive_id,
+                        PrintArchive.deleted_at.is_(None),
                     )
                 )
                 .order_by(PrintArchive.created_at.desc())
@@ -914,7 +965,7 @@ class ArchiveService:
         # Prefer strict name+hash matching when hash exists; fallback to name-only for legacy/manual
         # archives that may not have a content_hash.
         if print_name or makerworld_model_id:
-            conditions = [PrintArchive.id != archive_id]
+            conditions = [PrintArchive.id != archive_id, PrintArchive.deleted_at.is_(None)]
 
             name_conditions = []
             if print_name:
@@ -1198,6 +1249,10 @@ class ArchiveService:
         query = (
             select(PrintArchive)
             .options(selectinload(PrintArchive.project), selectinload(PrintArchive.created_by))
+            # Hide soft-deleted rows from the listings (#1343). The stats
+            # endpoint deliberately does NOT add this filter so deleted
+            # archives keep contributing to Quick Stats.
+            .where(PrintArchive.deleted_at.is_(None))
             .order_by(PrintArchive.created_at.desc())
         )
 
@@ -1219,6 +1274,71 @@ class ArchiveService:
         result = await self.db.execute(query)
         return list(result.scalars().all())
 
+    async def soft_delete_archive(self, archive_id: int) -> bool:
+        """Soft-delete an archive (#1343).
+
+        Removes the archive's files from disk (it disappears from the listings
+        and frees the storage) but flips the row's ``deleted_at`` so the stats
+        endpoint keeps counting its filament / energy / time / cost. The user
+        can opt into a hard delete via the "Also remove from statistics"
+        checkbox in the delete dialog — that path calls ``delete_archive``
+        instead and removes the row entirely.
+        """
+        archive = await self.get_archive(archive_id)
+        if not archive:
+            return False
+        if archive.deleted_at is not None:
+            # Already soft-deleted; nothing to do. The files were purged on
+            # the first soft-delete pass so there is nothing left on disk.
+            return True
+
+        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)
+        archive.deleted_at = datetime.now(timezone.utc)
+        await self.db.commit()
+
+        if dir_to_delete:
+            shutil.rmtree(dir_to_delete, ignore_errors=True)
+        return True
+
+    def _resolve_archive_dir_for_delete(self, archive: PrintArchive) -> Path | None:
+        """Return the on-disk directory that backs *archive*, after the same
+        two safety checks ``delete_archive`` enforces.
+
+        Extracted so soft-delete and hard-delete share the path-resolution
+        rules. Returns ``None`` when nothing should be removed from disk
+        (no file_path, path outside archive_dir, or path not deep enough).
+        """
+        if not archive.file_path or not archive.file_path.strip():
+            logger.error(
+                f"SECURITY: Refusing to delete files for archive {archive.id} - "
+                f"file_path is empty or invalid: '{archive.file_path}'"
+            )
+            return None
+
+        file_path = settings.base_dir / archive.file_path
+        if not file_path.exists():
+            return None
+
+        archive_dir = file_path.parent
+        try:
+            relative_path = archive_dir.resolve().relative_to(settings.archive_dir.resolve())
+        except ValueError:
+            logger.error(
+                f"SECURITY: Refusing to delete archive {archive.id} - "
+                f"path {archive_dir} is outside archive directory {settings.archive_dir}"
+            )
+            return None
+        if len(relative_path.parts) < 1:
+            logger.error(
+                f"SECURITY: Refusing to delete archive {archive.id} - "
+                f"path {archive_dir} is not deep enough inside archive directory"
+            )
+            return None
+        return archive_dir
+
     async def delete_archive(self, archive_id: int) -> bool:
         """Delete an archive and its files."""
         archive = await self.get_archive(archive_id)
@@ -1266,6 +1386,13 @@ class ArchiveService:
                 f"file_path is empty or invalid: '{archive.file_path}'"
             )
 
+        # NULL stale thumbnail_path on linked PrintLogEntries before the FK
+        # SET-NULL cascade fires. The on-disk file is about to be removed by
+        # the rmtree below, so the path on any surviving log entry (archive_id
+        # gets SET NULL by the FK) would otherwise point at a missing file
+        # and produce 404 storms in the print-log view (#1348-followup).
+        await _null_print_log_thumbnail_paths(self.db, archive_id)
+
         # Delete database record FIRST — if the commit fails (e.g. database locked
         # during concurrent bulk deletes), the files stay on disk and nothing is lost.
         await self.db.delete(archive)

+ 4 - 1
backend/app/services/archive_comparison.py

@@ -201,13 +201,15 @@ class ArchiveComparisonService:
         # Find similar archives
         similar = []
 
-        # By same print name
+        # By same print name (soft-deleted archives are hidden from the UI
+        # per #1343 so they must not surface here as "similar" either).
         if reference.print_name:
             result = await self.db.execute(
                 select(PrintArchive)
                 .where(
                     PrintArchive.id != archive_id,
                     PrintArchive.print_name == reference.print_name,
+                    PrintArchive.deleted_at.is_(None),
                 )
                 .order_by(PrintArchive.created_at.desc())
                 .limit(limit)
@@ -233,6 +235,7 @@ class ArchiveComparisonService:
                 .where(
                     PrintArchive.id != archive_id,
                     PrintArchive.content_hash == reference.content_hash,
+                    PrintArchive.deleted_at.is_(None),
                 )
                 .order_by(PrintArchive.created_at.desc())
                 .limit(limit - len(similar))

+ 21 - 1
backend/app/services/background_dispatch.py

@@ -35,6 +35,16 @@ from backend.app.services.printer_manager import printer_manager
 
 logger = logging.getLogger(__name__)
 
+# Bambu firmware states that mean the project_file has actually been accepted
+# and the printer is now processing / running / paused mid-print. Used by the
+# direct-dispatch verifier (#1370): a transition into one of these states means
+# the print landed, anything else (e.g. FINISH -> IDLE after the user dismisses
+# a post-print prompt) is NOT a valid "command landed" signal even though the
+# state value did change. Mirrors the same constant in print_scheduler.py —
+# kept duplicated rather than imported to avoid coupling the two services and
+# to keep the value at the point of use.
+_ACTIVE_PRINT_STATES: frozenset[str] = frozenset({"PREPARE", "SLICING", "RUNNING", "PAUSE"})
+
 
 class DispatchJobCancelled(Exception):
     """Raised when a dispatch job is cancelled by the user."""
@@ -990,9 +1000,19 @@ class BackgroundDispatchService:
                 # within the remaining timeout and still surface a transition.
                 continue
             last_status = state
-            if state.state != pre_state:
+            if state.state in _ACTIVE_PRINT_STATES:
+                # Printer is actively processing the job. We do NOT accept
+                # arbitrary state transitions: a printer going FINISH -> IDLE
+                # (user dismissed the post-print prompt without accepting our
+                # project_file) would otherwise look like "command landed"
+                # and the dispatch job would be marked successful even though
+                # no print is running (#1370).
                 return True
             if pre_subtask_id is not None and state.subtask_id is not None and state.subtask_id != pre_subtask_id:
+                # Printer picked up the job (subtask_id advanced). H2D can
+                # sit at FINISH for ~50 s after accepting project_file before
+                # transitioning to PREPARE, but the subtask_id flips to our
+                # submission_id almost immediately (#1078).
                 return True
         logger.warning(
             "Printer %s (%d) did not respond to print command within %.0fs "

+ 38 - 15
backend/app/services/bambu_cloud.py

@@ -14,6 +14,24 @@ logger = logging.getLogger(__name__)
 BAMBU_API_BASE = "https://api.bambulab.com"
 BAMBU_API_BASE_CN = "https://api.bambulab.cn"
 
+# Client identity sent to Bambu Lab's cloud services. We identify honestly as
+# Bambuddy — the URL in parens makes the source unambiguous so Bambu can
+# distinguish our traffic from impersonators. This is the opposite of what the
+# OrcaSlicer fork was called out for in the May 2026 Bambu Lab blog post
+# ("Setting the record straight on cloud access and community"): we do not
+# introduce ourselves as official Bambu Studio.
+_USER_AGENT = "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)"
+
+# The `/v1/iot-service/api/slicer/setting` endpoint requires a `version` query
+# parameter in the XX.YY.ZZ.WW format Bambu Studio releases use (without it the
+# API returns HTTP 400 "field 'version' is not set"; non-matching formats like
+# "bambuddy-1.0" return HTTP 422 "Invalid input parameters"). However, Bambu's
+# server accepts ANY value within that format — it doesn't validate against a
+# release manifest. We therefore use a neutral "1.0.0.0" placeholder that does
+# not impersonate any real Bambu Studio release. Our client identity is in the
+# User-Agent header.
+_SLICER_API_VERSION = "1.0.0.0"
+
 
 class BambuCloudError(Exception):
     """Base exception for Bambu Cloud errors."""
@@ -74,7 +92,7 @@ class BambuCloudService:
         """Get headers for authenticated requests."""
         headers = {
             "Content-Type": "application/json",
-            "User-Agent": "Bambuddy/1.0",
+            "User-Agent": _USER_AGENT,
         }
         if self.access_token:
             headers["Authorization"] = f"Bearer {self.access_token}"
@@ -174,24 +192,25 @@ class BambuCloudService:
             code: 6-digit TOTP code from authenticator app
         """
         try:
-            # TFA endpoint is on bambulab.com, NOT api.bambulab.com
-            # Requires browser-like headers to bypass Cloudflare
+            # TFA endpoint is on bambulab.com, NOT api.bambulab.com.
+            # We previously sent a Chrome User-Agent plus Origin/Referer headers
+            # under the assumption Cloudflare would block bot-identified
+            # requests. Verified 2026-05-12 via curl that the endpoint accepts
+            # honest "Bambuddy/X.Y.Z" identification cleanly (HTTP 400 with the
+            # expected application-level "Login failed" JSON, no Cloudflare
+            # interstitial). Browser-impersonation removed to stay clearly on
+            # the right side of Bambu Lab's "no falsified client identity" line.
             tfa_url = "https://bambulab.com/api/sign-in/tfa"
             if "bambulab.cn" in self.base_url:
                 tfa_url = "https://bambulab.cn/api/sign-in/tfa"
 
-            browser_headers = {
-                "Content-Type": "application/json",
-                "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
-                "Accept": "application/json, text/plain, */*",
-                "Accept-Language": "en-US,en;q=0.9",
-                "Origin": "https://bambulab.com",
-                "Referer": "https://bambulab.com/",
-            }
-
             response = await self._client.post(
                 tfa_url,
-                headers=browser_headers,
+                headers={
+                    "Content-Type": "application/json",
+                    "User-Agent": _USER_AGENT,
+                    "Accept": "application/json",
+                },
                 json={
                     "tfaKey": tfa_key,
                     "tfaCode": code,
@@ -281,12 +300,16 @@ class BambuCloudService:
         except httpx.RequestError as e:
             raise BambuCloudError(f"Request failed: {e}")
 
-    async def get_slicer_settings(self, version: str = "02.04.00.70") -> dict:
+    async def get_slicer_settings(self, version: str = _SLICER_API_VERSION) -> dict:
         """
         Get all slicer settings (filament, printer, process presets).
 
         Args:
-            version: Slicer version string
+            version: Slicer version string. Bambu's API requires the XX.YY.ZZ.WW
+                format but does not validate against a release manifest — we
+                default to the neutral _SLICER_API_VERSION placeholder so we
+                never claim to be a specific Bambu Studio build. Callers should
+                normally use the default.
         """
         if not self.is_authenticated:
             raise BambuCloudAuthError("Not authenticated")

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

@@ -1714,13 +1714,23 @@ class BambuMQTTClient:
         # 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 when power_on_flag=False: printer shutdown sends all-zero bits which would
-        # wipe all slot data and cause auto-unlink to remove spool assignments (#765)
+        # 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 and power_on:
+        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:
@@ -1752,8 +1762,6 @@ class BambuMQTTClient:
                             tray["tray_uuid"] = "00000000000000000000000000000000"
                             tray["tray_info_idx"] = ""
                             tray["remain"] = 0
-            except (ValueError, TypeError) as e:
-                logger.debug("[%s] Could not parse tray_exist_bits: %s", self.serial_number, e)
 
         self.state.raw_data["ams"] = merged_ams
 
@@ -2779,6 +2787,7 @@ class BambuMQTTClient:
         current_file = self.state.gcode_file or self.state.current_print
         is_new_print = (
             self.state.state == "RUNNING"
+            and self._previous_gcode_state is not None  # #1304: skip on first push after Bambuddy startup
             and self._previous_gcode_state != "RUNNING"
             and current_file
             and not self._was_running  # Prevent duplicates when resuming from PAUSE
@@ -4584,17 +4593,27 @@ class BambuMQTTClient:
             logger.warning("[%s] Cannot set AMS filament setting: not connected", self.serial_number)
             return False
 
-        # Calculate mqtt IDs based on AMS type
+        # Calculate mqtt IDs based on AMS type.
+        # External-spool convention verified against a BambuStudio→X1C packet capture
+        # (issue #1279, May 2026): for `ams_filament_setting` Studio sends the
+        # *global* tray index in `tray_id`, not a local position within the virtual
+        # unit. The printer's response echoes `tray_id: 0` (slot position), which
+        # is what the original code was matching — but the request and response
+        # use different semantics for that field. Sending `tray_id: 0` is what
+        # the P1S in #1279 rejected with `result: "fail"`.
         if ams_id == 255:
             vt_tray = self.state.raw_data.get("vt_tray", []) if self.state.raw_data else []
             if len(vt_tray) > 1:
                 # Dual external slots (H2D): each ext slot is its own virtual AMS unit
-                # (254=ext-L / slot 0, 255=ext-R / slot 1)
+                # (254=ext-L / slot 0, 255=ext-R / slot 1). The dual case is NOT
+                # covered by the X1C capture — left at `mqtt_tray_id = 0` until a
+                # captured Studio→H2D exchange confirms the correct value.
                 mqtt_ams_id = 254 + tray_id
+                mqtt_tray_id = 0
             else:
-                # Single external slot (X1C, P1S, A1): always ams_id=255
+                # Single external slot (X1C, P1S, A1): global tray_id=254.
                 mqtt_ams_id = 255
-            mqtt_tray_id = 0
+                mqtt_tray_id = 254
             slot_id = 0
         elif ams_id <= 3:
             mqtt_ams_id = ams_id
@@ -4649,16 +4668,18 @@ class BambuMQTTClient:
             logger.warning("[%s] Cannot reset AMS slot: not connected", self.serial_number)
             return False
 
-        # Calculate mqtt IDs based on AMS type
+        # Calculate mqtt IDs based on AMS type — same convention as
+        # ams_set_filament_setting above. See its comment for the #1279 capture rationale.
         if ams_id == 255:
             vt_tray = self.state.raw_data.get("vt_tray", []) if self.state.raw_data else []
             if len(vt_tray) > 1:
                 # Dual external slots (H2D): each ext slot is its own virtual AMS unit
                 mqtt_ams_id = 254 + tray_id
+                mqtt_tray_id = 0
             else:
-                # Single external slot (X1C, P1S, A1): always ams_id=255
+                # Single external slot (X1C, P1S, A1): global tray_id=254.
                 mqtt_ams_id = 255
-            mqtt_tray_id = 0
+                mqtt_tray_id = 254
             slot_id = 0
         elif ams_id <= 3:
             mqtt_ams_id = ams_id

+ 136 - 30
backend/app/services/firmware_check.py

@@ -6,6 +6,7 @@ download page. The wiki is used as the primary version source (always up-to-date
 while the download page provides firmware file URLs for offline updates.
 """
 
+import json
 import logging
 import re
 import time
@@ -116,37 +117,114 @@ class FirmwareCheckService:
     def __init__(self):
         self._build_id: str | None = None
         self._build_id_time: float = 0
+        self._download_page_unreachable: bool = False
         self._version_cache: dict[str, FirmwareVersion] = {}
         self._versions_list_cache: dict[str, list[FirmwareVersion]] = {}
         self._cache_time: float = 0
         self._client = httpx.AsyncClient(
             timeout=30.0,
             headers={
-                "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
+                # Identify honestly as Bambuddy when scraping the public Bambu
+                # Lab firmware wiki — verified 2026-05-12 that the wiki serves
+                # this UA identically to a Chrome UA (same HTML response shape).
+                # No browser impersonation needed for read-only public pages.
+                "User-Agent": "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)",
+                # Some Cloudflare bot rules on bambulab.com 403 requests with a
+                # bare UA but no browser-like Accept headers (seen on AU IPs in
+                # #1350). Sending normal Accept hints removes that signal while
+                # staying honestly identified via the UA above.
+                "Accept": "text/html,application/json,*/*;q=0.8",
+                "Accept-Language": "en-US,en;q=0.9",
             },
         )
 
+    def _build_id_cache_path(self) -> Path:
+        cache_dir = _data_dir / "firmware"
+        cache_dir.mkdir(parents=True, exist_ok=True)
+        return cache_dir / "build_id.json"
+
+    def _load_build_id_from_disk(self) -> tuple[str | None, float]:
+        """Load the last-known buildId from disk, returning (build_id, fetched_at)."""
+        path = self._build_id_cache_path()
+        try:
+            if not path.exists():
+                return None, 0.0
+            data = json.loads(path.read_text())
+            build_id = data.get("build_id")
+            fetched_at = float(data.get("fetched_at", 0))
+            if isinstance(build_id, str) and build_id:
+                return build_id, fetched_at
+        except (OSError, ValueError, TypeError) as e:
+            logger.debug("Could not read cached buildId: %s", e)
+        return None, 0.0
+
+    def _save_build_id_to_disk(self, build_id: str) -> None:
+        try:
+            self._build_id_cache_path().write_text(json.dumps({"build_id": build_id, "fetched_at": time.time()}))
+        except OSError as e:
+            logger.debug("Could not persist buildId: %s", e)
+
     async def _get_build_id(self) -> str | None:
-        """Fetch the Next.js build ID from Bambu Lab's firmware page."""
-        # Use cached build ID if still valid (cache for 1 hour)
+        """Fetch the Next.js build ID from Bambu Lab's firmware page.
+
+        Cache layers (fresh → stale → none):
+        1. In-memory (1 hour TTL) — fast path for repeated checks in a session
+        2. Disk-cached buildId (any age) — survives restarts, lets us recover
+           from upstream Cloudflare 403s. The buildId is treated as
+           "probably still valid" because Bambu rebuilds the page only every
+           few weeks; if the JSON fetch later fails, the caller falls back.
+        3. Live fetch from bambulab.com — only when both caches miss
+        """
+        # 1. In-memory cache (fresh)
         if self._build_id and (time.time() - self._build_id_time) < CACHE_TTL:
             return self._build_id
 
+        # 2. Disk cache: load if we don't have one in memory yet (first call
+        #    after restart). We still try the live fetch below to refresh.
+        if not self._build_id:
+            disk_id, disk_time = self._load_build_id_from_disk()
+            if disk_id:
+                self._build_id = disk_id
+                self._build_id_time = disk_time
+
+        # 3. Live fetch
         try:
             response = await self._client.get(f"{BAMBU_FIRMWARE_BASE}{FIRMWARE_PAGE}")
             if response.status_code == 200:
-                # Extract buildId from the page
                 match = re.search(r'"buildId":"([^"]+)"', response.text)
                 if match:
-                    self._build_id = match.group(1)
+                    new_build_id = match.group(1)
+                    if new_build_id != self._build_id:
+                        logger.info("Got Bambu Lab build ID: %s", new_build_id)
+                    self._build_id = new_build_id
                     self._build_id_time = time.time()
-                    logger.info("Got Bambu Lab build ID: %s", self._build_id)
+                    self._download_page_unreachable = False
+                    self._save_build_id_to_disk(new_build_id)
                     return self._build_id
-            logger.warning("Failed to get Bambu Lab page: %s", response.status_code)
+            else:
+                # 403/5xx — keep stale cached buildId if we have one (#1350).
+                logger.warning(
+                    "Failed to get Bambu Lab page: %s (will try cached buildId if available)",
+                    response.status_code,
+                )
+                self._download_page_unreachable = True
         except Exception as e:
             logger.error("Error fetching Bambu Lab build ID: %s", e)
+            self._download_page_unreachable = True
 
-        return self._build_id  # Return cached value if available
+        # Return whatever we have — even a stale buildId beats nothing.
+        return self._build_id
+
+    @property
+    def download_page_unreachable(self) -> bool:
+        """True if the most recent attempt to reach bambulab.com firmware page failed.
+
+        Used by callers (e.g. the firmware update prepare flow) to render a
+        clearer error message when a wiki-listed version has no download URL
+        because we couldn't reach Bambu Lab, vs the version genuinely not
+        being on the catalog (#1350).
+        """
+        return self._download_page_unreachable
 
     async def _fetch_version_from_wiki(self, api_key: str) -> str | None:
         """Fetch the latest firmware version from Bambu Lab's wiki release history page."""
@@ -212,34 +290,62 @@ class FirmwareCheckService:
         return []
 
     async def _fetch_all_versions_from_download_page(self, api_key: str) -> list[FirmwareVersion]:
-        """Fetch all firmware versions from Bambu Lab's download page (newest first)."""
+        """Fetch all firmware versions from Bambu Lab's download page (newest first).
+
+        If we have a stale (disk-cached) buildId and it returns 404 (Bambu
+        rebuilt the page), retry once with a fresh fetch — this only kicks in
+        when the in-memory cache thinks it's still valid but the upstream has
+        moved on.
+        """
         build_id = await self._get_build_id()
         if not build_id:
             return []
 
-        try:
-            url = f"{BAMBU_FIRMWARE_BASE}/_next/data/{build_id}/en/support/firmware-download/{api_key}.json"
-            response = await self._client.get(url)
+        for attempt in range(2):
+            try:
+                url = f"{BAMBU_FIRMWARE_BASE}/_next/data/{build_id}/en/support/firmware-download/{api_key}.json"
+                response = await self._client.get(url)
+
+                if response.status_code == 200:
+                    data = response.json()
+                    page_props = data.get("pageProps", {})
+                    printer_map = page_props.get("printerMap", {})
+                    printer_data = printer_map.get(api_key, {})
+                    versions = printer_data.get("versions", [])
+                    return [
+                        FirmwareVersion(
+                            version=v.get("version", ""),
+                            download_url=v.get("url", ""),
+                            release_notes=v.get("release_notes_en"),
+                            release_time=v.get("release_time"),
+                        )
+                        for v in versions
+                        if v.get("version")
+                    ]
+
+                # 404 with cached buildId → Bambu rebuilt the page; invalidate
+                # and retry once. Other status codes (403, 5xx) are upstream
+                # blocks — don't churn.
+                if response.status_code == 404 and attempt == 0:
+                    logger.info("Cached Bambu buildId stale (404), refreshing")
+                    self._build_id = None
+                    self._build_id_time = 0
+                    build_id = await self._get_build_id()
+                    if not build_id:
+                        return []
+                    continue
 
-            if response.status_code == 200:
-                data = response.json()
-                page_props = data.get("pageProps", {})
-                printer_map = page_props.get("printerMap", {})
-                printer_data = printer_map.get(api_key, {})
-                versions = printer_data.get("versions", [])
-                return [
-                    FirmwareVersion(
-                        version=v.get("version", ""),
-                        download_url=v.get("url", ""),
-                        release_notes=v.get("release_notes_en"),
-                        release_time=v.get("release_time"),
-                    )
-                    for v in versions
-                    if v.get("version")
-                ]
+                # 403 from the JSON endpoint is the same Cloudflare block
+                # signal as on the index page (#1350).
+                if response.status_code == 403:
+                    self._download_page_unreachable = True
 
-        except Exception as e:
-            logger.debug("Error fetching download page firmware for %s: %s", api_key, e)
+                logger.debug("Download-page JSON for %s returned status %s", api_key, response.status_code)
+                return []
+
+            except Exception as e:
+                logger.debug("Error fetching download page firmware for %s: %s", api_key, e)
+                return []
 
         return []
 

+ 14 - 2
backend/app/services/firmware_update.py

@@ -172,8 +172,20 @@ class FirmwareUpdateService:
             # We'll get actual size during download
             result["firmware_size"] = 100 * 1024 * 1024  # 100MB estimate
         elif target_version:
-            # Requested specific version has no download URL
-            result["errors"].append(f"Firmware file for {target_version} is not available from Bambu Lab")
+            # Requested specific version has no download URL. Distinguish
+            # "Bambu doesn't list this file" from "we couldn't reach Bambu's
+            # download page" (Cloudflare 403 reported in #1350) so users in
+            # affected regions get an actionable error instead of believing
+            # the firmware doesn't exist.
+            if firmware_service.download_page_unreachable:
+                result["errors"].append(
+                    f"Could not reach Bambu Lab's firmware download page to fetch the file URL for "
+                    f"{target_version}. Version is listed on the Bambu wiki but the download endpoint "
+                    f"is unreachable from this network. Try again later, or download the firmware "
+                    f"manually from bambulab.com and copy it to the printer's SD card."
+                )
+            else:
+                result["errors"].append(f"Firmware file for {target_version} is not available from Bambu Lab")
 
         # If a target version is requested, allow proceeding even if it equals or
         # is older than the current version (explicit downgrade/reinstall).

+ 209 - 65
backend/app/services/ldap_service.py

@@ -28,6 +28,16 @@ class LDAPUserInfo:
     groups: list[str]  # List of group DNs the user belongs to
 
 
+@dataclass
+class LDAPSearchResult:
+    """A directory user returned by the admin search endpoint (no auth performed)."""
+
+    username: str
+    email: str | None
+    display_name: str | None
+    dn: str
+
+
 @dataclass
 class LDAPConfig:
     """LDAP configuration parsed from settings."""
@@ -91,6 +101,104 @@ def _create_server(config: LDAPConfig) -> Server:
     return Server(config.server_url, use_ssl=use_ssl, tls=tls, get_info=ALL, connect_timeout=10)
 
 
+def _open_service_connection(config: LDAPConfig, server: Server, *, check_names: bool = True) -> Connection:
+    """Open and bind a service-account LDAP connection. Raises on failure.
+
+    `check_names` toggles ldap3's client-side attribute-name validation. The
+    default keeps it on so typos in `user_filter` fail loudly. The fuzzy
+    directory search disables it because its fixed OR filter spans both AD-only
+    (sAMAccountName, displayName) and OpenLDAP-only attribute names — without
+    this bypass ldap3 throws `LDAPAttributeError` before any request is sent
+    on a directory whose schema doesn't define one of the names.
+    """
+    conn = Connection(
+        server,
+        user=config.bind_dn,
+        password=config.bind_password,
+        auto_bind=False,
+        raise_exceptions=True,
+        read_only=True,
+        check_names=check_names,
+    )
+    conn.open()
+    if config.security == "starttls" and not config.server_url.startswith("ldaps://"):
+        conn.start_tls()
+    conn.bind()
+    return conn
+
+
+def _pick_canonical_username(entry, fallback: str) -> str:
+    """Prefer sAMAccountName, then uid, then the supplied fallback."""
+    if hasattr(entry, "sAMAccountName") and entry.sAMAccountName:
+        return str(entry.sAMAccountName)
+    if hasattr(entry, "uid") and entry.uid:
+        return str(entry.uid)
+    return fallback
+
+
+def _extract_user_info(
+    service_conn: Connection, config: LDAPConfig, user_entry, fallback_username: str
+) -> LDAPUserInfo:
+    """Build an LDAPUserInfo from an already-fetched directory entry.
+
+    Collects memberOf groups, POSIX memberUid groups, and the primary
+    gidNumber group; dedups DNs case-insensitively. Uses the supplied
+    service-bound connection to resolve POSIX groups.
+    """
+    email = str(user_entry.mail) if hasattr(user_entry, "mail") and user_entry.mail else None
+    display_name = (
+        str(user_entry.displayName) if hasattr(user_entry, "displayName") and user_entry.displayName else None
+    )
+
+    # Collect groups from memberOf attribute (Active Directory / groupOfNames)
+    groups = [str(g) for g in user_entry.memberOf] if hasattr(user_entry, "memberOf") and user_entry.memberOf else []
+
+    canonical_username = _pick_canonical_username(user_entry, fallback_username)
+
+    # Also search for POSIX groups (memberUid-based) using the service account
+    posix_filter = f"(&(objectClass=posixGroup)(memberUid={_ldap_escape(canonical_username)}))"
+    service_conn.search(
+        search_base=config.search_base,
+        search_filter=posix_filter,
+        search_scope=SUBTREE,
+        attributes=["cn"],
+    )
+    for entry in service_conn.entries:
+        groups.append(str(entry.entry_dn))
+
+    # POSIX primary group: user's gidNumber matches a posixGroup's gidNumber.
+    # Standard Unix semantics treat this as full group membership, so we need
+    # to resolve it to a group DN alongside the memberUid results.
+    if hasattr(user_entry, "gidNumber") and user_entry.gidNumber:
+        primary_gid = str(user_entry.gidNumber)
+        primary_filter = f"(&(objectClass=posixGroup)(gidNumber={_ldap_escape(primary_gid)}))"
+        service_conn.search(
+            search_base=config.search_base,
+            search_filter=primary_filter,
+            search_scope=SUBTREE,
+            attributes=["cn"],
+        )
+        for entry in service_conn.entries:
+            groups.append(str(entry.entry_dn))
+
+    # Dedupe group DNs (user may be in a group via both memberUid and primary gidNumber).
+    # Case-insensitive comparison — LDAP DNs are case-insensitive by spec.
+    seen_lower: set[str] = set()
+    deduped_groups: list[str] = []
+    for g in groups:
+        key = g.lower()
+        if key not in seen_lower:
+            seen_lower.add(key)
+            deduped_groups.append(g)
+
+    return LDAPUserInfo(
+        username=canonical_username,
+        email=email,
+        display_name=display_name,
+        groups=deduped_groups,
+    )
+
+
 def authenticate_ldap_user(config: LDAPConfig, username: str, password: str) -> LDAPUserInfo | None:
     """Authenticate a user via LDAP bind.
 
@@ -105,20 +213,8 @@ def authenticate_ldap_user(config: LDAPConfig, username: str, password: str) ->
 
     server = _create_server(config)
 
-    # Step 1: Service account bind + user search
     try:
-        service_conn = Connection(
-            server,
-            user=config.bind_dn,
-            password=config.bind_password,
-            auto_bind=False,
-            raise_exceptions=True,
-            read_only=True,
-        )
-        service_conn.open()
-        if config.security == "starttls" and not config.server_url.startswith("ldaps://"):
-            service_conn.start_tls()
-        service_conn.bind()
+        service_conn = _open_service_connection(config, server)
     except Exception as e:
         logger.warning("LDAP service account bind failed: %s", e)
         return None
@@ -159,70 +255,118 @@ def authenticate_ldap_user(config: LDAPConfig, username: str, password: str) ->
             logger.info("LDAP bind failed for user %s: %s", username, e)
             return None
 
-        # Step 3: Extract user info
-        email = str(user_entry.mail) if hasattr(user_entry, "mail") and user_entry.mail else None
-        display_name = (
-            str(user_entry.displayName) if hasattr(user_entry, "displayName") and user_entry.displayName else None
+        info = _extract_user_info(service_conn, config, user_entry, username)
+        logger.info(
+            "LDAP authentication successful for user: %s (DN: %s, groups: %d)",
+            info.username,
+            user_dn,
+            len(info.groups),
         )
+        return info
+    finally:
+        service_conn.unbind()
 
-        # Collect groups from memberOf attribute (Active Directory / groupOfNames)
-        groups = (
-            [str(g) for g in user_entry.memberOf] if hasattr(user_entry, "memberOf") and user_entry.memberOf else []
-        )
 
-        # Also search for POSIX groups (memberUid-based) using the service account
-        canonical_username = username
-        if hasattr(user_entry, "sAMAccountName") and user_entry.sAMAccountName:
-            canonical_username = str(user_entry.sAMAccountName)
-        elif hasattr(user_entry, "uid") and user_entry.uid:
-            canonical_username = str(user_entry.uid)
+def lookup_ldap_user(config: LDAPConfig, username: str) -> LDAPUserInfo | None:
+    """Look up a directory user by exact username via the service-account bind.
+
+    Performs no password verification — intended for the admin manual-provision
+    flow, where the caller has already been authenticated as a BamBuddy admin
+    and now needs the directory attributes (email, display name, group DNs)
+    to create the user.
 
-        posix_filter = f"(&(objectClass=posixGroup)(memberUid={_ldap_escape(canonical_username)}))"
+    Uses the same `user_filter` template that the login path uses, so anything
+    that logs in successfully via auto-provision is also resolvable here.
+    """
+    server = _create_server(config)
+
+    try:
+        service_conn = _open_service_connection(config, server)
+    except Exception as e:
+        logger.warning("LDAP service account bind failed during lookup: %s", e)
+        raise
+
+    try:
+        search_filter = config.user_filter.replace("{username}", _ldap_escape(username))
         service_conn.search(
             search_base=config.search_base,
-            search_filter=posix_filter,
+            search_filter=search_filter,
             search_scope=SUBTREE,
-            attributes=["cn"],
+            attributes=["*"],
         )
-        for entry in service_conn.entries:
-            groups.append(str(entry.entry_dn))
+        if not service_conn.entries:
+            logger.info("LDAP lookup: user not found: %s", username)
+            return None
+        return _extract_user_info(service_conn, config, service_conn.entries[0], username)
+    finally:
+        service_conn.unbind()
 
-        # POSIX primary group: user's gidNumber matches a posixGroup's gidNumber.
-        # Standard Unix semantics treat this as full group membership, so we need
-        # to resolve it to a group DN alongside the memberUid results.
-        if hasattr(user_entry, "gidNumber") and user_entry.gidNumber:
-            primary_gid = str(user_entry.gidNumber)
-            primary_filter = f"(&(objectClass=posixGroup)(gidNumber={_ldap_escape(primary_gid)}))"
-            service_conn.search(
-                search_base=config.search_base,
-                search_filter=primary_filter,
-                search_scope=SUBTREE,
-                attributes=["cn"],
-            )
-            for entry in service_conn.entries:
-                groups.append(str(entry.entry_dn))
-
-        # Dedupe group DNs (user may be in a group via both memberUid and primary gidNumber).
-        # Case-insensitive comparison — LDAP DNs are case-insensitive by spec.
-        seen_lower: set[str] = set()
-        deduped_groups: list[str] = []
-        for g in groups:
-            key = g.lower()
-            if key not in seen_lower:
-                seen_lower.add(key)
-                deduped_groups.append(g)
-        groups = deduped_groups
 
-        logger.info(
-            "LDAP authentication successful for user: %s (DN: %s, groups: %d)", canonical_username, user_dn, len(groups)
-        )
+def search_ldap_users(config: LDAPConfig, query: str, limit: int = 25) -> list[LDAPSearchResult]:
+    """Fuzzy search the directory for users matching `query`.
 
-        return LDAPUserInfo(
-            username=canonical_username,
-            email=email,
-            display_name=display_name,
-            groups=groups,
+    Uses a fixed OR filter across sAMAccountName, uid, mail, displayName, and
+    cn — covering both Active Directory and OpenLDAP layouts. The query is
+    RFC-4515 escaped so a typed `*` doesn't enumerate the whole directory.
+    Returns up to `limit` results (default 25). Service-bind failures raise so
+    the caller can surface a 503; "no matches" returns an empty list.
+
+    Callers should enforce a minimum query length (≥2 chars) — short queries
+    against a large directory are wasteful and effectively unbounded.
+    """
+    query = query.strip()
+    if len(query) < 2:
+        return []
+
+    escaped = _ldap_escape(query)
+    search_filter = (
+        f"(|(sAMAccountName=*{escaped}*)(uid=*{escaped}*)(mail=*{escaped}*)(displayName=*{escaped}*)(cn=*{escaped}*))"
+    )
+
+    server = _create_server(config)
+
+    try:
+        # check_names=False so OpenLDAP directories (no sAMAccountName/displayName
+        # in schema) don't reject the cross-schema OR filter — see helper docstring.
+        service_conn = _open_service_connection(config, server, check_names=False)
+    except Exception as e:
+        logger.warning("LDAP service account bind failed during search: %s", e)
+        raise
+
+    try:
+        # attributes=["*"] requests all user attributes. We can't enumerate the
+        # AD/OpenLDAP-specific names (sAMAccountName, displayName) explicitly
+        # because ldap3 validates the attribute list against the server schema
+        # even with check_names=False — and OpenLDAP rejects the AD names. The
+        # `*` wildcard is hardcoded in ldap3's ATTRIBUTES_EXCLUDED_FROM_CHECK so
+        # it bypasses that validation, and the server returns whatever it has.
+        service_conn.search(
+            search_base=config.search_base,
+            search_filter=search_filter,
+            search_scope=SUBTREE,
+            attributes=["*"],
+            size_limit=limit,
         )
+        results: list[LDAPSearchResult] = []
+        for entry in service_conn.entries:
+            username = _pick_canonical_username(entry, "")
+            if not username and hasattr(entry, "cn") and entry.cn:
+                # Last resort — some OpenLDAP layouts only have cn
+                username = str(entry.cn)
+            if not username:
+                continue
+            email = str(entry.mail) if hasattr(entry, "mail") and entry.mail else None
+            display_name = str(entry.displayName) if hasattr(entry, "displayName") and entry.displayName else None
+            results.append(
+                LDAPSearchResult(
+                    username=username,
+                    email=email,
+                    display_name=display_name,
+                    dn=str(entry.entry_dn),
+                )
+            )
+        logger.info("LDAP directory search for %r returned %d result(s)", query, len(results))
+        return results
     finally:
         service_conn.unbind()
 

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

@@ -43,11 +43,16 @@ MAKERWORLD_CDN_HOSTS = ("makerworld.bblmw.com", "public-cdn.bblmw.com")
 # Pr0zak/YASTL#52. The suffix check matches any regional S3 endpoint.
 _ALLOWED_DOWNLOAD_SUFFIXES = (".amazonaws.com",)
 
-# Browser-like headers. ``api.bambulab.com`` accepts minimal headers cleanly;
-# the Referer is kept so MakerWorld origin checks don't fail anywhere the
-# same client hits ``makerworld.com``.
+# Client identity sent to MakerWorld / api.bambulab.com. We identify honestly
+# as Bambuddy with a source URL so Bambu can distinguish our traffic from
+# impersonators — the opposite of what the OrcaSlicer fork was called out for
+# in the May 2026 Bambu Lab blog post on cloud access. Verified 2026-05-12 via
+# curl that MakerWorld treats this UA identically to a Firefox UA at the
+# Cloudflare edge (same response shape on /api/v1/design-service/* paths).
+# The Referer is kept because MakerWorld's CSRF / origin-check middleware uses
+# it on some endpoints — that's distinct from client impersonation.
 _CLIENT_HEADERS = {
-    "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0",
+    "User-Agent": "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)",
     "Accept": "text/html,application/json,*/*",
     "Accept-Language": "en-US,en;q=0.9",
     "Referer": "https://makerworld.com/",

+ 4 - 1
backend/app/services/notification_service.py

@@ -418,7 +418,10 @@ class NotificationService:
         if not webhook_url:
             return False, "Webhook URL is required"
 
-        if not webhook_url.startswith("https://discord.com/api/webhooks/"):
+        if not (
+            webhook_url.startswith("https://discord.com/api/webhooks/")
+            or webhook_url.startswith("https://discordapp.com/api/webhooks/")
+        ):
             return False, "Invalid Discord webhook URL"
 
         # Discord embed format for nicer messages

+ 25 - 0
backend/app/services/obico_detection.py

@@ -199,6 +199,31 @@ class ObicoDetectionService:
                 timeout=SNAPSHOT_CAPTURE_TIMEOUT,
                 snapshot_url=printer.external_camera_snapshot_url,
             )
+
+        # Reuse the fan-out broadcaster's buffered frame when a viewer is
+        # already watching — avoids opening a second concurrent RTSP socket
+        # on printers that allow only one camera connection (e.g. X2D
+        # firmware 01.01.00.00; see #1271). Buffered frame is <1s old while
+        # a viewer is connected.
+        #
+        # When a viewer is attached but no frame is buffered yet (startup
+        # race, mid-reconnect), we DELIBERATELY skip this poll cycle instead
+        # of falling through to capture_camera_frame_bytes. Opening a fresh
+        # RTSP/chamber socket would compete with the live viewer and kick
+        # the fan-out connection on most firmwares — exactly the freeze
+        # reported in #1348. The poll loop retries in ~10s.
+        from backend.app.api.routes.camera import is_stream_active, try_get_active_buffered_frame
+
+        if is_stream_active(printer_id):
+            buffered = try_get_active_buffered_frame(printer_id)
+            if buffered:
+                return buffered
+            logger.info(
+                "Obico: viewer attached for printer %s but buffer empty; skipping this poll to avoid competing camera socket (#1348)",
+                printer_id,
+            )
+            return None
+
         return await capture_camera_frame_bytes(
             ip_address=printer.ip_address,
             access_code=printer.access_code,

+ 177 - 0
backend/app/services/oidc_icon.py

@@ -0,0 +1,177 @@
+"""OIDC provider icon fetcher (#1333).
+
+Server-side proxy that fetches an admin-supplied icon URL and returns
+``(bytes, content_type, etag)``. The bytes are cached in the
+``oidc_providers.icon_data`` BLOB column so the SPA can serve them from
+``/api/v1/auth/oidc/providers/{id}/icon`` (same-origin) — avoiding any
+loosening of the strict ``img-src 'self' data: blob:`` CSP.
+
+Pattern mirrors ``services/makerworld.fetch_thumbnail``:
+- ``follow_redirects=False`` so the SSRF host allowlist (here: assert_safe_public_https_url)
+  isn't bypassed by a 302 to a private address.
+- MIME whitelist (PNG/JPEG/WebP/GIF). SVG is rejected in v1 — XML payloads
+  carry too many corner cases (xlink, external refs) for an MVP.
+- ``application/octet-stream`` is accepted only if the URL path ends in a
+  whitelisted image extension; the response Content-Type alone is not
+  trusted because some CDNs serve images as octet-stream.
+- 1 MB hard cap (typical OIDC icons are 5-50 KB; 1 MB is generous).
+- 10s timeout, matching the OIDC discovery/JWKS timeouts in routes/mfa.py.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import logging
+from urllib.parse import urlparse
+
+import httpx
+
+logger = logging.getLogger(__name__)
+
+
+_MAX_ICON_BYTES = 1 * 1024 * 1024  # 1 MB
+_FETCH_TIMEOUT_SECONDS = 10.0
+
+# Content-Type whitelist. SVG is intentionally omitted — see module docstring.
+_ALLOWED_MIME_TYPES = frozenset(
+    {
+        "image/png",
+        "image/jpeg",
+        "image/webp",
+        "image/gif",
+    }
+)
+
+# Extension → MIME fallback for ``application/octet-stream`` responses.
+_EXT_TO_MIME = {
+    ".png": "image/png",
+    ".jpg": "image/jpeg",
+    ".jpeg": "image/jpeg",
+    ".webp": "image/webp",
+    ".gif": "image/gif",
+}
+
+
+class OIDCIconError(Exception):
+    """Base class for icon-fetch failures."""
+
+
+class OIDCIconUrlError(OIDCIconError):
+    """The URL is invalid or rejected by the SSRF guard.
+
+    Maps to a 400 Bad Request when surfaced at the API layer.
+    """
+
+
+class OIDCIconUnavailableError(OIDCIconError):
+    """The fetch reached the upstream but the response was unusable.
+
+    Network timeouts, non-200 status, wrong content-type, oversized payload,
+    redirects (we never follow), etc.  Maps to a 400 at the API layer because
+    the admin's input (the URL) is what's at fault.
+    """
+
+
+def _resolve_content_type(upstream_type: str, url_path: str) -> str:
+    """Map an upstream Content-Type to a whitelisted MIME, or raise.
+
+    Three-step derivation:
+    1. Trust upstream ``image/*`` if it's in the allowlist.
+    2. Fall back to URL extension if upstream returned
+       ``application/octet-stream`` (some CDNs do this with
+       ``Content-Disposition: attachment; filename="…png"``).
+    3. Distinct error when the header is missing entirely (#1333 review)
+       — empty quotes in a generic "unsupported content-type: ''" message
+       was user-hostile.
+
+    Extracted from ``fetch_icon`` so the dispatch logic is unit-testable
+    without spinning up the streaming-mock harness.
+    """
+    if not upstream_type:
+        raise OIDCIconUnavailableError("Icon URL response is missing a Content-Type header")
+    if upstream_type in _ALLOWED_MIME_TYPES:
+        return upstream_type
+    if upstream_type == "application/octet-stream":
+        path_lower = url_path.lower()
+        for ext, mime in _EXT_TO_MIME.items():
+            if path_lower.endswith(ext):
+                return mime
+        raise OIDCIconUnavailableError("Icon URL returned application/octet-stream with no image extension")
+    raise OIDCIconUnavailableError(
+        f"Icon URL returned unsupported content-type: {upstream_type!r} "
+        "(allowed: image/png, image/jpeg, image/webp, image/gif)"
+    )
+
+
+async def fetch_icon(url: str) -> tuple[bytes, str, str]:
+    """Fetch ``url`` and return ``(bytes, content_type, etag)``.
+
+    Streams the response body and aborts as soon as ``_MAX_ICON_BYTES`` is
+    exceeded — never buffers more than one chunk past the cap, so a hostile
+    or misconfigured IdP serving a 500 MB payload cannot OOM the server.
+
+    Raises:
+        OIDCIconUrlError: URL parsing/scheme issue OR ``httpx.InvalidURL``
+            (validator should have caught these earlier; this is a
+            defence-in-depth check).
+        OIDCIconUnavailableError: upstream issue — timeout, non-200,
+            redirect, wrong content-type, oversized payload, empty body.
+    """
+    try:
+        parsed = urlparse(url)
+    except ValueError as exc:
+        raise OIDCIconUrlError(f"Invalid icon URL: {exc}") from exc
+
+    if parsed.scheme.lower() != "https":
+        # Pydantic validator + assert_safe_public_https_url catch this earlier,
+        # but the service is the last defence — refuse non-HTTPS even if a
+        # future code path bypassed the validators.
+        raise OIDCIconUrlError("Icon URL must use https://")
+
+    try:
+        async with (
+            httpx.AsyncClient(timeout=_FETCH_TIMEOUT_SECONDS) as client,
+            client.stream("GET", url, follow_redirects=False) as response,
+        ):
+            if response.status_code != 200:
+                # Any non-200 — including 301/302 redirects (we set follow_redirects=False
+                # so the SSRF guard on the original URL isn't bypassed by a redirect
+                # to a private address).
+                raise OIDCIconUnavailableError(
+                    f"Icon URL returned HTTP {response.status_code} "
+                    "(redirects are not followed; the URL must respond with the image directly)"
+                )
+
+            upstream_type = response.headers.get("content-type", "").split(";")[0].strip().lower()
+            content_type = _resolve_content_type(upstream_type, parsed.path)
+
+            # Stream with early-exit at the size cap. Read in chunks so a
+            # hostile 500 MB body never gets allocated whole — we raise
+            # immediately when the running total crosses the cap.
+            chunks: list[bytes] = []
+            total = 0
+            async for chunk in response.aiter_bytes():
+                total += len(chunk)
+                if total > _MAX_ICON_BYTES:
+                    raise OIDCIconUnavailableError(f"Icon exceeds {_MAX_ICON_BYTES // 1024} KB cap")
+                chunks.append(chunk)
+            payload = b"".join(chunks)
+    except httpx.TimeoutException as exc:
+        raise OIDCIconUnavailableError(f"Icon fetch timed out: {exc}") from exc
+    except httpx.InvalidURL as exc:
+        # ``httpx.InvalidURL`` is a sibling of ``httpx.HTTPError`` (verified:
+        # MRO is ``InvalidURL → Exception``, no HTTPError in between). Fires
+        # at send-time for URLs that ``urlparse`` accepts but httpx refuses —
+        # typically null bytes or control chars. Map to URL-error path so
+        # the admin sees a 400, not a 500.
+        raise OIDCIconUrlError(f"Invalid icon URL: {exc}") from exc
+    except httpx.HTTPError as exc:
+        raise OIDCIconUnavailableError(f"Icon fetch failed: {exc}") from exc
+
+    if not payload:
+        raise OIDCIconUnavailableError("Icon URL returned an empty body")
+
+    # SHA-256 hex is deterministic — identical bytes always yield the same
+    # ETag so revalidation via If-None-Match works across server restarts.
+    etag = hashlib.sha256(payload).hexdigest()
+    return payload, content_type, etag

+ 12 - 0
backend/app/services/print_log.py

@@ -17,6 +17,7 @@ async def write_log_entry(
     db: AsyncSession,
     *,
     status: str,
+    archive_id: int | None = None,
     print_name: str | None = None,
     printer_name: str | None = None,
     printer_id: int | None = None,
@@ -25,7 +26,12 @@ async def write_log_entry(
     filament_type: str | None = None,
     filament_color: str | None = None,
     filament_used_grams: float | None = None,
+    cost: float | None = None,
+    energy_kwh: float | None = None,
+    energy_cost: float | None = None,
+    failure_reason: str | None = None,
     thumbnail_path: str | None = None,
+    created_by_id: int | None = None,
     created_by_username: str | None = None,
 ) -> PrintLogEntry:
     """Write a print log entry."""
@@ -34,6 +40,7 @@ async def write_log_entry(
         duration = int((completed_at - started_at).total_seconds())
 
     entry = PrintLogEntry(
+        archive_id=archive_id,
         print_name=print_name,
         printer_name=printer_name,
         printer_id=printer_id,
@@ -44,7 +51,12 @@ async def write_log_entry(
         filament_type=filament_type,
         filament_color=filament_color,
         filament_used_grams=filament_used_grams,
+        cost=cost,
+        energy_kwh=energy_kwh,
+        energy_cost=energy_cost,
+        failure_reason=failure_reason,
         thumbnail_path=thumbnail_path,
+        created_by_id=created_by_id,
         created_by_username=created_by_username,
     )
     db.add(entry)

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

@@ -11,7 +11,7 @@ from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.config import settings
-from backend.app.core.database import async_session
+from backend.app.core.database import async_session, run_with_retry
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile
 from backend.app.models.print_queue import PrintQueueItem
@@ -32,6 +32,15 @@ from backend.app.utils.printer_models import normalize_printer_model
 
 logger = logging.getLogger(__name__)
 
+# Bambu firmware states that mean the project_file has actually been accepted
+# and the printer is now processing / running / paused mid-print. Used by the
+# dispatch watchdog (#1370): a transition into one of these states means the
+# print landed, anything else (e.g. FINISH -> IDLE after the user dismisses
+# a post-print prompt) is NOT a valid "command landed" signal even though the
+# state value did change. SLICING is included because some firmwares park
+# briefly in SLICING between PREPARE and RUNNING while parsing the g-code.
+_ACTIVE_PRINT_STATES: frozenset[str] = frozenset({"PREPARE", "SLICING", "RUNNING", "PAUSE"})
+
 # Filament type equivalence groups — types within the same group are
 # interchangeable on the printer side (Bambu Lab firmware treats them as compatible).
 _FILAMENT_TYPE_GROUPS: list[list[str]] = [
@@ -2029,27 +2038,66 @@ class PrintScheduler:
                 scheduler._release_dispatch_hold(printer_id)
                 return
             last_status = status
-            if status.state != pre_state:
-                # Printer picked up the job (state transition) — release the
+            if status.state in _ACTIVE_PRINT_STATES:
+                # Printer is actively processing the job — release the
                 # post-dispatch hold so the next pending item for this printer
-                # can be evaluated normally.
+                # can be evaluated normally. We do NOT accept arbitrary state
+                # transitions: a printer going FINISH -> IDLE (user dismissed
+                # the post-print prompt without accepting our project_file)
+                # would otherwise look like "command landed" and leave the
+                # queue item stuck in 'printing' forever (#1370).
                 scheduler._release_dispatch_hold(printer_id)
                 return
             if pre_subtask_id is not None and status.subtask_id is not None and status.subtask_id != pre_subtask_id:
-                # Printer picked up the job (subtask_id advanced)
+                # Printer picked up the job (subtask_id advanced). H2D can
+                # sit at FINISH for ~50 s after accepting project_file
+                # before transitioning to PREPARE, but the subtask_id flips
+                # to our submission_id almost immediately (#1078).
                 scheduler._release_dispatch_hold(printer_id)
                 return
 
         # No transition. Revert the item so the scheduler can retry.
         # Drop the in-memory hold so the retry isn't blocked by it.
         scheduler._release_dispatch_hold(printer_id)
-        async with async_session() as db:
+
+        # Three outcomes from the revert attempt, each routed differently:
+        #   "reverted":          row flipped from printing -> pending, run recovery
+        #   "already_moved_on":  item.status != 'printing' (completed/cancelled by
+        #                        on_print_complete or user). Skip recovery entirely
+        #                        — the print clearly landed somewhere even if the
+        #                        watchdog didn't see the active-state transition.
+        #   "revert_failed":     SQLite contention exhausted retries. Still run
+        #                        recovery so the MQTT session gets a fresh client_id
+        #                        on the half-broken-session path.
+        async def _do_revert(db):
             item = await db.get(PrintQueueItem, queue_item_id)
             if not item or item.status != "printing":
-                return  # Already moved on (completed/cancelled/etc.)
+                return "already_moved_on"
             item.status = "pending"
             item.started_at = None
             await db.commit()
+            return "reverted"
+
+        try:
+            revert_outcome = await run_with_retry(_do_revert, label=f"watchdog revert item={queue_item_id}")
+        except Exception as e:
+            logger.warning(
+                "Queue item %s: failed to revert to 'pending' (printer %d): %s — "
+                "scheduler may keep treating this item as in-flight",
+                queue_item_id,
+                printer_id,
+                e,
+            )
+            revert_outcome = "revert_failed"
+
+        if revert_outcome == "already_moved_on":
+            # Preserves the pre-#1370 early-return: if on_print_complete (or any
+            # other path) already moved the item past 'printing', don't run the
+            # MQTT session-recovery logic below — a forced reconnect on a healthy
+            # session breaks ongoing prints on the same printer.
+            return
+
+        if revert_outcome == "reverted":
             logger.warning(
                 "Queue item %s: printer %d did not respond to print command within "
                 "%.0fs (state still %s, subtask_id still %s) — reverted to 'pending' "

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

@@ -98,6 +98,24 @@ def has_stg_cur_idle_bug(model: str | None) -> bool:
     return model_upper in STG_CUR_IDLE_BUG_MODELS
 
 
+def is_bed_slinger(model: str | None) -> bool:
+    """Whether the printer's Z axis controls the *toolhead*, not the bed.
+
+    Bambu's A1 family (A1, A1 Mini; internal codes N1 / N2S) are open-frame
+    bed-slingers: the bed moves on Y, the toolhead moves on X+Z. On every
+    other current model (X1, P1, H2, H2C, H2D, H2S, P2S, ...) the bed moves
+    on Z and the toolhead is fixed in Z.
+
+    G-code direction is opposite on these two families. `G1 Z-10` reduces
+    the nozzle-bed gap on both, but on bed-on-Z machines it does so by
+    moving the BED up, while on bed-slingers it does so by moving the
+    TOOLHEAD down — which is what crashed the nozzle in #1334.
+    """
+    if not model:
+        return False
+    return model.strip().upper() in A1_MODELS
+
+
 # Minimum firmware versions for AMS drying support (confirmed via capture testing)
 # Keys are exact model names (upper-cased). Do NOT use substring matching — it would
 # incorrectly gate X1E (matched by "X1") and H2D Pro (matched by "H2D").
@@ -251,14 +269,16 @@ class PrinterManager:
             )
 
     async def _persist_awaiting_plate_clear(self, printer_id: int, awaiting: bool):
-        from backend.app.core.database import async_session
+        from backend.app.core.database import run_with_retry
+
+        async def _do(db):
+            printer = await db.get(Printer, printer_id)
+            if printer is not None:
+                printer.awaiting_plate_clear = awaiting
+                await db.commit()
 
         try:
-            async with async_session() as db:
-                printer = await db.get(Printer, printer_id)
-                if printer is not None:
-                    printer.awaiting_plate_clear = awaiting
-                    await db.commit()
+            await run_with_retry(_do, label=f"persist awaiting_plate_clear printer={printer_id}")
         except Exception as e:
             logger.warning("Failed to persist awaiting_plate_clear for printer %d: %s", printer_id, e)
 
@@ -750,6 +770,19 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
                 if k_value is None and cali_idx is not None and cali_idx in kprofile_map:
                     k_value = kprofile_map[cali_idx]
 
+                # P1S / A1 Mini physically-empty-slot signal (#1322 follow-up by
+                # @RosdasHH): for a truly empty slot the firmware sends only
+                # {"id": N} — no state, no tray_type, no anything else. Treat
+                # that as the firmware's "no spool" indicator (state=9) so the
+                # assign-spool path in inventory.py can short-circuit a MQTT
+                # publish the firmware would silently drop anyway. The
+                # post-"Reset Slot" A1 Mini BMCU case sends a populated payload
+                # (state=3, tray_type="") — different shape, doesn't match this
+                # guard, still attempts the MQTT push per the #1322 fix.
+                state_val = tray.get("state")
+                if state_val is None and len(tray) == 1 and "id" in tray:
+                    state_val = 9
+
                 trays.append(
                     {
                         "id": int(tray.get("id", 0)),
@@ -767,7 +800,7 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
                         "nozzle_temp_max": tray.get("nozzle_temp_max"),
                         "drying_temp": tray.get("drying_temp"),
                         "drying_time": tray.get("drying_time"),
-                        "state": tray.get("state"),
+                        "state": state_val,
                     }
                 )
             # Prefer humidity_raw (actual percentage) over humidity (index 1-5)

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

@@ -435,6 +435,7 @@ class SlicerApiService:
         filament_names: list[str],
         plate: int | None = None,
         export_3mf: bool = False,
+        bed_type: str | None = None,
         request_id: str | None = None,
         on_progress: Callable[[dict], None] | None = None,
     ) -> SliceResult:
@@ -476,6 +477,16 @@ class SlicerApiService:
             data["plate"] = str(plate)
         if export_3mf:
             data["exportType"] = "3mf"
+        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
 

+ 47 - 10
backend/app/services/spoolman.py

@@ -637,6 +637,26 @@ class SpoolmanClient:
             vendor_match = (not brand) or f_vendor_name == (brand or "").strip().lower()
 
             if material_match and name_match and color_match and vendor_match:
+                # #1319: color_name is not part of the match key, but if the
+                # caller passed a value that differs from what's stored, update
+                # the filament — otherwise the user's edit is silently dropped
+                # and the inventory read falls back to subtype, making it look
+                # like color_name "reverts" to the subtype on every save.
+                # Convention: None = "don't touch"; "" = explicit clear; any
+                # other string = set/update.
+                if color_name is not None:
+                    existing = (f.get("color_name") or "").strip()
+                    requested = color_name.strip()
+                    if requested != existing:
+                        payload_value: str | None = requested if requested else None
+                        try:
+                            await self.patch_filament(f["id"], {"color_name": payload_value})
+                        except Exception as e:
+                            logger.warning(
+                                "Failed to update color_name on filament %s: %s",
+                                f["id"],
+                                e,
+                            )
                 return f["id"]
 
         filament = await self.create_filament(
@@ -1024,34 +1044,50 @@ class SpoolmanClient:
 
     async def _find_or_create_filament(self, tray: AMSTray) -> dict | None:
         """Return a Bambu Lab filament matching the tray's material/color, creating it if absent."""
-        # Get Bambu Lab vendor ID for filtering
         bambu_vendor_id = await self.ensure_bambu_vendor()
         color_hex = tray.tray_color[:6]  # Strip alpha channel
+        material_upper = tray.tray_type.upper()
+        color_upper = color_hex.upper()
 
         # Search internal filaments - only match Bambu Lab vendor
         filaments = await self.get_filaments()
         for filament in filaments:
-            # Only match filaments from Bambu Lab vendor
             fil_vendor_id = filament.get("vendor_id") or filament.get("vendor", {}).get("id")
             if fil_vendor_id != bambu_vendor_id:
                 continue
-
-            # Match by material and color (handle None values)
             fil_material = filament.get("material") or ""
             fil_color = filament.get("color_hex") or ""
-            if fil_material.upper() == tray.tray_type.upper() and fil_color.upper() == color_hex.upper():
+            if fil_material.upper() == material_upper and fil_color.upper() == color_upper:
                 return filament
 
-        # Search external filaments (Bambu library)
+        # Search external filaments (SpoolmanDB) — restrict to Bambu Lab only.
+        # The /api/v1/external/filament endpoint returns the full multi-vendor catalog
+        # with no server-side filter, so without a manufacturer check the first PLA/black
+        # hit is typically 3DJAKE or 3DXTECH, not Bambu Lab.
         external = await self.get_external_filaments()
+        sub_brand = (tray.tray_sub_brands or "").strip().lower()
+        bambu_candidates = []
         for filament in external:
+            manufacturer = (filament.get("manufacturer") or "").strip().lower()
+            ext_id = (filament.get("id") or "").strip().lower()
+            if manufacturer != "bambu lab" and not ext_id.startswith("bambulab_"):
+                continue
             fil_material = filament.get("material") or ""
             fil_color = filament.get("color_hex") or ""
-            if fil_material.upper() == tray.tray_type.upper() and fil_color.upper() == color_hex.upper():
-                # Found in external library - need to create internal copy
-                return await self._create_filament_from_external(filament, tray)
+            if fil_material.upper() == material_upper and fil_color.upper() == color_upper:
+                bambu_candidates.append(filament)
+
+        if bambu_candidates:
+            # Prefer the entry whose `name` matches the AMS `tray_sub_brands`
+            # (e.g. "PLA Basic", "Support for PLA/PETG Black") so the more specific
+            # variant wins over a generic "Black" entry when both are present.
+            chosen = next(
+                (f for f in bambu_candidates if (f.get("name") or "").strip().lower() == sub_brand),
+                bambu_candidates[0],
+            )
+            return await self._create_filament_from_external(chosen, tray)
 
-        # Not found - create new Bambu Lab filament
+        # Not found in either source - create a new Bambu Lab filament from scratch.
         return await self.create_filament(
             name=tray.tray_sub_brands or tray.tray_type,
             vendor_id=bambu_vendor_id,
@@ -1069,6 +1105,7 @@ class SpoolmanClient:
             material=external.get("material", tray.tray_type),
             color_hex=external.get("color_hex", tray.tray_color[:6]),
             weight=external.get("weight", tray.tray_weight),
+            density=external.get("density"),
         )
 
 

+ 20 - 10
backend/app/services/spoolman_tracking.py

@@ -106,15 +106,30 @@ def _resolve_global_tray_id(slot_id: int, slot_to_tray: list | None, ams_trays:
     """Map a 1-based slot_id to a global_tray_id using optional custom mapping.
 
     Custom mapping: slot_to_tray[slot_id - 1] is used when >= 0.
+    A value of -1 in the custom mapping means the slicer routed this slot to
+    the external spool. BambuStudio converts virtual tray IDs (254/255) to -1
+    in the flat ams_mapping array before sending to the printer — see
+    start_print() in bambu_mqtt.py which documents this convention. We mirror
+    it here: when -1 is seen, look up the external spool's actual
+    global_tray_id (254/255) in ams_trays rather than falling through to the
+    position-based default (which would map slot_id=1 to the first AMS tray
+    and credit an unrelated spool — see #1276, regression of #853).
     Position-based default: uses sorted ams_trays keys so external spools (ID 254/255)
     naturally follow standard AMS trays, matching the slicer's slot numbering.
-    A value of -1 in the custom mapping means unmapped (uses position-based default).
     Final fallback: slot_id - 1 (legacy, works for pure AMS without external spools).
     """
     if slot_to_tray and slot_id <= len(slot_to_tray):
         mapped_tray = slot_to_tray[slot_id - 1]
         if mapped_tray >= 0:
             return mapped_tray
+        if mapped_tray == -1 and ams_trays:
+            # -1 means external spool. 254 = VIRTUAL_TRAY_DEPUTY_ID (main on
+            # single-nozzle, left/deputy on H2D dual-nozzle); 255 =
+            # VIRTUAL_TRAY_MAIN_ID. Prefer 254 when both exist since that's
+            # what single-nozzle printers report via tray_now.
+            for ext_id in (254, 255):
+                if ext_id in ams_trays:
+                    return ext_id
     # Position-based default: sort available tray IDs so external spools (254/255)
     # come after standard AMS trays, matching the slicer's slot assignment order.
     if ams_trays:
@@ -166,8 +181,10 @@ async def store_print_data(
 ):
     """Store Spoolman tracking data at print start (persisted to database).
 
-    Only stores data when Spoolman is enabled and AMS weight sync is disabled
-    (i.e., we're using per-usage tracking instead of AMS percentage estimates).
+    Per-print tracking is the primary weight-update path for Spoolman, mirroring
+    how the internal Filament Inventory works. The legacy AMS-remain%-based sync
+    is no longer used as a weight writer (#1119), so this runs whenever Spoolman
+    is enabled regardless of the deprecated `spoolman_disable_weight_sync` flag.
     """
     from backend.app.api.routes.settings import get_setting
     from backend.app.models.active_print_spoolman import ActivePrintSpoolman
@@ -183,13 +200,6 @@ async def store_print_data(
     if not spoolman_enabled or spoolman_enabled.lower() != "true":
         return
 
-    # Only store tracking data if "Disable AMS Weight Sync" is enabled
-    disable_weight_sync_str = await get_setting(db, "spoolman_disable_weight_sync")
-    disable_weight_sync = disable_weight_sync_str and disable_weight_sync_str.lower() == "true"
-    if not disable_weight_sync:
-        logger.debug("[SPOOLMAN] Weight sync enabled, skipping per-usage tracking data storage")
-        return
-
     # Get 3MF file path
     full_path = app_settings.base_dir / file_path
     if not full_path.exists():

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

@@ -32,6 +32,12 @@ def generate_stl_thumbnail(
     Returns:
         Path to the generated thumbnail, or None on failure
     """
+    # Callers historically pass either Path or str; coerce so the `thumbnails_dir
+    # / thumb_filename` join at the end of this function can't fail with the
+    # str-divided-by-str TypeError (see #1299).
+    stl_path = Path(stl_path)
+    thumbnails_dir = Path(thumbnails_dir)
+
     try:
         import matplotlib
         import trimesh

+ 62 - 3
backend/app/services/usage_tracker.py

@@ -448,6 +448,30 @@ async def on_print_complete(
                 ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
             )
 
+            # Build set of trays actually involved in this print (#1269).
+            # Without this guard, swapping a spool in an UNUSED slot mid-print
+            # makes that slot's remain% drop to 0, which the fallback below
+            # would otherwise charge to the originally-assigned spool.
+            def _global_to_ams_key(global_tray_id: int) -> tuple[int, int]:
+                if global_tray_id >= 254:
+                    return (255, global_tray_id - 254)
+                if global_tray_id >= 128:
+                    return (global_tray_id, 0)
+                return (global_tray_id // 4, global_tray_id % 4)
+
+            print_used_keys: set[tuple[int, int]] = set()
+            if ams_mapping:
+                for gid in ams_mapping:
+                    if isinstance(gid, int) and gid >= 0:
+                        print_used_keys.add(_global_to_ams_key(gid))
+            for change in getattr(state, "tray_change_log", None) or []:
+                if isinstance(change, (tuple, list)) and len(change) >= 1:
+                    gid = change[0]
+                    if isinstance(gid, int) and gid >= 0:
+                        print_used_keys.add(_global_to_ams_key(gid))
+            if session.tray_now_at_start is not None and session.tray_now_at_start >= 0:
+                print_used_keys.add(_global_to_ams_key(session.tray_now_at_start))
+
             # Collect all trays to check: AMS trays + VT (external) trays
             # Each entry: (ams_id_for_assignment, tray_id_for_assignment, current_remain, label)
             trays_to_check: list[tuple[int, int, int, str]] = []
@@ -480,6 +504,18 @@ async def on_print_complete(
                 if key not in session.tray_remain_start:
                     continue
 
+                # Skip trays the print never touched. Only enforce when we have
+                # evidence of which trays the print used; if print_used_keys is
+                # empty (no mapping, no change log, no tray_now_at_start) keep
+                # the legacy behavior of scanning every tray.
+                if print_used_keys and key not in print_used_keys:
+                    logger.info(
+                        "[UsageTracker] %s: not in print mapping/tray_change_log — skipping fallback for printer %d",
+                        tray_label,
+                        printer_id,
+                    )
+                    continue
+
                 if not isinstance(current_remain, int) or current_remain < 0 or current_remain > 100:
                     logger.info(
                         "[UsageTracker] %s: invalid remain%% at completion (%s), skipping fallback for printer %d",
@@ -570,19 +606,42 @@ async def on_print_complete(
         await db.commit()
 
     # --- Update PrintArchive.cost from THIS print session only ---
+    #
+    # Cover any filament weight that wasn't tracked by an inventory spool with
+    # the global default rate (#1344). Without this, a multi-color print where
+    # only some AMS trays are mapped to inventory spools would record only the
+    # mapped slots' share — e.g. $0.01 for a 110g print when 3 of 4 trays had
+    # no spool record. The initial cost set by archive.py (total grams *
+    # primary cost_per_kg) is fine on its own, but this block overwrites it,
+    # so the overwrite must reconstruct the whole-print cost.
 
     if archive_id and results:
-        from sqlalchemy import select
+        from sqlalchemy import func, select
 
         from backend.app.models.archive import PrintArchive
+        from backend.app.models.print_log import PrintLogEntry
 
         archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
         archive = archive_result.scalar_one_or_none()
         if archive:
             total_cost = sum(r.get("cost", 0) or 0 for r in results)
+            tracked_grams = sum(r.get("weight_used", 0) or 0 for r in results)
+            archive_grams = archive.filament_used_grams or 0
+            untracked_grams = max(0.0, archive_grams - tracked_grams)
+            if untracked_grams > 0 and default_filament_cost > 0:
+                total_cost += (untracked_grams / 1000.0) * default_filament_cost
             if total_cost > 0:
-                archive.cost = round(total_cost, 2)
-                await db.commit()
+                # Only overwrite archive.cost on the first run. Reprint actuals
+                # live in PrintLogEntry; the archive card keeps the first run's
+                # cost so a failed reprint doesn't visually clobber a successful
+                # 100 g/$X print with a 10 g/$X/10 partial (#1378).
+                _existing_runs_result = await db.execute(
+                    select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == archive_id)
+                )
+                _existing_runs = _existing_runs_result.scalar()
+                if not _existing_runs:
+                    archive.cost = round(total_cost, 2)
+                    await db.commit()
 
     return results
 

+ 54 - 2
backend/app/services/virtual_printer/manager.py

@@ -217,9 +217,18 @@ class VirtualPrinterInstance:
         else:
             await self._queue_file(file_path, source_ip)
 
-        # Reset MQTT status back to IDLE
+        # Signal job completion to the slicer. Send-flow slicers don't watch the
+        # post-upload state and would be happy with anything; the Print flow
+        # (intended for proxy-mode VPs, but users sometimes click it against
+        # queue/immediate/review modes too — #1280) watches the gcode_state
+        # cycle and only releases its in-flight-job lock when it sees FINISH.
+        # Going PREPARE → IDLE wedges the slicer's UI at "Downloading...(0%)"
+        # and blocks the next dispatch with "busy with another print job".
+        # PREPARE → FINISH satisfies both flows. prepare_percent=100 also
+        # unfreezes the slicer's "Downloading X%" progress bar which it ticks
+        # against the same field during the upload window.
         if self._mqtt and file_path.suffix.lower() == ".3mf":
-            self._mqtt.set_gcode_state("IDLE")
+            self._mqtt.set_gcode_state("FINISH", filename=file_path.name, prepare_percent="100")
 
     async def on_print_command(self, filename: str, data: dict) -> None:
         """Handle print command from MQTT."""
@@ -260,6 +269,7 @@ class VirtualPrinterInstance:
                 )
                 if archive:
                     logger.info("[VP %s] Archived: %s - %s", self.name, archive.id, archive.print_name)
+                    await self._broadcast_archive_created(archive)
                     try:
                         file_path.unlink()
                     except OSError:
@@ -345,6 +355,20 @@ class VirtualPrinterInstance:
             async with self._session_factory() as db:
                 name_source = await get_setting(db, "virtual_printer_archive_name_source")
                 prefer_filename = name_source == "filename"
+
+                # Read workflow defaults from settings. Without this the
+                # PrintQueueItem below would fall back to the column-level
+                # defaults and ignore the user's workflow preferences (#1235).
+                # Fallbacks match AppSettings defaults in schemas/settings.py.
+                def _bool_setting(value: str | None, default: bool) -> bool:
+                    return value.lower() == "true" if value is not None else default
+
+                bed_levelling = _bool_setting(await get_setting(db, "default_bed_levelling"), True)
+                flow_cali = _bool_setting(await get_setting(db, "default_flow_cali"), False)
+                vibration_cali = _bool_setting(await get_setting(db, "default_vibration_cali"), True)
+                layer_inspect = _bool_setting(await get_setting(db, "default_layer_inspect"), False)
+                timelapse = _bool_setting(await get_setting(db, "default_timelapse"), False)
+
                 service = ArchiveService(db)
                 archive = await service.archive_print(
                     printer_id=None,
@@ -405,10 +429,16 @@ class VirtualPrinterInstance:
                         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.commit()
                     logger.info("[VP %s] Added to queue: %s", self.name, queue_item.id)
+                    await self._broadcast_archive_created(archive)
                     try:
                         file_path.unlink()
                     except OSError:
@@ -419,6 +449,28 @@ class VirtualPrinterInstance:
         except Exception as e:
             logger.error("Error adding to print queue: %s", e)
 
+    async def _broadcast_archive_created(self, archive) -> None:
+        """Notify connected clients that a new archive exists.
+
+        Real-printer prints get this from main.py's MQTT print_start handler;
+        VP-uploaded prints need their own broadcast or the Archives page stays
+        stale until the user switches tabs (#1282).
+        """
+        try:
+            from backend.app.core.websocket import ws_manager
+
+            await ws_manager.send_archive_created(
+                {
+                    "id": archive.id,
+                    "printer_id": archive.printer_id,
+                    "filename": archive.filename,
+                    "print_name": archive.print_name,
+                    "status": archive.status,
+                }
+            )
+        except Exception as e:
+            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."""

+ 38 - 1
backend/app/services/virtual_printer/mqtt_bridge.py

@@ -48,6 +48,25 @@ 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",
+)
+
 
 def _ip_to_uint32_le(ip_str: str) -> int:
     """Encode dotted-quad IPv4 as little-endian uint32 (Bambu MQTT's `net.info[].ip` shape)."""
@@ -261,7 +280,25 @@ class MQTTBridge:
                                 entry["ip"] = self._vp_ip_uint32_le
             # Defensive deep copy on store so the cache is fully decoupled from
             # the freshly-parsed tree and from any reader's reference.
-            self._latest_print_state = copy.deepcopy(print_data)
+            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).
+            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 and sticky_key in prev:
+                        new_state[sticky_key] = prev[sticky_key]
+            self._latest_print_state = new_state
             return
 
         # info.get_version responses → cache the module list so the synthetic

+ 9 - 5
backend/app/services/virtual_printer/tailscale.py

@@ -6,11 +6,15 @@ they want to reach a VP over Tailscale.
 
 Historical note: this module previously provisioned Let's Encrypt certs via
 `tailscale cert` so the slicer would not need a manual CA import. That path
-was removed because BambuStudio's printer-MQTT trust path validates only
-against its bundled BBL CA (not the system trust store), so LE-signed certs
-are rejected regardless of hostname/IP. The self-signed CA flow (with one-
-time `bbl_ca.crt` import into the slicer) is the only viable trust mechanism;
-Tailscale's role is now strictly network reach.
+was removed because LE-signed certs can't help on two independent dimensions:
+(1) BambuStudio / OrcaSlicer printer-MQTT trust validates only against the
+bundled BBL CA, not the system trust store, so non-BBL chains are rejected
+at the issuer check; (2) both slicers' Add Printer dialog accepts only an
+IP address (not a hostname), so even if the trust store accepted the LE
+issuer, the cert's hostname (`*.<tailnet>.ts.net`) couldn't match the
+`100.x.x.x` connection target. The self-signed CA flow (one-time `bbl_ca.crt`
+import into the slicer) is the only viable trust mechanism; Tailscale's role
+is now strictly network reach.
 """
 
 import asyncio

+ 0 - 0
backend/tests/_fixtures/__init__.py


+ 122 - 0
backend/tests/_fixtures/oidc_icon.py

@@ -0,0 +1,122 @@
+"""Shared test data and mock builders for OIDC icon tests (#1333).
+
+Cross-imported from ``backend.tests.unit.*`` and ``backend.tests.integration.*``
+following the established pattern (see ``backend/tests/unit/services/conftest.py``
+which imports ``mock_ftp_server``).
+
+Two mock builders are provided because ``fetch_icon`` evolved from a single
+``client.get(...)`` call into a streaming ``client.stream(...).aiter_bytes()``
+loop:
+
+* ``build_get_icon_mock`` — the pre-streaming pattern, kept for tests that
+  exercise httpx.AsyncClient.get() directly (e.g. routes that use httpx
+  outside the icon-fetcher).
+* ``build_streaming_icon_mock`` — the current ``fetch_icon`` pattern; tests
+  that exercise the size-cap early-exit need this.
+
+Both produce ``(MockHttpxClient, call_recorder)`` tuples for patching.
+"""
+
+import hashlib
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+# Tiny valid 1×1 transparent PNG (~70 bytes) — small enough to fit in one
+# 4 KB chunk during streaming tests; suitable for the happy-path everywhere.
+PNG_BYTES = bytes.fromhex(
+    "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4"
+    "890000000d49444154789c63000100000005000100"
+    "0d0a2db40000000049454e44ae426082"
+)
+PNG_ETAG = hashlib.sha256(PNG_BYTES).hexdigest()
+
+
+def build_get_icon_mock(
+    *,
+    body: bytes = PNG_BYTES,
+    content_type: str | None = "image/png",
+    status_code: int = 200,
+):
+    """Returns ``(MockHttpxClient, mock_get)`` for the pre-streaming pattern.
+
+    ``mock_get`` is an ``AsyncMock`` so tests can assert call count and
+    inspect kwargs (e.g. ``follow_redirects=False``).
+
+    Passing ``content_type=None`` produces a response with no Content-Type
+    header at all (distinct from ``""``) — used to exercise the missing-
+    header branch.
+    """
+    headers: dict[str, str] = {"content-type": content_type} if content_type is not None else {}
+    response = SimpleNamespace(status_code=status_code, headers=headers, content=body)
+    mock_get = AsyncMock(return_value=response)
+
+    class _MockHttpxClient:
+        def __init__(self, *_args, **_kwargs):
+            pass
+
+        async def __aenter__(self):
+            return SimpleNamespace(get=mock_get)
+
+        async def __aexit__(self, *_exc):
+            return False
+
+    return _MockHttpxClient, mock_get
+
+
+def build_streaming_icon_mock(
+    *,
+    body: bytes = PNG_BYTES,
+    content_type: str | None = "image/png",
+    status_code: int = 200,
+    chunk_size: int = 4096,
+):
+    """Returns ``(MockHttpxClient, stream_recorder)`` for the current
+    ``client.stream("GET", url, follow_redirects=...)`` + ``aiter_bytes()`` path.
+
+    ``stream_recorder`` is an ``AsyncMock`` that records every ``.stream()``
+    call so tests can assert e.g. ``follow_redirects=False`` was passed.
+
+    ``body`` is emitted in ``chunk_size``-byte chunks. Tests of the size-cap
+    early-exit should pick a chunk_size that crosses the cap mid-stream
+    (e.g. body = 2 MB, chunk_size = 4096 → cap fires after ~256 chunks
+    without buffering the whole payload).
+    """
+    headers: dict[str, str] = {"content-type": content_type} if content_type is not None else {}
+    stream_recorder = AsyncMock()
+
+    async def _aiter_bytes():
+        for i in range(0, len(body), chunk_size):
+            yield body[i : i + chunk_size]
+
+    response = SimpleNamespace(
+        status_code=status_code,
+        headers=headers,
+        aiter_bytes=_aiter_bytes,
+    )
+
+    class _StreamCtx:
+        def __init__(self, *args, **kwargs):
+            # Record the .stream() call positional + keyword args so tests
+            # can assert `follow_redirects=False` etc.
+            stream_recorder(*args, **kwargs)
+
+        async def __aenter__(self):
+            return response
+
+        async def __aexit__(self, *_exc):
+            return False
+
+    class _MockHttpxClient:
+        def __init__(self, *_args, **_kwargs):
+            pass
+
+        async def __aenter__(self):
+            return self
+
+        async def __aexit__(self, *_exc):
+            return False
+
+        def stream(self, *args, **kwargs):
+            return _StreamCtx(*args, **kwargs)
+
+    return _MockHttpxClient, stream_recorder

+ 38 - 1
backend/tests/conftest.py

@@ -116,6 +116,7 @@ async def test_engine():
         notification,
         notification_template,
         oidc_provider,
+        print_log,
         print_queue,
         printer,
         project,
@@ -504,10 +505,21 @@ def notification_provider_factory(db_session):
 
 @pytest.fixture
 def archive_factory(db_session):
-    """Factory to create test archives."""
+    """Factory to create test archives.
+
+    Also synthesizes one PrintLogEntry per archive (matching the production
+    flow where statistics are aggregated from PrintLogEntry, not PrintArchive,
+    per #1378). Pass ``with_run=False`` to skip — useful for testing the
+    "archived but never printed" state. Pass ``run_status=...`` to override
+    the run's status independently of the archive's status field.
+    """
 
     async def _create_archive(printer_id: int, **kwargs):
         from backend.app.models.archive import PrintArchive
+        from backend.app.models.print_log import PrintLogEntry
+
+        with_run = kwargs.pop("with_run", True)
+        run_status = kwargs.pop("run_status", None)
 
         defaults = {
             "printer_id": printer_id,
@@ -526,6 +538,31 @@ def archive_factory(db_session):
         db_session.add(archive)
         await db_session.commit()
         await db_session.refresh(archive)
+
+        if with_run:
+            duration = None
+            if archive.started_at and archive.completed_at:
+                duration = int((archive.completed_at - archive.started_at).total_seconds()) or None
+            run = PrintLogEntry(
+                archive_id=archive.id,
+                printer_id=archive.printer_id,
+                status=run_status or archive.status,
+                started_at=archive.started_at,
+                completed_at=archive.completed_at,
+                duration_seconds=duration,
+                filament_type=archive.filament_type,
+                filament_color=archive.filament_color,
+                filament_used_grams=archive.filament_used_grams,
+                cost=archive.cost,
+                energy_kwh=archive.energy_kwh,
+                energy_cost=archive.energy_cost,
+                failure_reason=archive.failure_reason,
+                print_name=archive.print_name,
+                created_by_id=archive.created_by_id,
+            )
+            db_session.add(run)
+            await db_session.commit()
+
         return archive
 
     return _create_archive

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

@@ -196,6 +196,215 @@ class TestArchivesAPI:
 
         assert response.status_code == 404
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_soft_delete_preserves_stats_contribution(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """#1343: deleting an archive without ``purge_stats`` keeps its
+        contribution in Quick Stats. The row vanishes from listings but the
+        filament / time / cost totals stay intact.
+        """
+        printer = await printer_factory()
+        await archive_factory(
+            printer.id,
+            status="completed",
+            print_time_seconds=3600,
+            filament_used_grams=50.0,
+            cost=1.50,
+        )
+        archive_to_delete = await archive_factory(
+            printer.id,
+            status="completed",
+            print_time_seconds=7200,
+            filament_used_grams=100.0,
+            cost=3.00,
+        )
+
+        # Pre-delete: stats include both archives.
+        pre = (await async_client.get("/api/v1/archives/stats")).json()
+        assert pre["total_prints"] == 2
+        assert pre["total_filament_grams"] == 150.0
+        assert pre["total_cost"] == 4.50
+
+        # Soft delete (default — no purge_stats param).
+        resp = await async_client.delete(f"/api/v1/archives/{archive_to_delete.id}")
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["purged_from_stats"] is False
+
+        # Listing hides the deleted archive…
+        listing = (await async_client.get("/api/v1/archives/")).json()
+        assert all(a["id"] != archive_to_delete.id for a in listing)
+
+        # …but stats still reflect both prints (the whole point of #1343).
+        post = (await async_client.get("/api/v1/archives/stats")).json()
+        assert post["total_prints"] == 2
+        assert post["total_filament_grams"] == 150.0
+        assert post["total_cost"] == 4.50
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_soft_delete_clears_thumbnail_path_on_linked_log_entries(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """#1348 follow-up: soft-deleting an archive removes its files from disk;
+        the cached thumbnail_path on linked PrintLogEntry rows must be NULLed
+        in the same transaction so the print-log view doesn't 404-storm on the
+        now-deleted thumbnail file."""
+        from sqlalchemy import select
+
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            status="completed",
+            thumbnail_path="archives/test/test_print/thumbnail.png",
+        )
+        # The factory's auto-PrintLogEntry doesn't copy thumbnail_path; set it
+        # manually to mirror what the production write_log_entry path stores.
+        run_query = await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
+        run = run_query.scalar_one()
+        run.thumbnail_path = "archives/test/test_print/thumbnail.png"
+        await db_session.commit()
+        assert run.thumbnail_path is not None
+
+        resp = await async_client.delete(f"/api/v1/archives/{archive.id}")
+        assert resp.status_code == 200
+        assert resp.json()["purged_from_stats"] is False
+
+        await db_session.refresh(run)
+        assert run.thumbnail_path is None, "soft-delete must NULL thumbnail_path on linked log entry"
+        # The log entry itself survives the soft delete (its filament/cost
+        # contribution still needs to flow into stats per #1343).
+        assert run.id is not None
+        assert run.archive_id == archive.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_hard_delete_clears_thumbnail_path_before_fk_cascade(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """#1348 follow-up: the auto-purge sweeper (and any caller of
+        ArchiveService.delete_archive) hard-deletes the archive row but leaves
+        PrintLogEntry rows alive via ON DELETE SET NULL. The eager
+        thumbnail_path clear must run inside delete_archive so even orphaned
+        log entries don't surface stale paths."""
+        from sqlalchemy import select
+
+        from backend.app.models.print_log import PrintLogEntry
+        from backend.app.services.archive import ArchiveService
+
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            status="completed",
+            thumbnail_path="archives/test/test_print/thumbnail.png",
+        )
+        run_query = await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
+        run = run_query.scalar_one()
+        run.thumbnail_path = "archives/test/test_print/thumbnail.png"
+        await db_session.commit()
+        run_id = run.id
+
+        service = ArchiveService(db_session)
+        assert await service.delete_archive(archive.id) is True
+
+        # Log entry survives the hard-delete (the FK is ON DELETE SET NULL
+        # in production; SQLite test config doesn't enable foreign_keys=ON
+        # by default so archive_id may still be set, but the row itself
+        # remains for audit). The thumbnail_path was cleared eagerly by
+        # _null_print_log_thumbnail_paths before db.delete(archive).
+        refetch = await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.id == run_id))
+        survivor = refetch.scalar_one()
+        assert survivor.thumbnail_path is None, (
+            "delete_archive must NULL thumbnail_path before removing the archive row"
+        )
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_print_log_thumbnail_route_lazy_nulls_missing_file(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """#1348 follow-up: GET /print-log/{id}/thumbnail self-heals when the
+        thumbnail_path on a log entry points at a missing file (failed print
+        whose thumbnail was never written, or a stale path that escaped the
+        delete-time cleanup)."""
+        from sqlalchemy import select
+
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, status="failed")
+        run_query = await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
+        run = run_query.scalar_one()
+        # Path points at a file that never existed (failed-print case where
+        # archive.thumbnail_path was set but the extractor never produced one).
+        run.thumbnail_path = "archives/missing/never_written/thumbnail.png"
+        await db_session.commit()
+
+        # Auth is disabled in the integration test config, so the stream-token
+        # guard is bypassed — the route runs the lazy-NULL branch directly.
+        resp = await async_client.get(f"/api/v1/print-log/{run.id}/thumbnail")
+        assert resp.status_code == 404
+
+        await db_session.refresh(run)
+        assert run.thumbnail_path is None, "missing file must self-heal to NULL"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_purge_stats_drops_archive_from_quick_stats(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """#1343: deleting with ``?purge_stats=true`` hard-deletes the row,
+        dropping its contribution from Quick Stats (the original behaviour,
+        now opt-in)."""
+        printer = await printer_factory()
+        keep = await archive_factory(printer.id, status="completed", filament_used_grams=50.0)
+        purge = await archive_factory(printer.id, status="completed", filament_used_grams=100.0)
+
+        resp = await async_client.delete(f"/api/v1/archives/{purge.id}?purge_stats=true")
+        assert resp.status_code == 200
+        assert resp.json()["purged_from_stats"] is True
+
+        stats = (await async_client.get("/api/v1/archives/stats")).json()
+        assert stats["total_prints"] == 1
+        assert stats["total_filament_grams"] == 50.0
+
+        # The kept archive is still listed.
+        listing = (await async_client.get("/api/v1/archives/")).json()
+        assert [a["id"] for a in listing] == [keep.id]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_soft_deleted_archive_404_on_detail(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """A soft-deleted archive must 404 on GET — a stale bookmark or
+        direct URL should not expose a row the user has already removed."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id)
+        await async_client.delete(f"/api/v1/archives/{archive.id}")
+        resp = await async_client.get(f"/api/v1/archives/{archive.id}")
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_soft_deleted_archive_hidden_from_search(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Search must skip soft-deleted archives. Uses the LIKE fallback by
+        querying a single-character pattern that the SQLite FTS5 rejects, so
+        the test covers the fallback path that the production FTS path also
+        respects."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, print_name="UniqueSoftDeleteCandidate")
+        await async_client.delete(f"/api/v1/archives/{archive.id}")
+        resp = await async_client.get("/api/v1/archives/search?q=UniqueSoftDeleteCandidate")
+        assert resp.status_code == 200
+        assert resp.json() == []
+
     # ========================================================================
     # Statistics endpoints
     # ========================================================================

+ 26 - 2
backend/tests/integration/test_auth_api.py

@@ -424,8 +424,23 @@ class TestUsersAPI:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_delete_user(self, async_client: AsyncClient, auth_token: str):
-        """Verify admin can delete a user."""
+    async def test_delete_user(self, async_client: AsyncClient, auth_token: str, db_session):
+        """Verify admin can delete a user and that all auth-table side effects cascade.
+
+        The auth-cleanup side effects matter on SQLite (FK enforcement off by default):
+        without explicit DELETEs in the endpoint, deleting a user leaves orphan rows
+        in user_oidc_links / user_totp / user_otp_codes / api_keys — which would
+        block SSO re-login and leak MFA secrets (#1285).
+        """
+        from sqlalchemy import select
+
+        from backend.app.models.api_key import APIKey
+        from backend.app.models.long_lived_token import LongLivedToken
+        from backend.app.models.oidc_provider import UserOIDCLink
+        from backend.app.models.user import User
+        from backend.app.models.user_otp_code import UserOTPCode
+        from backend.app.models.user_totp import UserTOTP
+
         # Create user
         create_response = await async_client.post(
             "/api/v1/users/",
@@ -446,6 +461,15 @@ class TestUsersAPI:
 
         assert response.status_code == 204
 
+        # All auth-related rows for this user must be gone — see #1285.
+        await db_session.commit()
+        user_row = await db_session.execute(select(User).where(User.id == user_id))
+        assert user_row.scalar_one_or_none() is None, "User row not deleted"
+
+        for model in (UserOIDCLink, UserTOTP, UserOTPCode, APIKey, LongLivedToken):
+            rows = await db_session.execute(select(model).where(model.user_id == user_id))
+            assert rows.scalars().all() == [], f"Orphan {model.__name__} rows left after user delete"
+
 
 class TestAuthDisableAPI:
     """Integration tests for /api/v1/auth/disable endpoint."""

+ 161 - 0
backend/tests/integration/test_camera_api.py

@@ -239,6 +239,35 @@ class TestCameraAPI:
         assert response.status_code == 503
         assert "Failed to capture" in response.json()["detail"]
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_camera_snapshot_reuses_buffered_frame_when_stream_active(
+        self, async_client: AsyncClient, printer_factory
+    ):
+        """#1271: /camera/snapshot must reuse the broadcaster's buffered frame
+        when a live stream is running, instead of opening a second concurrent
+        RTSP socket. On printers with strict single-connection enforcement (e.g.
+        X2D firmware 01.01.00.00) opening a second socket kicks the live stream.
+        """
+        printer = await printer_factory()
+        fake_jpeg = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00"
+
+        # Simulate a running broadcaster: one active stream entry + buffered frame.
+        active_streams = {f"{printer.id}-fanout": MagicMock()}
+        last_frames = {printer.id: fake_jpeg}
+
+        with (
+            patch("backend.app.api.routes.camera._active_streams", active_streams),
+            patch("backend.app.api.routes.camera._last_frames", last_frames),
+            patch("backend.app.api.routes.camera.capture_camera_frame", new_callable=AsyncMock) as mock_capture,
+        ):
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
+
+        assert response.status_code == 200
+        assert response.content == fake_jpeg
+        # The fresh-capture path must NOT have been taken — that's the whole point.
+        mock_capture.assert_not_called()
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_camera_snapshot_external_camera_success(self, async_client: AsyncClient, printer_factory):
@@ -430,6 +459,138 @@ class TestCameraAPI:
         assert result["success"] is True
         assert "index" in result
 
+    # ------------------------------------------------------------------
+    # Regression: #1359 — the manual UI check/calibrate routes must derive
+    # use_external from the printer's external_camera_enabled setting when
+    # the caller omits the flag. Otherwise the UI calibrates against the
+    # built-in camera while the runtime auto-check at print start uses the
+    # external one, producing a permanent "build plate not empty".
+    # ------------------------------------------------------------------
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_check_plate_defaults_use_external_when_external_camera_enabled(
+        self, async_client: AsyncClient, printer_factory
+    ):
+        """Omitting use_external on a printer with external camera enabled
+        must call the service with use_external=True."""
+        printer = await printer_factory(
+            external_camera_enabled=True,
+            external_camera_url="http://192.168.1.50/mjpeg",
+            external_camera_type="mjpeg",
+        )
+
+        mock_result = MagicMock()
+        mock_result.to_dict.return_value = {
+            "is_empty": True,
+            "confidence": 0.95,
+            "difference_percent": 0.5,
+            "message": "Plate appears empty",
+            "has_debug_image": False,
+            "needs_calibration": False,
+        }
+        mock_result.debug_image = None
+
+        mock_detector = MagicMock()
+        mock_detector.get_calibration_count.return_value = 0
+        mock_detector.MAX_REFERENCES = 5
+
+        with (
+            patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True),
+            patch("backend.app.services.plate_detection.check_plate_empty", new_callable=AsyncMock) as mock_check,
+            patch("backend.app.services.plate_detection.PlateDetector", return_value=mock_detector),
+        ):
+            mock_check.return_value = mock_result
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/check-plate")
+
+        assert response.status_code == 200
+        assert mock_check.await_args.kwargs["use_external"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_check_plate_defaults_use_external_false_when_external_camera_disabled(
+        self, async_client: AsyncClient, printer_factory
+    ):
+        """Omitting use_external on a printer without an external camera
+        must call the service with use_external=False (built-in)."""
+        printer = await printer_factory()  # external_camera_enabled defaults to False
+
+        mock_result = MagicMock()
+        mock_result.to_dict.return_value = {
+            "is_empty": True,
+            "confidence": 0.95,
+            "difference_percent": 0.5,
+            "message": "Plate appears empty",
+            "has_debug_image": False,
+            "needs_calibration": False,
+        }
+        mock_result.debug_image = None
+
+        mock_detector = MagicMock()
+        mock_detector.get_calibration_count.return_value = 0
+        mock_detector.MAX_REFERENCES = 5
+
+        with (
+            patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True),
+            patch("backend.app.services.plate_detection.check_plate_empty", new_callable=AsyncMock) as mock_check,
+            patch("backend.app.services.plate_detection.PlateDetector", return_value=mock_detector),
+        ):
+            mock_check.return_value = mock_result
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/check-plate")
+
+        assert response.status_code == 200
+        assert mock_check.await_args.kwargs["use_external"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_calibrate_plate_defaults_use_external_when_external_camera_enabled(
+        self, async_client: AsyncClient, printer_factory
+    ):
+        """Calibrating with use_external omitted on an external-camera-enabled
+        printer captures the reference from the external camera — matching
+        what the runtime check at print start will compare against (#1359)."""
+        printer = await printer_factory(
+            external_camera_enabled=True,
+            external_camera_url="http://192.168.1.50/mjpeg",
+            external_camera_type="mjpeg",
+        )
+
+        with (
+            patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True),
+            patch("backend.app.services.plate_detection.calibrate_plate", new_callable=AsyncMock) as mock_calibrate,
+        ):
+            mock_calibrate.return_value = (True, "Calibration saved (1/5 references)", 0)
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/camera/plate-detection/calibrate")
+
+        assert response.status_code == 200
+        assert mock_calibrate.await_args.kwargs["use_external"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_calibrate_plate_explicit_use_external_false_overrides_default(
+        self, async_client: AsyncClient, printer_factory
+    ):
+        """An explicit use_external=false from the caller still wins even
+        when the printer has an external camera configured, so power users
+        can force a built-in-camera reference if they ever need to."""
+        printer = await printer_factory(
+            external_camera_enabled=True,
+            external_camera_url="http://192.168.1.50/mjpeg",
+            external_camera_type="mjpeg",
+        )
+
+        with (
+            patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True),
+            patch("backend.app.services.plate_detection.calibrate_plate", new_callable=AsyncMock) as mock_calibrate,
+        ):
+            mock_calibrate.return_value = (True, "Calibration saved (1/5 references)", 0)
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/camera/plate-detection/calibrate?use_external=false"
+            )
+
+        assert response.status_code == 200
+        assert mock_calibrate.await_args.kwargs["use_external"] is False
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_delete_calibration_printer_not_found(self, async_client: AsyncClient):

+ 5 - 1
backend/tests/integration/test_cost_statistics.py

@@ -350,12 +350,16 @@ class TestCostCalculationScenarios:
         await db_session.refresh(spool_new)
         await db_session.refresh(spool_old)
 
-        # Create archive with new SpoolUsageHistory (archive_id set)
+        # Create archive with new SpoolUsageHistory (archive_id set).
+        # filament_used_grams matches the tracked weight so the #1344 default-
+        # rate top-up for untracked filament doesn't apply -- this test pins
+        # the query routing, not the top-up branch.
         archive_new = await archive_factory(
             printer.id,
             print_name="UniquePrint",
             status="completed",
             cost=None,
+            filament_used_grams=20.0,
         )
 
         history_new = SpoolUsageHistory(

+ 230 - 58
backend/tests/integration/test_inventory_assign.py

@@ -60,8 +60,13 @@ class TestAssignSpoolTrayInfoIdx:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_pfus_slicer_filament_used_directly(self, async_client: AsyncClient, printer_factory, spool_factory):
-        """PFUS* cloud-synced custom preset IDs are sent to the printer."""
+    async def test_pfus_slicer_filament_falls_back_to_generic(
+        self, async_client: AsyncClient, printer_factory, spool_factory
+    ):
+        """PFUS* cloud setting_ids are rejected by the slicer as tray_info_idx, so the
+        no-kp path falls back to the generic material id (PLA → GFL99). The K-profile
+        realignment path translates PFUS → P-prefix when a stored kp exists; that's
+        covered separately."""
         printer = await printer_factory(name="H2D")
         spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
 
@@ -82,14 +87,14 @@ class TestAssignSpoolTrayInfoIdx:
 
             assert response.status_code == 200
             call_kwargs = mock_client.ams_set_filament_setting.call_args
-            assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_spool_preset_takes_priority_over_slot(
-        self, async_client: AsyncClient, printer_factory, spool_factory
-    ):
-        """Spool's own slicer_filament takes priority over slot's existing preset."""
+    async def test_pfus_spool_reuses_valid_slot_preset(self, async_client: AsyncClient, printer_factory, spool_factory):
+        """When the spool's PFUS gets discarded as slicer-invalid, the slot's existing
+        valid P-prefix preset is reused if it matches the spool's material — preserves
+        the printer's calibration context rather than resetting to generic."""
         printer = await printer_factory(name="H2D")
         spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
 
@@ -113,15 +118,15 @@ class TestAssignSpoolTrayInfoIdx:
 
             assert response.status_code == 200
             call_kwargs = mock_client.ams_set_filament_setting.call_args
-            # Spool's own preset wins over slot's existing one
-            assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
+            assert call_kwargs.kwargs["tray_info_idx"] == "P4d64437"
 
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_spool_preset_used_even_if_different_material_on_slot(
         self, async_client: AsyncClient, printer_factory, spool_factory
     ):
-        """Spool's own slicer_filament is used regardless of what's on the slot."""
+        """Spool's material drives the fallback generic id. Slot's existing PLA preset
+        is overridden because the spool is PETG → GFG99."""
         printer = await printer_factory(name="H2D")
         spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PETG")
 
@@ -145,7 +150,7 @@ class TestAssignSpoolTrayInfoIdx:
 
             assert response.status_code == 200
             call_kwargs = mock_client.ams_set_filament_setting.call_args
-            assert call_kwargs.kwargs["tray_info_idx"] == "PFUS9ac902733670a9"
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFG99"
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -201,8 +206,11 @@ class TestAssignSpoolTrayInfoIdx:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_spool_pfus_used_over_slot_pfus(self, async_client: AsyncClient, printer_factory, spool_factory):
-        """Spool's own PFUS preset is used even when slot has a different PFUS."""
+    async def test_spool_pfus_falls_back_to_generic_over_slot_pfus(
+        self, async_client: AsyncClient, printer_factory, spool_factory
+    ):
+        """Both spool and slot have PFUS values — both rejected as tray_info_idx —
+        falls back to generic material id (PLA → GFL99)."""
         printer = await printer_factory(name="H2D")
         spool = await spool_factory(slicer_filament="PFUS1111111111", material="PLA")
 
@@ -226,15 +234,17 @@ class TestAssignSpoolTrayInfoIdx:
 
             assert response.status_code == 200
             call_kwargs = mock_client.ams_set_filament_setting.call_args
-            # Spool's own preset wins
-            assert call_kwargs.kwargs["tray_info_idx"] == "PFUS1111111111"
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_generic_on_slot_not_reused_over_spool_preset(
+    async def test_generic_on_slot_falls_back_to_material_generic(
         self, async_client: AsyncClient, printer_factory, spool_factory
     ):
-        """Generic ID on slot (e.g. GFB99) must not override spool's own preset."""
+        """When spool's PFUS is discarded and slot only has a generic ID, the result
+        comes from the spool's material (ABS → GFB99) — not from the slot. Important
+        because the generic-id check (`not in _generic_id_values`) prevents stale
+        generic reuse and routes the decision through the material fallback."""
         printer = await printer_factory(name="P2S")
         spool = await spool_factory(slicer_filament="PFUScda4c46fc9031", material="ABS")
 
@@ -258,8 +268,7 @@ class TestAssignSpoolTrayInfoIdx:
 
             assert response.status_code == 200
             call_kwargs = mock_client.ams_set_filament_setting.call_args
-            # Spool's preset wins — generic on slot must not be sticky
-            assert call_kwargs.kwargs["tray_info_idx"] == "PFUScda4c46fc9031"
+            assert call_kwargs.kwargs["tray_info_idx"] == "GFB99"
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -471,18 +480,21 @@ class TestAssignSpoolPresetMapping:
 
 
 class TestAssignSpoolLiveCaliIdx:
-    """P9-TEST-BE-3: assign_spool falls back to live tray cali_idx when no K-profile stored."""
+    """assign_spool always resets the slot to Default K when the spool has no stored K-profile."""
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_no_kprofile_uses_live_cali_idx(self, async_client: AsyncClient, printer_factory, spool_factory):
-        """When no KProfile row exists, live tray cali_idx is sent via extrusion_cali_sel."""
+    async def test_no_kprofile_resets_to_default_k(self, async_client: AsyncClient, printer_factory, spool_factory):
+        """When no KProfile row exists, slot resets to cali_idx=-1 (Default K) regardless of live value."""
         printer = await printer_factory()
         spool = await spool_factory()
 
         mock_client = MagicMock()
         mock_client.ams_set_filament_setting.return_value = True
         mock_client.extrusion_cali_sel.return_value = True
+        # Live cali_idx=42 belongs to whatever filament was previously calibrated
+        # in this slot. Applying it to a different spool would use the wrong K
+        # value, so the assign flow must override it with Default K (-1).
         tray_data = {
             "id": 1,
             "cali_idx": 42,
@@ -504,15 +516,14 @@ class TestAssignSpoolLiveCaliIdx:
 
         assert response.status_code == 200
         mock_client.extrusion_cali_sel.assert_called_once()
-        call_kwargs = mock_client.extrusion_cali_sel.call_args[1]
-        assert call_kwargs["cali_idx"] == 42
+        assert mock_client.extrusion_cali_sel.call_args[1]["cali_idx"] == -1
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_no_kprofile_no_live_cali_idx_nothing_sent(
+    async def test_no_kprofile_no_live_cali_idx_sends_default(
         self, async_client: AsyncClient, printer_factory, spool_factory
     ):
-        """When tray has no cali_idx, extrusion_cali_sel is not called."""
+        """When tray has no cali_idx, extrusion_cali_sel is sent with cali_idx=-1 (Default)."""
         printer = await printer_factory()
         spool = await spool_factory()
 
@@ -539,12 +550,15 @@ class TestAssignSpoolLiveCaliIdx:
             )
 
         assert response.status_code == 200
-        mock_client.extrusion_cali_sel.assert_not_called()
+        mock_client.extrusion_cali_sel.assert_called_once()
+        assert mock_client.extrusion_cali_sel.call_args[1]["cali_idx"] == -1
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_negative_live_cali_idx_not_sent(self, async_client: AsyncClient, printer_factory, spool_factory):
-        """A negative live cali_idx (-1) is invalid and must not be sent."""
+    async def test_negative_live_cali_idx_sends_default(
+        self, async_client: AsyncClient, printer_factory, spool_factory
+    ):
+        """A negative live cali_idx (-1) falls through and is sent as Default (cali_idx=-1)."""
         printer = await printer_factory()
         spool = await spool_factory()
 
@@ -571,25 +585,30 @@ class TestAssignSpoolLiveCaliIdx:
             )
 
         assert response.status_code == 200
-        mock_client.extrusion_cali_sel.assert_not_called()
+        mock_client.extrusion_cali_sel.assert_called_once()
+        assert mock_client.extrusion_cali_sel.call_args[1]["cali_idx"] == -1
 
 
 class TestAssignSpoolEmptySlotPreConfig:
-    """SpoolBuddy primary workflow: weigh-then-assign before the spool is in the AMS.
-
-    Bambu firmware silently drops ams_filament_setting / extrusion_cali_sel for
-    unloaded slots — there's no filament context for the cali_idx to attach to.
-    The endpoint persists the SpoolAssignment row with an empty fingerprint_type
-    (the "pending config" marker) and skips the MQTT publish; on_ams_change
-    re-fires the full configuration when filament is later inserted.
+    """Assign path under ambiguous / explicit-empty AMS state.
+
+    Updated for the #1322 follow-up: only the firmware's *explicit* empty
+    signal (state ∈ {9, 10}) skips MQTT. Anything else — including the
+    SpoolBuddy weigh-then-assign-before-insert case where state/tray_type
+    can't tell us whether a spool is loaded — attempts MQTT. The deferred-
+    config workflow still works because on_ams_change at main.py:1031-1054
+    re-fires when an AMS push eventually reports the loaded slot.
     """
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_empty_slot_skips_mqtt_but_persists_assignment(
+    async def test_empty_tray_type_without_state_still_fires_mqtt(
         self, async_client: AsyncClient, printer_factory, spool_factory
     ):
-        """Assigning to an empty slot skips MQTT and returns pending_config=True."""
+        """tray_type='' with no state field: AMS can't tell us whether a
+        spool is loaded. Trust the user's Assign click and fire MQTT —
+        firmware accepts it when a spool is physically there, drops it
+        silently otherwise (no harm)."""
         printer = await printer_factory(name="H2D")
         spool = await spool_factory(slicer_filament="GFL05", material="PLA")
 
@@ -597,7 +616,6 @@ class TestAssignSpoolEmptySlotPreConfig:
         mock_client.ams_set_filament_setting.return_value = True
         mock_client.extrusion_cali_sel.return_value = True
 
-        # Slot found but empty (tray_type=""): the SpoolBuddy scenario
         status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "tray_type": ""}]}])
 
         with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
@@ -610,27 +628,27 @@ class TestAssignSpoolEmptySlotPreConfig:
             )
 
         assert response.status_code == 200
+        mock_client.ams_set_filament_setting.assert_called_once()
         body = response.json()
-        assert body["pending_config"] is True
-        assert body["configured"] is False
-        # Critical: no MQTT was published (firmware would drop it)
-        mock_client.ams_set_filament_setting.assert_not_called()
-        mock_client.extrusion_cali_sel.assert_not_called()
+        assert body["pending_config"] is False
+        assert body["configured"] is True
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_empty_slot_no_ams_data_skips_mqtt(self, async_client: AsyncClient, printer_factory, spool_factory):
-        """No AMS data at all (printer offline, no telemetry yet) → still pre-config."""
+    async def test_no_ams_data_with_no_client_marks_pending(
+        self, async_client: AsyncClient, printer_factory, spool_factory
+    ):
+        """No AMS data + no MQTT client (printer offline, no telemetry):
+        publish can't happen, so configured=False and pending_config=True so
+        on_ams_change replay picks it up when the printer comes online."""
         printer = await printer_factory(name="X1C")
         spool = await spool_factory(slicer_filament="GFL05", material="PLA")
 
-        mock_client = MagicMock()
-
-        # No AMS data — fingerprint_type stays None, treated as empty
+        # No AMS data — fingerprint_type stays None.
         status = _make_mock_status(ams_data=[])
 
         with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
-            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_client.return_value = None  # Printer offline, no MQTT client.
             mock_pm.get_status.return_value = status
 
             response = await async_client.post(
@@ -639,8 +657,9 @@ class TestAssignSpoolEmptySlotPreConfig:
             )
 
         assert response.status_code == 200
-        assert response.json()["pending_config"] is True
-        mock_client.ams_set_filament_setting.assert_not_called()
+        body = response.json()
+        assert body["pending_config"] is True
+        assert body["configured"] is False
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -797,6 +816,75 @@ class TestAssignSpoolEmptySlotPreConfig:
         # Fingerprint was already set — re-fire path skipped
         mock_client.ams_set_filament_setting.assert_not_called()
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_on_ams_change_fires_replay_when_tray_type_appears_without_state_11(
+        self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
+    ):
+        """A1 Mini / P1S firmware variant of the SpoolBuddy pre-config replay
+        (#1322). The user pre-assigned via SpoolBuddy (fingerprint empty), then
+        configured the slot manually in Bambu Studio so tray_type went from ''
+        to 'PLA' — but state stays at 3 because these firmwares never set it
+        to 11. With state-only detection the replay never fired."""
+        from unittest.mock import AsyncMock
+
+        from backend.app.main import on_ams_change
+        from backend.app.models.spool_assignment import SpoolAssignment
+
+        printer = await printer_factory(name="A1 mini")
+        spool = await spool_factory(slicer_filament="GFL05", material="PLA")
+
+        pre_assignment = SpoolAssignment(
+            spool_id=spool.id,
+            printer_id=printer.id,
+            ams_id=0,
+            tray_id=3,
+            fingerprint_color=None,
+            fingerprint_type=None,
+        )
+        db_session.add(pre_assignment)
+        await db_session.commit()
+
+        # state=3 (never goes to 11 on A1 Mini BMCU 01.07.02.00) but tray_type
+        # is now configured — the replay must fire on this transition too.
+        ams_data = [
+            {
+                "id": 0,
+                "tray": [{"id": 3, "tray_type": "PLA", "tray_color": "FF0000FF", "state": 3, "tray_info_idx": "GFL05"}],
+            }
+        ]
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+
+        status = _make_mock_status(ams_data=ams_data)
+        printer_info = MagicMock(name="A1 mini", serial_number="0309CA391800999")
+
+        with (
+            patch("backend.app.main.printer_manager") as mock_pm_main,
+            patch("backend.app.services.printer_manager.printer_manager") as mock_pm_inv,
+            patch("backend.app.main.mqtt_relay") as mock_relay,
+            patch("backend.app.main.ws_manager") as mock_ws,
+        ):
+            mock_pm_main.get_printer.return_value = printer_info
+            mock_pm_main.get_status.return_value = status
+            mock_pm_main.get_client.return_value = mock_client
+            mock_pm_main.get_model.return_value = "A1 mini"
+            mock_pm_inv.get_client.return_value = mock_client
+            mock_pm_inv.get_status.return_value = status
+            mock_relay.on_ams_change = AsyncMock()
+            mock_ws.send_printer_status = AsyncMock()
+            mock_ws.broadcast = AsyncMock()
+
+            await on_ams_change(printer.id, ams_data)
+
+        # Replay fired despite state never being 11 — the disjunction picked
+        # up tray_type going non-empty.
+        mock_client.ams_set_filament_setting.assert_called_once()
+        await db_session.refresh(pre_assignment)
+        assert pre_assignment.fingerprint_type == "PLA"
+
 
 class TestAssignSpoolEmptyDetection:
     """Bambu firmware reports tray.state — 11=loaded, 9=empty, 10=spool present
@@ -910,15 +998,23 @@ class TestAssignSpoolEmptyDetection:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_state_missing_falls_back_to_tray_type_empty(
+    async def test_state_missing_with_empty_tray_type_still_fires_mqtt(
         self, async_client: AsyncClient, printer_factory, spool_factory
     ):
-        """Older firmware without state field + empty tray_type → pending."""
+        """Older firmware without state field + empty tray_type still fires MQTT.
+
+        The AMS doesn't tell us whether a spool is physically loaded in this
+        case (no state, no tray_type), so the assign click is the user's
+        assertion that a spool is there. Firmware silently drops the push on
+        a truly empty slot — no harm done, and on_ams_change replay handles
+        the deferred-config case (#1322 follow-up).
+        """
         printer = await printer_factory()
         spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
 
         mock_client = MagicMock()
         mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
 
         tray_data = {"id": 3, "tray_type": "", "tray_color": ""}
         status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
@@ -933,10 +1029,86 @@ class TestAssignSpoolEmptyDetection:
             )
 
         assert response.status_code == 200
-        # Legacy fallback: empty tray_type + no state → treated as empty.
-        mock_client.ams_set_filament_setting.assert_not_called()
+        mock_client.ams_set_filament_setting.assert_called_once()
         body = response.json()
-        assert body["pending_config"] is True
+        assert body["pending_config"] is False
+        assert body["configured"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_state_never_eleven_firmware_with_loaded_tray_fires_mqtt(
+        self, async_client: AsyncClient, printer_factory, spool_factory
+    ):
+        """A1 Mini BMCU 01.07.02.00 and P1S Standard AMS 00.00.06.75 always
+        report tray.state=3, never 11 — even for fully-loaded configured slots.
+        A state-only check classified those as empty and skipped MQTT (#1322).
+        With the disjunctive check, tray_type='PLA' alone is enough to fire."""
+        printer = await printer_factory()
+        spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+
+        # state=3, tray_type non-empty — A1 Mini / P1S configured slot.
+        tray_data = {"id": 3, "state": 3, "tray_type": "PLA", "tray_color": "FF0000FF", "tray_info_idx": "GFL99"}
+        status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
+
+        with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = status
+
+            response = await async_client.post(
+                "/api/v1/inventory/assignments",
+                json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
+            )
+
+        assert response.status_code == 200
+        mock_client.ams_set_filament_setting.assert_called_once()
+        body = response.json()
+        assert body["pending_config"] is False
+        assert body["configured"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_post_reset_slot_with_state_3_still_fires_mqtt(
+        self, async_client: AsyncClient, printer_factory, spool_factory
+    ):
+        """A1 Mini BMCU / P1S Standard AMS post-"Reset Slot" with spool still
+        inserted: state=3, tray_type="". The AMS gives us no signal to tell
+        this apart from a truly-empty slot. We trust the user's Assign click
+        and fire MQTT — firmware accepts the push because a spool is
+        physically there (#1322 follow-up by @RosdasHH).
+
+        Replaces the previous "marks_pending" assertion which was the bug:
+        that gate created a deadlock because the AMS would never report a
+        state change (nothing physically changed), so on_ams_change replay
+        never re-fired the deferred config either.
+        """
+        printer = await printer_factory()
+        spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
+
+        mock_client = MagicMock()
+        mock_client.ams_set_filament_setting.return_value = True
+        mock_client.extrusion_cali_sel.return_value = True
+
+        tray_data = {"id": 3, "state": 3, "tray_type": "", "tray_color": "00000000", "tray_info_idx": ""}
+        status = _make_mock_status(ams_data=[{"id": 2, "tray": [tray_data]}])
+
+        with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            mock_pm.get_status.return_value = status
+
+            response = await async_client.post(
+                "/api/v1/inventory/assignments",
+                json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
+            )
+
+        assert response.status_code == 200
+        mock_client.ams_set_filament_setting.assert_called_once()
+        body = response.json()
+        assert body["pending_config"] is False
+        assert body["configured"] is True
 
     @pytest.mark.asyncio
     @pytest.mark.integration

+ 196 - 0
backend/tests/integration/test_ldap_group_sync.py

@@ -0,0 +1,196 @@
+"""Regression tests for LDAP user group sync behavior (#1292).
+
+Reporter @Fuechslein: when an admin manually assigned a BamBuddy group to an
+LDAP user, the assignment was silently wiped on the user's next login. Cause
+was that _sync_ldap_user used to replace `user.groups` entirely on every login,
+overwriting anything not derived from LDAP state.
+
+The fix partitions the user's groups into "LDAP-managed" (anything in the
+ldap_group_mapping config values + the default_group) and "manual". Only the
+LDAP-managed slice is rebuilt from LDAP truth; manual assignments survive.
+"""
+
+from dataclasses import dataclass
+
+import pytest
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.api.routes.auth import _sync_ldap_user
+from backend.app.models.group import Group
+from backend.app.models.user import User
+
+
+@dataclass
+class _FakeLdapUser:
+    """Stand-in for backend.app.services.ldap_service.LDAPUserInfo."""
+
+    username: str
+    email: str | None
+    groups: list[str]
+
+
+@dataclass
+class _FakeLdapConfig:
+    """Stand-in for backend.app.services.ldap_service.LDAPConfig — only the
+    fields _sync_ldap_user actually reads."""
+
+    group_mapping: dict[str, str]
+    default_group: str = ""
+
+
+async def _make_group(db: AsyncSession, name: str) -> Group:
+    group = Group(name=name, description=f"Test group {name}")
+    db.add(group)
+    await db.commit()
+    await db.refresh(group)
+    return group
+
+
+async def _make_ldap_user(db: AsyncSession, username: str, groups: list[Group]) -> User:
+    user = User(
+        username=username,
+        email=f"{username}@example.com",
+        password_hash=None,
+        role="user",
+        auth_source="ldap",
+        is_active=True,
+    )
+    user.groups = groups
+    db.add(user)
+    await db.commit()
+    await db.refresh(user, attribute_names=["groups"])
+    return user
+
+
+class TestLdapGroupSyncPreservesManualAssignments:
+    """The #1292 fix: groups outside the LDAP-managed set must survive logins."""
+
+    @pytest.mark.asyncio
+    async def test_manual_group_survives_login(self, db_session: AsyncSession):
+        """Admin assigns 'Administrators' to an LDAP user. 'Administrators' is
+        NOT in the LDAP group_mapping. Next login must keep it."""
+        admins = await _make_group(db_session, "Administrators")
+        users = await _make_group(db_session, "Users")
+
+        user = await _make_ldap_user(db_session, "alice", [admins])
+        assert {g.name for g in user.groups} == {"Administrators"}
+
+        ldap_user = _FakeLdapUser(
+            username="alice", email="alice@example.com", groups=["cn=staff,ou=groups,dc=example,dc=com"]
+        )
+        ldap_config = _FakeLdapConfig(
+            group_mapping={"cn=staff,ou=groups,dc=example,dc=com": "Users"},
+            default_group="",
+        )
+
+        await _sync_ldap_user(db_session, user, ldap_user, ldap_config)
+        await db_session.refresh(user, attribute_names=["groups"])
+
+        assert {g.name for g in user.groups} == {"Administrators", "Users"}, (
+            "Manual 'Administrators' assignment must be preserved; LDAP-mapped 'Users' must be added"
+        )
+        # Use the local refs to silence linters about unused locals
+        assert admins.id != users.id
+
+    @pytest.mark.asyncio
+    async def test_default_group_not_treated_as_manual(self, db_session: AsyncSession):
+        """The default_group is LDAP-managed even though it's not in the mapping
+        values — it gets added when no mapped groups resolve. So if LDAP later
+        revokes all group memberships, the default group stays; if a different
+        default_group is configured, the old one is dropped from the user."""
+        guest = await _make_group(db_session, "Guests")
+        await _make_group(db_session, "Users")
+
+        # User has the (LDAP-managed) Guests group as their default — no manual groups.
+        user = await _make_ldap_user(db_session, "bob", [guest])
+
+        ldap_user = _FakeLdapUser(username="bob", email="bob@example.com", groups=[])
+        ldap_config = _FakeLdapConfig(group_mapping={}, default_group="Guests")
+
+        await _sync_ldap_user(db_session, user, ldap_user, ldap_config)
+        await db_session.refresh(user, attribute_names=["groups"])
+        assert {g.name for g in user.groups} == {"Guests"}, "Default group should persist"
+
+    @pytest.mark.asyncio
+    async def test_revocation_in_ldap_still_propagates(self, db_session: AsyncSession):
+        """The original design intent — revocation in LDAP must flow through — must
+        still work for LDAP-managed groups. User was in 'Users' (LDAP-mapped); LDAP
+        no longer reports the mapped group; sync must remove 'Users'."""
+        users = await _make_group(db_session, "Users")
+
+        user = await _make_ldap_user(db_session, "charlie", [users])
+        assert {g.name for g in user.groups} == {"Users"}
+
+        ldap_user = _FakeLdapUser(username="charlie", email="charlie@example.com", groups=[])
+        ldap_config = _FakeLdapConfig(
+            group_mapping={"cn=staff,ou=groups,dc=example,dc=com": "Users"},
+            default_group="",
+        )
+
+        await _sync_ldap_user(db_session, user, ldap_user, ldap_config)
+        await db_session.refresh(user, attribute_names=["groups"])
+        assert {g.name for g in user.groups} == set(), (
+            "LDAP-managed groups must be removed when LDAP no longer reports the user in them"
+        )
+
+    @pytest.mark.asyncio
+    async def test_manual_assignment_to_managed_group_still_overridden(self, db_session: AsyncSession):
+        """If an admin manually assigns a group that IS in the LDAP mapping, LDAP
+        truth still wins — otherwise revoking access in LDAP wouldn't work for
+        users who happened to have manual assignments to the same group. Cannot
+        distinguish manual-but-mapped from LDAP-derived once the assignment is
+        in the DB; resolved by treating any group in the LDAP-managed set as
+        authoritative-by-LDAP."""
+        users = await _make_group(db_session, "Users")
+
+        # Manually assign 'Users' (which IS in the LDAP mapping) to an LDAP user.
+        user = await _make_ldap_user(db_session, "dave", [users])
+
+        # LDAP says the user is in no mapped groups.
+        ldap_user = _FakeLdapUser(username="dave", email="dave@example.com", groups=[])
+        ldap_config = _FakeLdapConfig(
+            group_mapping={"cn=staff,ou=groups,dc=example,dc=com": "Users"},
+            default_group="",
+        )
+
+        await _sync_ldap_user(db_session, user, ldap_user, ldap_config)
+        await db_session.refresh(user, attribute_names=["groups"])
+        assert {g.name for g in user.groups} == set(), (
+            "Manual assignment to an LDAP-managed group is overridden by LDAP state"
+        )
+
+    @pytest.mark.asyncio
+    async def test_mixed_manual_and_ldap_groups(self, db_session: AsyncSession):
+        """Most realistic scenario: user has multiple manual assignments AND LDAP
+        mapped groups. Manual groups survive; LDAP-managed slice gets rebuilt."""
+        admins = await _make_group(db_session, "Administrators")
+        ops = await _make_group(db_session, "PrintOps")
+        users = await _make_group(db_session, "Users")
+        await _make_group(db_session, "Power Users")
+
+        # User has two manual groups (Administrators, PrintOps) plus one LDAP
+        # group (Users) at the start.
+        user = await _make_ldap_user(db_session, "eve", [admins, ops, users])
+
+        # LDAP login: user is now in two LDAP-mapped groups.
+        ldap_user = _FakeLdapUser(
+            username="eve",
+            email="eve@example.com",
+            groups=["cn=staff,ou=groups,dc=example,dc=com", "cn=power,ou=groups,dc=example,dc=com"],
+        )
+        ldap_config = _FakeLdapConfig(
+            group_mapping={
+                "cn=staff,ou=groups,dc=example,dc=com": "Users",
+                "cn=power,ou=groups,dc=example,dc=com": "Power Users",
+            },
+            default_group="",
+        )
+
+        await _sync_ldap_user(db_session, user, ldap_user, ldap_config)
+        await db_session.refresh(user, attribute_names=["groups"])
+        assert {g.name for g in user.groups} == {
+            "Administrators",  # manual, preserved
+            "PrintOps",  # manual, preserved
+            "Users",  # LDAP-managed, retained from LDAP
+            "Power Users",  # LDAP-managed, newly added from LDAP
+        }

+ 369 - 0
backend/tests/integration/test_ldap_provision.py

@@ -0,0 +1,369 @@
+"""Integration tests for the manual LDAP user provisioning routes (#1298).
+
+Reporter @Fuechslein noted that BamBuddy forced admins to leave auto-provision
+on because there was no UI path to create an LDAP user by hand. The new
+endpoints are GET /auth/ldap/search (admin types a partial name, picks a
+candidate) and POST /auth/ldap/provision (server re-resolves and creates the
+user).
+
+These tests cover:
+
+- Permission gating (only USERS_CREATE can search/provision)
+- LDAP-disabled and short-query rejections
+- Service-unreachable surfaces as 503, not 200 empty
+- Provision creates the user with auth_source=ldap, password_hash=None
+- Provision applies the same group mapping as the auto-provision login path
+- Duplicate-username protection (409 with explanation)
+"""
+
+from unittest.mock import patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.settings import Settings
+from backend.app.models.user import User
+from backend.app.services.ldap_service import LDAPSearchResult, LDAPUserInfo
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+async def _seed_ldap_settings(db: AsyncSession, **overrides) -> None:
+    """Write a minimal but valid LDAP config to the settings table."""
+    defaults = {
+        "ldap_enabled": "true",
+        "ldap_server_url": "ldaps://ldap.test.example:636",  # pragma: allowlist secret — test fixture
+        "ldap_bind_dn": "cn=admin,dc=test,dc=com",  # pragma: allowlist secret — test fixture
+        "ldap_bind_password": "x",  # pragma: allowlist secret — test fixture
+        "ldap_search_base": "dc=test,dc=com",
+        "ldap_user_filter": "(uid={username})",
+        "ldap_security": "ldaps",
+        "ldap_group_mapping": "{}",
+        "ldap_auto_provision": "false",
+        "ldap_ca_cert_path": "",
+        "ldap_default_group": "",
+    }
+    defaults.update(overrides)
+    for key, value in defaults.items():
+        db.add(Settings(key=key, value=value))
+    await db.commit()
+
+
+@pytest.fixture
+async def admin_token(async_client: AsyncClient) -> str:
+    """Enable auth, create an admin, return a valid bearer token."""
+    # pragma: allowlist secret — test fixture only, not a real credential
+    test_password = "AdminPass1!"  # noqa: S105
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={
+            "auth_enabled": True,
+            "admin_username": "ldapadmin",
+            "admin_password": test_password,
+        },
+    )
+    login = await async_client.post(
+        "/api/v1/auth/login",
+        json={"username": "ldapadmin", "password": test_password},
+    )
+    return login.json()["access_token"]
+
+
+# ---------------------------------------------------------------------------
+# /auth/ldap/search
+# ---------------------------------------------------------------------------
+
+
+class TestLdapSearchRoute:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_requires_auth(self, async_client: AsyncClient, db_session: AsyncSession):
+        """Anonymous access is rejected when auth is enabled."""
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "x",
+                "admin_password": "AdminPass1!",
+            },  # pragma: allowlist secret — test fixture
+        )
+
+        response = await async_client.get("/api/v1/auth/ldap/search?q=jdoe")
+
+        assert response.status_code == 401
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_short_query(self, async_client: AsyncClient, admin_token: str, db_session: AsyncSession):
+        """Single-char queries would be effectively unbounded against a large directory."""
+        await _seed_ldap_settings(db_session)
+
+        response = await async_client.get(
+            "/api/v1/auth/ldap/search?q=j",
+            headers={"Authorization": f"Bearer {admin_token}"},
+        )
+
+        assert response.status_code == 400
+        assert "at least 2 characters" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_when_ldap_disabled(
+        self, async_client: AsyncClient, admin_token: str, db_session: AsyncSession
+    ):
+        """No LDAP config in settings → 400 with a clear message."""
+        response = await async_client.get(
+            "/api/v1/auth/ldap/search?q=jdoe",
+            headers={"Authorization": f"Bearer {admin_token}"},
+        )
+
+        assert response.status_code == 400
+        assert "LDAP is not enabled" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_surfaces_unreachable_as_503(
+        self, async_client: AsyncClient, admin_token: str, db_session: AsyncSession
+    ):
+        """When the underlying search fails (network/auth), the admin gets 503 — not
+        a silent empty list (which would look like 'no matches')."""
+        await _seed_ldap_settings(db_session)
+
+        with patch(
+            "backend.app.services.ldap_service.search_ldap_users",
+            side_effect=RuntimeError("simulated outage"),
+        ):
+            response = await async_client.get(
+                "/api/v1/auth/ldap/search?q=jdoe",
+                headers={"Authorization": f"Bearer {admin_token}"},
+            )
+
+        assert response.status_code == 503
+        # Detail now includes the underlying exception class + message so the
+        # admin can see why (e.g. "LDAP search failed: RuntimeError: simulated outage").
+        detail = response.json()["detail"].lower()
+        assert "ldap search failed" in detail
+        assert "simulated outage" in detail
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_returns_results_annotated_with_already_provisioned(
+        self, async_client: AsyncClient, admin_token: str, db_session: AsyncSession
+    ):
+        """Results that match an existing local row must come back with the flag set."""
+        await _seed_ldap_settings(db_session)
+
+        # Seed an existing local user that shares a username with one LDAP result.
+        db_session.add(User(username="existing", email="x@test.com", password_hash="$x$", role="user"))
+        await db_session.commit()
+
+        fake_results = [
+            LDAPSearchResult(
+                username="jdoe",
+                email="jdoe@test.com",
+                display_name="John Doe",
+                dn="cn=John Doe,dc=test,dc=com",
+            ),
+            LDAPSearchResult(
+                username="existing",
+                email="existing@test.com",
+                display_name="Already Provisioned",
+                dn="cn=existing,dc=test,dc=com",
+            ),
+        ]
+
+        with patch(
+            "backend.app.services.ldap_service.search_ldap_users",
+            return_value=fake_results,
+        ):
+            response = await async_client.get(
+                "/api/v1/auth/ldap/search?q=jdoe",
+                headers={"Authorization": f"Bearer {admin_token}"},
+            )
+
+        assert response.status_code == 200
+        body = response.json()
+        assert len(body) == 2
+        by_user = {r["username"]: r for r in body}
+        assert by_user["jdoe"]["already_provisioned"] is False
+        assert by_user["existing"]["already_provisioned"] is True
+
+
+# ---------------------------------------------------------------------------
+# /auth/ldap/provision
+# ---------------------------------------------------------------------------
+
+
+class TestLdapProvisionRoute:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_requires_auth(self, async_client: AsyncClient):
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "x",
+                "admin_password": "AdminPass1!",
+            },  # pragma: allowlist secret — test fixture
+        )
+
+        response = await async_client.post(
+            "/api/v1/auth/ldap/provision",
+            json={"username": "jdoe"},
+        )
+
+        assert response.status_code == 401
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_404_when_directory_lookup_misses(
+        self, async_client: AsyncClient, admin_token: str, db_session: AsyncSession
+    ):
+        await _seed_ldap_settings(db_session)
+
+        with patch("backend.app.services.ldap_service.lookup_ldap_user", return_value=None):
+            response = await async_client.post(
+                "/api/v1/auth/ldap/provision",
+                json={"username": "nobody"},
+                headers={"Authorization": f"Bearer {admin_token}"},
+            )
+
+        assert response.status_code == 404
+        assert "not found in LDAP directory" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_409_when_local_user_exists(
+        self, async_client: AsyncClient, admin_token: str, db_session: AsyncSession
+    ):
+        """A local user with the same username must block provision — the admin has
+        to resolve the collision manually rather than silently coexisting."""
+        await _seed_ldap_settings(db_session)
+
+        db_session.add(User(username="jdoe", password_hash="$x$", role="user", auth_source="local"))
+        await db_session.commit()
+
+        fake_ldap = LDAPUserInfo(username="jdoe", email="jdoe@test.com", display_name=None, groups=[])
+        with patch("backend.app.services.ldap_service.lookup_ldap_user", return_value=fake_ldap):
+            response = await async_client.post(
+                "/api/v1/auth/ldap/provision",
+                json={"username": "jdoe"},
+                headers={"Authorization": f"Bearer {admin_token}"},
+            )
+
+        assert response.status_code == 409
+        assert "local user" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_409_when_already_provisioned(
+        self, async_client: AsyncClient, admin_token: str, db_session: AsyncSession
+    ):
+        """Re-provisioning an existing LDAP user must give a distinct error so the
+        UI can suggest 'they exist already, just have them log in' rather than
+        the more alarming 'local conflict' message."""
+        await _seed_ldap_settings(db_session)
+
+        db_session.add(User(username="alice", password_hash=None, role="user", auth_source="ldap"))
+        await db_session.commit()
+
+        fake_ldap = LDAPUserInfo(username="alice", email="alice@test.com", display_name=None, groups=[])
+        with patch("backend.app.services.ldap_service.lookup_ldap_user", return_value=fake_ldap):
+            response = await async_client.post(
+                "/api/v1/auth/ldap/provision",
+                json={"username": "alice"},
+                headers={"Authorization": f"Bearer {admin_token}"},
+            )
+
+        assert response.status_code == 409
+        assert "already provisioned" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_503_when_directory_unreachable(
+        self, async_client: AsyncClient, admin_token: str, db_session: AsyncSession
+    ):
+        await _seed_ldap_settings(db_session)
+
+        with patch(
+            "backend.app.services.ldap_service.lookup_ldap_user",
+            side_effect=RuntimeError("simulated outage"),
+        ):
+            response = await async_client.post(
+                "/api/v1/auth/ldap/provision",
+                json={"username": "jdoe"},
+                headers={"Authorization": f"Bearer {admin_token}"},
+            )
+
+        assert response.status_code == 503
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_happy_path_creates_user_with_ldap_auth_source(
+        self, async_client: AsyncClient, admin_token: str, db_session: AsyncSession
+    ):
+        """Verifies the full provision: response shape + DB state."""
+        await _seed_ldap_settings(db_session)
+
+        fake_ldap = LDAPUserInfo(
+            username="newuser",
+            email="newuser@test.com",
+            display_name="New User",
+            groups=[],
+        )
+
+        with patch("backend.app.services.ldap_service.lookup_ldap_user", return_value=fake_ldap):
+            response = await async_client.post(
+                "/api/v1/auth/ldap/provision",
+                json={"username": "newuser"},
+                headers={"Authorization": f"Bearer {admin_token}"},
+            )
+
+        assert response.status_code == 201
+        body = response.json()
+        assert body["username"] == "newuser"
+        assert body["email"] == "newuser@test.com"
+        assert body["auth_source"] == "ldap"
+
+        # Verify DB state: password_hash MUST be None (LDAP has no local credential)
+        from sqlalchemy import select
+
+        row = (await db_session.execute(select(User).where(User.username == "newuser"))).scalar_one()
+        assert row.auth_source == "ldap"
+        assert row.password_hash is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_happy_path_applies_group_mapping(
+        self, async_client: AsyncClient, admin_token: str, db_session: AsyncSession
+    ):
+        """Provision must run the same group-mapping logic as the auto-provision
+        login path — so an admin who provisions Alice gets the exact same group
+        memberships as if Alice had logged in herself with auto-provision on."""
+        await _seed_ldap_settings(
+            db_session,
+            ldap_group_mapping='{"cn=staff,ou=groups,dc=test,dc=com": "Operators"}',
+        )
+
+        # Operators group is auto-seeded by the test harness — no need to create it.
+        fake_ldap = LDAPUserInfo(
+            username="alice",
+            email="alice@test.com",
+            display_name="Alice",
+            groups=["cn=staff,ou=groups,dc=test,dc=com"],
+        )
+
+        with patch("backend.app.services.ldap_service.lookup_ldap_user", return_value=fake_ldap):
+            response = await async_client.post(
+                "/api/v1/auth/ldap/provision",
+                json={"username": "alice"},
+                headers={"Authorization": f"Bearer {admin_token}"},
+            )
+
+        assert response.status_code == 201
+        body = response.json()
+        group_names = {g["name"] for g in body["groups"]}
+        assert "Operators" in group_names

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

@@ -234,6 +234,86 @@ class TestSliceLibraryFile:
         assert final["result"]["print_time_seconds"] == 656
         assert captured["url"].endswith("/slice")
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bed_type_override_patches_process_profile(self, async_client: AsyncClient, slice_test_setup):
+        """#1337: when SliceRequest.bed_type is set, the process JSON sent to
+        the sidecar must carry curr_bed_type with that exact value. Without
+        the patch, slicing high-temp filaments on a "Cool Plate" process
+        preset fails inside the slicer CLI with "does not support filament 1"
+        and the user has no way to switch plates from the SliceModal."""
+        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={
+                "printer_preset_id": slice_test_setup["printer_id"],
+                "process_preset_id": slice_test_setup["process_id"],
+                "filament_preset_id": slice_test_setup["filament_id"],
+                "bed_type": "Textured PEI Plate",
+            },
+        )
+        assert response.status_code == 202
+        final = await _wait_for_job(async_client, response.json()["job_id"])
+        assert final["status"] == "completed", final
+
+        # The presetProfile part of the multipart upload now carries the
+        # override. Searching the raw body avoids parsing the multipart by
+        # hand — the substring is unique enough since we control the JSON
+        # being patched.
+        assert b'"curr_bed_type": "Textured PEI Plate"' in captured["body"], (
+            "bed_type override must appear in the process JSON sent to the sidecar"
+        )
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bed_type_omitted_leaves_process_profile_untouched(self, async_client: AsyncClient, slice_test_setup):
+        """Companion to the override test: the patch must NOT fire when the
+        client omits bed_type, so the process preset's own curr_bed_type
+        (or absence thereof) is forwarded to the sidecar unchanged."""
+        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={
+                "printer_preset_id": slice_test_setup["printer_id"],
+                "process_preset_id": slice_test_setup["process_id"],
+                "filament_preset_id": slice_test_setup["filament_id"],
+            },
+        )
+        assert response.status_code == 202
+        final = await _wait_for_job(async_client, response.json()["job_id"])
+        assert final["status"] == "completed", final
+        assert b"curr_bed_type" not in captured["body"], (
+            "bed_type must stay out of the process JSON when no override is set"
+        )
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_invalid_preset_id_surfaces_as_failed_job_with_status_400(
@@ -513,6 +593,84 @@ class TestSliceWithBundle:
         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(

+ 49 - 0
backend/tests/integration/test_mfa_api.py

@@ -4843,3 +4843,52 @@ class TestOIDCAutoCreateDefaultGroup:
             jwks_data=jwks_data,
         )
         assert "Administrators" in group_names, f"Expected Administrators, got {group_names}"
+
+
+class TestListOidcLinksDefensiveProviderNull:
+    """list_oidc_links must not crash if a link's provider is orphaned on SQLite.
+
+    PR for #1285 added a defensive null-check at mfa.py:1871 so the endpoint
+    returns ``provider_name="<deleted>"`` for orphan links instead of raising
+    AttributeError when accessing ``link.provider.name``. This scenario is
+    only reachable on SQLite (PRAGMA foreign_keys=OFF) when an OIDCProvider
+    row is removed without the ORM cascade running.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_oidc_links_returns_deleted_marker_for_orphan_provider(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        from sqlalchemy import select
+
+        from backend.app.models.oidc_provider import UserOIDCLink
+
+        admin_token = await _setup_and_login(async_client, "linkorphan_adm", "LinkOrphanAdm1!")
+
+        admin_row = await db_session.execute(select(User).where(User.username == "linkorphan_adm"))
+        admin_user = admin_row.scalar_one()
+
+        # Orphan link: provider_id=99999 deliberately points at no row.
+        db_session.add(
+            UserOIDCLink(
+                user_id=admin_user.id,
+                provider_id=99999,
+                provider_user_id="orphan-sub",
+                provider_email="orphan@example.com",
+            )
+        )
+        await db_session.commit()
+
+        resp = await async_client.get(
+            "/api/v1/auth/oidc/links",
+            headers=_auth_header(admin_token),
+        )
+        assert resp.status_code == 200, resp.text
+        links = resp.json()
+        assert len(links) == 1
+        # The fix: orphan provider_id no longer crashes — returns "<deleted>" instead.
+        assert links[0]["provider_name"] == "<deleted>", (
+            f"Expected '<deleted>' fallback for orphan provider, got {links[0]['provider_name']!r}"
+        )
+        assert links[0]["provider_id"] == 99999

+ 736 - 0
backend/tests/integration/test_oidc_icon_api.py

@@ -0,0 +1,736 @@
+"""Integration tests for the OIDC icon proxy endpoints (#1333).
+
+Covers create/update/delete/refresh round-trips, the public GET /icon
+endpoint with ETag/304 behaviour, and the strict "disabled provider → 404"
+rule that protects against existence-leak on disabled providers.
+
+httpx mocking follows the project convention:
+``patch("backend.app.services.oidc_icon.httpx.AsyncClient", ...)``.
+"""
+
+from unittest.mock import patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import undefer
+
+from backend.tests._fixtures.oidc_icon import (
+    PNG_BYTES as _PNG_BYTES,
+    PNG_ETAG as _PNG_ETAG,
+    build_streaming_icon_mock,
+)
+
+
+def _build_icon_mock(*, body: bytes = _PNG_BYTES, content_type: str = "image/png", status_code: int = 200):
+    """Adapter to the shared streaming-mock fixture.
+
+    Kept as a thin wrapper so individual test bodies (lots of them) stay
+    readable. Returns ``(MockHttpxClient, stream_recorder)`` — same shape
+    as the pre-streaming ``(MockHttpxClient, mock_get)`` so the per-test
+    ``mock_get.assert_called_once()`` patterns continue to mean
+    ``the fetcher hit the upstream exactly once``.
+    """
+    return build_streaming_icon_mock(body=body, content_type=content_type, status_code=status_code)
+
+
+@pytest.fixture
+async def admin_token(async_client: AsyncClient):
+    """Setup auth + return an admin token."""
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={
+            "auth_enabled": True,
+            "admin_username": "iconadmin",
+            "admin_password": "AdminPass1!",
+        },
+    )
+    login = await async_client.post(
+        "/api/v1/auth/login",
+        json={"username": "iconadmin", "password": "AdminPass1!"},
+    )
+    return login.json()["access_token"]
+
+
+def _auth_h(token: str) -> dict:
+    return {"Authorization": f"Bearer {token}"}
+
+
+def _create_payload(**overrides) -> dict:
+    """Minimal valid OIDC provider create payload; overrides shadow fields."""
+    base = {
+        "name": "Test",
+        "issuer_url": "https://idp.example.com",
+        "client_id": "client",
+        "client_secret": "secret",
+    }
+    base.update(overrides)
+    return base
+
+
+# ───────────────────────────────────────────────────────────────────────────
+# CREATE
+# ───────────────────────────────────────────────────────────────────────────
+
+
+class TestCreateProviderWithIcon:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_with_valid_icon_url_fetches_and_caches(
+        self, async_client: AsyncClient, db_session: AsyncSession, admin_token: str
+    ):
+        from backend.app.models.oidc_provider import OIDCProvider
+
+        mock_cls, mock_get = _build_icon_mock()
+        with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
+            resp = await async_client.post(
+                "/api/v1/auth/oidc/providers",
+                headers=_auth_h(admin_token),
+                json=_create_payload(name="GoogleProv", icon_url="https://google.com/icon.png"),
+            )
+        assert resp.status_code == 201, resp.text
+        body = resp.json()
+        assert body["has_icon"] is True
+        # DB row has all three icon columns populated — undefer() is required
+        # because icon_data is deferred (deferred BLOBs raise MissingGreenlet
+        # on direct attribute access inside an async session).
+        result = await db_session.execute(
+            select(OIDCProvider).options(undefer(OIDCProvider.icon_data)).where(OIDCProvider.name == "GoogleProv")
+        )
+        provider = result.scalar_one()
+        assert provider.icon_content_type == "image/png"
+        assert provider.icon_etag == _PNG_ETAG
+        assert provider.icon_data == _PNG_BYTES
+        mock_get.assert_called_once()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_with_unreachable_icon_url_returns_400_no_row(
+        self, async_client: AsyncClient, db_session: AsyncSession, admin_token: str
+    ):
+        """Atomicity: failed icon-fetch leaves no row in the DB."""
+        from backend.app.models.oidc_provider import OIDCProvider
+
+        mock_cls, _ = _build_icon_mock(status_code=404)
+        with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
+            resp = await async_client.post(
+                "/api/v1/auth/oidc/providers",
+                headers=_auth_h(admin_token),
+                json=_create_payload(name="BrokenIconProv", icon_url="https://google.com/missing.png"),
+            )
+        assert resp.status_code == 400
+        result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "BrokenIconProv"))
+        assert result.scalar_one_or_none() is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_without_icon_url_has_icon_false(self, async_client: AsyncClient, admin_token: str):
+        resp = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            headers=_auth_h(admin_token),
+            json=_create_payload(name="NoIconProv"),
+        )
+        assert resp.status_code == 201
+        assert resp.json()["has_icon"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_fetch_failure_logs_warning(self, async_client: AsyncClient, admin_token: str, caplog):
+        """I2: every fetch failure writes a WARNING log so operators have
+        a forensic trail beyond the admin's transient toast."""
+        import logging
+
+        mock_cls, _ = _build_icon_mock(status_code=500)
+        # The Pydantic + SSRF validators must pass for the fetcher branch
+        # to be reached; we use a public, safe URL and let the upstream
+        # mock fail with a 500.
+        with (
+            caplog.at_level(logging.WARNING, logger="backend.app.api.routes.mfa"),
+            patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls),
+        ):
+            resp = await async_client.post(
+                "/api/v1/auth/oidc/providers",
+                headers=_auth_h(admin_token),
+                json=_create_payload(name="LogProv", icon_url="https://google.com/icon.png"),
+            )
+        assert resp.status_code == 400
+        warnings = [r for r in caplog.records if "fetch failed" in r.getMessage()]
+        assert warnings, "expected a WARNING log for the failed icon fetch"
+        assert "https://google.com/icon.png" in warnings[0].getMessage()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ssrf_rejection_logs_warning(
+        self, async_client: AsyncClient, db_session: AsyncSession, admin_token: str, caplog
+    ):
+        """I2: SSRF rejection path also logs WARNING (separate branch from
+        fetch failure — same forensic-trail requirement).
+
+        The Pydantic validator now (after I1) catches private IPs at
+        422-time, so the route-level _fetch_icon_or_400 SSRF branch is
+        only reachable via /refresh on a row whose icon_url was inserted
+        directly (bypassing Pydantic). Use the test DB session to seed
+        that row, then trigger /refresh.
+        """
+        import logging
+
+        from backend.app.models.oidc_provider import OIDCProvider
+
+        prov = OIDCProvider(
+            name="SsrfLogProv",
+            issuer_url="https://idp.example.com",
+            client_id="c",
+            scopes="openid",
+            is_enabled=True,
+            icon_url="https://192.168.1.1/icon.png",  # private — must be rejected
+        )
+        prov.client_secret = "secret"
+        db_session.add(prov)
+        await db_session.commit()
+        pid = prov.id
+
+        with caplog.at_level(logging.WARNING, logger="backend.app.api.routes.mfa"):
+            resp = await async_client.post(
+                f"/api/v1/auth/oidc/providers/{pid}/icon/refresh",
+                headers=_auth_h(admin_token),
+            )
+        assert resp.status_code == 400
+        warnings = [r for r in caplog.records if "SSRF guard" in r.getMessage()]
+        assert warnings, "expected a WARNING log for the SSRF rejection"
+        assert "192.168.1.1" in warnings[0].getMessage()
+
+
+# ───────────────────────────────────────────────────────────────────────────
+# UPDATE
+# ───────────────────────────────────────────────────────────────────────────
+
+
+class TestUpdateProviderIcon:
+    async def _create_with_icon(self, async_client, admin_token, name="UpdProv") -> int:
+        mock_cls, _ = _build_icon_mock()
+        with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
+            resp = await async_client.post(
+                "/api/v1/auth/oidc/providers",
+                headers=_auth_h(admin_token),
+                json=_create_payload(name=name, icon_url="https://example.com/a.png"),
+            )
+        assert resp.status_code == 201, resp.text
+        return resp.json()["id"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_put_without_icon_url_field_does_not_refetch(self, async_client: AsyncClient, admin_token: str):
+        pid = await self._create_with_icon(async_client, admin_token)
+        mock_cls, mock_get = _build_icon_mock()
+        with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
+            resp = await async_client.put(
+                f"/api/v1/auth/oidc/providers/{pid}",
+                headers=_auth_h(admin_token),
+                json={"name": "Renamed"},
+            )
+        assert resp.status_code == 200
+        mock_get.assert_not_called()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_put_with_unchanged_icon_url_and_data_present_skips_fetch(
+        self, async_client: AsyncClient, admin_token: str
+    ):
+        pid = await self._create_with_icon(async_client, admin_token)
+        mock_cls, mock_get = _build_icon_mock()
+        with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
+            resp = await async_client.put(
+                f"/api/v1/auth/oidc/providers/{pid}",
+                headers=_auth_h(admin_token),
+                json={"icon_url": "https://example.com/a.png"},
+            )
+        assert resp.status_code == 200
+        mock_get.assert_not_called()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_put_with_unchanged_url_but_missing_cached_bytes_refetches(
+        self, async_client: AsyncClient, db_session: AsyncSession, admin_token: str
+    ):
+        """Upgrade-path edge case: existing row with icon_url but no cached bytes
+        (e.g. row predates the proxy migration). Saving must trigger a fetch."""
+        from backend.app.models.oidc_provider import OIDCProvider
+
+        pid = await self._create_with_icon(async_client, admin_token, name="UpgrTest")
+        # Simulate the upgrade scenario: clear the cached bytes directly in DB.
+        result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == pid))
+        prov = result.scalar_one()
+        prov.icon_data = None
+        prov.icon_content_type = None
+        prov.icon_etag = None
+        await db_session.commit()
+
+        mock_cls, mock_get = _build_icon_mock()
+        with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
+            resp = await async_client.put(
+                f"/api/v1/auth/oidc/providers/{pid}",
+                headers=_auth_h(admin_token),
+                json={"icon_url": "https://example.com/a.png"},  # unchanged URL
+            )
+        assert resp.status_code == 200
+        mock_get.assert_called_once()
+        assert resp.json()["has_icon"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_put_with_changed_icon_url_refetches(self, async_client: AsyncClient, admin_token: str):
+        pid = await self._create_with_icon(async_client, admin_token, name="ChangedUrlProv")
+        mock_cls, mock_get = _build_icon_mock()
+        with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
+            resp = await async_client.put(
+                f"/api/v1/auth/oidc/providers/{pid}",
+                headers=_auth_h(admin_token),
+                json={"icon_url": "https://example.com/b.png"},
+            )
+        assert resp.status_code == 200
+        mock_get.assert_called_once()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_put_with_icon_url_null_clears_icon(
+        self, async_client: AsyncClient, db_session: AsyncSession, admin_token: str
+    ):
+        """Explicit ``icon_url: null`` in the PUT body clears icon_url AND
+        all three cached-bytes columns. Distinct from "field absent" which
+        leaves the icon untouched.
+        """
+        from sqlalchemy.orm import undefer
+
+        from backend.app.models.oidc_provider import OIDCProvider
+
+        pid = await self._create_with_icon(async_client, admin_token, name="ClearViaPutProv")
+
+        # Sanity: icon is present before the clear.
+        pre_resp = await async_client.get(f"/api/v1/auth/oidc/providers/{pid}/icon")
+        assert pre_resp.status_code == 200
+
+        # PUT with explicit null clears icon_url + cached bytes.
+        resp = await async_client.put(
+            f"/api/v1/auth/oidc/providers/{pid}",
+            headers=_auth_h(admin_token),
+            json={"icon_url": None},
+        )
+        assert resp.status_code == 200, resp.text
+        body = resp.json()
+        assert body["icon_url"] is None
+        assert body["has_icon"] is False
+
+        # DB state: all four icon columns NULL.
+        db_session.expire_all()
+        result = await db_session.execute(
+            select(OIDCProvider).options(undefer(OIDCProvider.icon_data)).where(OIDCProvider.id == pid)
+        )
+        prov = result.scalar_one()
+        assert prov.icon_url is None
+        assert prov.icon_data is None
+        assert prov.icon_content_type is None
+        assert prov.icon_etag is None
+
+        # GET /icon now 404s.
+        post_resp = await async_client.get(f"/api/v1/auth/oidc/providers/{pid}/icon")
+        assert post_resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_put_with_broken_new_icon_url_preserves_old_bytes(
+        self, async_client: AsyncClient, db_session: AsyncSession, admin_token: str
+    ):
+        """Atomicity: failed icon-fetch on PUT must not clear old cached bytes."""
+        from backend.app.models.oidc_provider import OIDCProvider
+
+        pid = await self._create_with_icon(async_client, admin_token, name="AtomicProv")
+        # Failed fetch (404).
+        mock_cls, _ = _build_icon_mock(status_code=404)
+        with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
+            resp = await async_client.put(
+                f"/api/v1/auth/oidc/providers/{pid}",
+                headers=_auth_h(admin_token),
+                json={"icon_url": "https://example.com/broken.png"},
+            )
+        assert resp.status_code == 400
+        # Re-read state: row still has the original icon bytes.
+        db_session.expire_all()
+        result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == pid))
+        prov = result.scalar_one()
+        assert prov.icon_content_type == "image/png"
+        assert prov.icon_etag == _PNG_ETAG
+        # icon_url also unchanged (rollback works) — admin sees no partial state.
+        assert prov.icon_url == "https://example.com/a.png"
+
+
+# ───────────────────────────────────────────────────────────────────────────
+# DELETE /icon
+# ───────────────────────────────────────────────────────────────────────────
+
+
+class TestDeleteIcon:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_icon_clears_columns(
+        self, async_client: AsyncClient, db_session: AsyncSession, admin_token: str
+    ):
+        from backend.app.models.oidc_provider import OIDCProvider
+
+        mock_cls, _ = _build_icon_mock()
+        with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
+            create_resp = await async_client.post(
+                "/api/v1/auth/oidc/providers",
+                headers=_auth_h(admin_token),
+                json=_create_payload(name="DelIconProv", icon_url="https://example.com/icon.png"),
+            )
+        pid = create_resp.json()["id"]
+
+        resp = await async_client.delete(
+            f"/api/v1/auth/oidc/providers/{pid}/icon",
+            headers=_auth_h(admin_token),
+        )
+        assert resp.status_code == 204
+
+        db_session.expire_all()
+        # undefer icon_data so we can assert it's None without triggering
+        # an async lazy-load (which would raise MissingGreenlet).
+        result = await db_session.execute(
+            select(OIDCProvider).options(undefer(OIDCProvider.icon_data)).where(OIDCProvider.id == pid)
+        )
+        prov = result.scalar_one()
+        assert prov.icon_data is None
+        assert prov.icon_content_type is None
+        assert prov.icon_etag is None
+        # DELETE /icon clears the URL too — "Remove icon" means the whole
+        # record is gone, not just the cache. Without this the admin form
+        # would still show a stale URL while the login page rendered the
+        # Shield fallback (confusing half-state).
+        assert prov.icon_url is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_icon_without_auth_rejected(self, async_client: AsyncClient, admin_token: str):
+        # Create with admin auth, then try to DELETE icon anonymously.
+        mock_cls, _ = _build_icon_mock()
+        with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
+            create_resp = await async_client.post(
+                "/api/v1/auth/oidc/providers",
+                headers=_auth_h(admin_token),
+                json=_create_payload(name="AuthGuardProv", icon_url="https://example.com/i.png"),
+            )
+        pid = create_resp.json()["id"]
+        resp = await async_client.delete(f"/api/v1/auth/oidc/providers/{pid}/icon")
+        assert resp.status_code in (401, 403)
+
+
+# ───────────────────────────────────────────────────────────────────────────
+# REFRESH /icon
+# ───────────────────────────────────────────────────────────────────────────
+
+
+class TestRefreshIcon:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_refresh_fetches_from_stored_url(
+        self, async_client: AsyncClient, db_session: AsyncSession, admin_token: str
+    ):
+        from backend.app.models.oidc_provider import OIDCProvider
+
+        mock_cls, _ = _build_icon_mock()
+        with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
+            create_resp = await async_client.post(
+                "/api/v1/auth/oidc/providers",
+                headers=_auth_h(admin_token),
+                json=_create_payload(name="RefProv", icon_url="https://example.com/i.png"),
+            )
+        pid = create_resp.json()["id"]
+
+        # Now clear in DB (simulate icon corruption / IdP change)
+        result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == pid))
+        prov = result.scalar_one()
+        prov.icon_data = None
+        prov.icon_content_type = None
+        prov.icon_etag = None
+        await db_session.commit()
+
+        new_png = _PNG_BYTES + b"\x00\x01"
+        mock_cls2, mock_get2 = _build_icon_mock(body=new_png)
+        with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls2):
+            resp = await async_client.post(
+                f"/api/v1/auth/oidc/providers/{pid}/icon/refresh",
+                headers=_auth_h(admin_token),
+            )
+        assert resp.status_code == 200, resp.text
+        assert resp.json()["has_icon"] is True
+        mock_get2.assert_called_once()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_refresh_without_icon_url_returns_400(self, async_client: AsyncClient, admin_token: str):
+        # Create provider without an icon_url
+        create_resp = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            headers=_auth_h(admin_token),
+            json=_create_payload(name="NoUrlRef"),
+        )
+        pid = create_resp.json()["id"]
+        resp = await async_client.post(
+            f"/api/v1/auth/oidc/providers/{pid}/icon/refresh",
+            headers=_auth_h(admin_token),
+        )
+        assert resp.status_code == 400
+        assert "no icon_url" in resp.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_refresh_failure_preserves_old_bytes(
+        self, async_client: AsyncClient, db_session: AsyncSession, admin_token: str
+    ):
+        from backend.app.models.oidc_provider import OIDCProvider
+
+        mock_cls, _ = _build_icon_mock()
+        with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
+            create_resp = await async_client.post(
+                "/api/v1/auth/oidc/providers",
+                headers=_auth_h(admin_token),
+                json=_create_payload(name="RefAtomicProv", icon_url="https://example.com/i.png"),
+            )
+        pid = create_resp.json()["id"]
+
+        mock_cls_fail, _ = _build_icon_mock(status_code=500)
+        with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls_fail):
+            resp = await async_client.post(
+                f"/api/v1/auth/oidc/providers/{pid}/icon/refresh",
+                headers=_auth_h(admin_token),
+            )
+        assert resp.status_code == 400
+
+        db_session.expire_all()
+        result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == pid))
+        prov = result.scalar_one()
+        assert prov.icon_etag == _PNG_ETAG  # original bytes intact
+
+
+# ───────────────────────────────────────────────────────────────────────────
+# GET /icon — the public icon-proxy endpoint
+# ───────────────────────────────────────────────────────────────────────────
+
+
+class TestGetProviderIcon:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_anonymous_get_returns_bytes(self, async_client: AsyncClient, admin_token: str):
+        mock_cls, _ = _build_icon_mock()
+        with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
+            create_resp = await async_client.post(
+                "/api/v1/auth/oidc/providers",
+                headers=_auth_h(admin_token),
+                json=_create_payload(name="PubIconProv", icon_url="https://example.com/i.png"),
+            )
+        pid = create_resp.json()["id"]
+
+        # Anonymous request — no Authorization header at all.
+        resp = await async_client.get(f"/api/v1/auth/oidc/providers/{pid}/icon")
+        assert resp.status_code == 200
+        assert resp.content == _PNG_BYTES
+        assert resp.headers["content-type"] == "image/png"
+        assert resp.headers["etag"] == f'"{_PNG_ETAG}"'
+        assert resp.headers["cache-control"] == "public, max-age=3600"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_without_cached_data_returns_404(self, async_client: AsyncClient, admin_token: str):
+        create_resp = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            headers=_auth_h(admin_token),
+            json=_create_payload(name="EmptyIconProv"),  # no icon_url → no bytes
+        )
+        pid = create_resp.json()["id"]
+        resp = await async_client.get(f"/api/v1/auth/oidc/providers/{pid}/icon")
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_for_disabled_provider_returns_404(
+        self, async_client: AsyncClient, db_session: AsyncSession, admin_token: str
+    ):
+        """Disabled providers must not leak existence via the icon endpoint."""
+        from backend.app.models.oidc_provider import OIDCProvider
+
+        mock_cls, _ = _build_icon_mock()
+        with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
+            create_resp = await async_client.post(
+                "/api/v1/auth/oidc/providers",
+                headers=_auth_h(admin_token),
+                json=_create_payload(name="DisabledProv", icon_url="https://example.com/d.png"),
+            )
+        pid = create_resp.json()["id"]
+        # Disable directly in DB.
+        result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == pid))
+        prov = result.scalar_one()
+        prov.is_enabled = False
+        await db_session.commit()
+
+        resp = await async_client.get(f"/api/v1/auth/oidc/providers/{pid}/icon")
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_if_none_match_exact_returns_304(self, async_client: AsyncClient, admin_token: str):
+        mock_cls, _ = _build_icon_mock()
+        with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
+            create_resp = await async_client.post(
+                "/api/v1/auth/oidc/providers",
+                headers=_auth_h(admin_token),
+                json=_create_payload(name="EtagProv", icon_url="https://example.com/e.png"),
+            )
+        pid = create_resp.json()["id"]
+        resp = await async_client.get(
+            f"/api/v1/auth/oidc/providers/{pid}/icon",
+            headers={"If-None-Match": f'"{_PNG_ETAG}"'},
+        )
+        assert resp.status_code == 304
+        assert resp.content == b""
+        assert resp.headers["etag"] == f'"{_PNG_ETAG}"'
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_if_none_match_mismatch_returns_200(self, async_client: AsyncClient, admin_token: str):
+        mock_cls, _ = _build_icon_mock()
+        with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
+            create_resp = await async_client.post(
+                "/api/v1/auth/oidc/providers",
+                headers=_auth_h(admin_token),
+                json=_create_payload(name="EtagMismatchProv", icon_url="https://example.com/m.png"),
+            )
+        pid = create_resp.json()["id"]
+        resp = await async_client.get(
+            f"/api/v1/auth/oidc/providers/{pid}/icon",
+            headers={"If-None-Match": '"stale-etag-value"'},
+        )
+        assert resp.status_code == 200
+        assert resp.content == _PNG_BYTES
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_if_none_match_weak_prefix_returns_304(self, async_client: AsyncClient, admin_token: str):
+        """N5 — RFC 7232 §2.3 weak validator prefix ``W/"…"`` must match.
+
+        CDN intermediaries and some browsers send weak validators on GET
+        even though we issue strong ones; without the W/ strip a 200 was
+        returned needlessly.
+        """
+        mock_cls, _ = _build_icon_mock()
+        with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
+            create_resp = await async_client.post(
+                "/api/v1/auth/oidc/providers",
+                headers=_auth_h(admin_token),
+                json=_create_payload(name="EtagWeakProv", icon_url="https://example.com/w.png"),
+            )
+        pid = create_resp.json()["id"]
+        resp = await async_client.get(
+            f"/api/v1/auth/oidc/providers/{pid}/icon",
+            headers={"If-None-Match": f'W/"{_PNG_ETAG}"'},
+        )
+        assert resp.status_code == 304
+        assert resp.content == b""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_if_none_match_wildcard_returns_304(self, async_client: AsyncClient, admin_token: str):
+        """N5 — RFC 7232 §3.2 ``*`` wildcard matches any current
+        representation when the resource exists. We always have an icon
+        here (resource existence verified above by the 404 path) so ``*``
+        always means "I have something; tell me if it's stale" → 304."""
+        mock_cls, _ = _build_icon_mock()
+        with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
+            create_resp = await async_client.post(
+                "/api/v1/auth/oidc/providers",
+                headers=_auth_h(admin_token),
+                json=_create_payload(name="EtagWildcardProv", icon_url="https://example.com/wc.png"),
+            )
+        pid = create_resp.json()["id"]
+        resp = await async_client.get(
+            f"/api/v1/auth/oidc/providers/{pid}/icon",
+            headers={"If-None-Match": "*"},
+        )
+        assert resp.status_code == 304
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_if_none_match_multiple_tokens_one_match(self, async_client: AsyncClient, admin_token: str):
+        """N5 — comma-separated list with one matching token returns 304."""
+        mock_cls, _ = _build_icon_mock()
+        with patch("backend.app.services.oidc_icon.httpx.AsyncClient", mock_cls):
+            create_resp = await async_client.post(
+                "/api/v1/auth/oidc/providers",
+                headers=_auth_h(admin_token),
+                json=_create_payload(name="EtagMultiProv", icon_url="https://example.com/m2.png"),
+            )
+        pid = create_resp.json()["id"]
+        resp = await async_client.get(
+            f"/api/v1/auth/oidc/providers/{pid}/icon",
+            headers={"If-None-Match": f'"stale", "{_PNG_ETAG}"'},
+        )
+        assert resp.status_code == 304
+
+
+# ───────────────────────────────────────────────────────────────────────────
+# N12 — Edge cases (404 paths, inconsistent triplet via raw SQL)
+# ───────────────────────────────────────────────────────────────────────────
+
+
+class TestEdgeCases:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_icon_on_nonexistent_provider_returns_404(self, async_client: AsyncClient, admin_token: str):
+        """N12 — DELETE /icon on a missing provider_id must 404, not 500."""
+        resp = await async_client.delete(
+            "/api/v1/auth/oidc/providers/99999/icon",
+            headers=_auth_h(admin_token),
+        )
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_refresh_icon_on_nonexistent_provider_returns_404(self, async_client: AsyncClient, admin_token: str):
+        """N12 — POST /icon/refresh on a missing provider_id must 404, not 500."""
+        resp = await async_client.post(
+            "/api/v1/auth/oidc/providers/99999/icon/refresh",
+            headers=_auth_h(admin_token),
+        )
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_icon_with_inconsistent_triplet_returns_404(
+        self, async_client: AsyncClient, db_session: AsyncSession, admin_token: str
+    ):
+        """N12 — defensive double-check on the GET /icon endpoint.
+
+        The CHECK constraint (#1333 / N10) prevents this state from being
+        reachable via the application, but the defensive
+        ``provider.icon_data is None`` guard at the route layer is what
+        protects against a manual SQL hotfix that bypassed the constraint
+        (e.g. operator-run UPDATE during incident recovery on stale
+        SQLite where the CHECK isn't present). We can't write such a row
+        via SQLAlchemy here (the CHECK fires), so we verify the
+        equivalent path: a provider with NO icon at all returns 404.
+        """
+        from backend.app.models.oidc_provider import OIDCProvider
+
+        prov = OIDCProvider(
+            name="EmptyTripletProv",
+            issuer_url="https://idp.example.com",
+            client_id="c",
+            scopes="openid",
+            is_enabled=True,
+        )
+        prov.client_secret = "secret"
+        db_session.add(prov)
+        await db_session.commit()
+        pid = prov.id
+
+        resp = await async_client.get(f"/api/v1/auth/oidc/providers/{pid}/icon")
+        assert resp.status_code == 404

+ 151 - 0
backend/tests/integration/test_oidc_icon_blob_roundtrip.py

@@ -0,0 +1,151 @@
+"""Type-mapping coverage for the OIDC icon BLOB column (#1333).
+
+Bambuddy's ``create_backup_zip`` rebuilds the SQLite backup schema from
+``Base.metadata`` when the source database is PostgreSQL. The column-type
+mapping previously fell through to ``TEXT`` for any unknown SQLAlchemy
+type — including ``LargeBinary`` / ``BYTEA`` — which corrupts non-UTF8
+icon bytes during the PG → SQLite-ZIP round trip.
+
+These tests exercise the extracted ``_sqlalchemy_type_to_sqlite_type``
+helper directly so the regression guard doesn't depend on a full backup
+pipeline. The SQLite source path is just ``shutil.copy2`` of the live
+.db file and is therefore unaffected by the type mapping.
+"""
+
+import hashlib
+import sqlite3
+
+import pytest
+from sqlalchemy import Column, LargeBinary
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.api.routes.settings import _sqlalchemy_type_to_sqlite_type
+from backend.tests._fixtures.oidc_icon import PNG_BYTES as _PNG_BYTES
+
+
+class TestTypeMapping:
+    """Unit-level coverage of the helper that backups use for PG→SQLite."""
+
+    def test_largebinary_maps_to_blob(self):
+        # Direct from a SQLAlchemy LargeBinary column — this is exactly
+        # what the create_backup_zip loop calls str() on.
+        col = Column(LargeBinary)
+        assert _sqlalchemy_type_to_sqlite_type(str(col.type)) == "BLOB"
+
+    @pytest.mark.parametrize(
+        "type_repr",
+        ["BLOB", "BYTEA", "BYTEA(1024)", "VARBINARY", "BINARY", "binary varying"],
+    )
+    def test_binary_type_strings_map_to_blob(self, type_repr):
+        assert _sqlalchemy_type_to_sqlite_type(type_repr) == "BLOB"
+
+    def test_integer_unchanged(self):
+        assert _sqlalchemy_type_to_sqlite_type("INTEGER") == "INTEGER"
+        assert _sqlalchemy_type_to_sqlite_type("BIGINT") == "INTEGER"
+
+    def test_boolean_unchanged(self):
+        assert _sqlalchemy_type_to_sqlite_type("BOOLEAN") == "BOOLEAN"
+
+    def test_unknown_falls_back_to_text(self):
+        assert _sqlalchemy_type_to_sqlite_type("VARCHAR(500)") == "TEXT"
+        assert _sqlalchemy_type_to_sqlite_type("DATETIME") == "TEXT"
+
+
+class TestSqliteBinaryRoundtrip:
+    """SQLite natively stores BLOB without escaping — sanity-check that the
+    serialise/deserialise path used by the PG→SQLite backup (``executemany``
+    with bytes values) preserves non-UTF8 bytes exactly."""
+
+    def test_binary_value_roundtrips_through_sqlite_blob(self, tmp_path):
+        db_path = tmp_path / "roundtrip.db"
+        conn = sqlite3.connect(str(db_path))
+        try:
+            conn.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, blob BLOB)")
+            # A payload that's deliberately not UTF8-decodable.
+            payload = bytes(range(256))
+            conn.execute("INSERT INTO t (id, blob) VALUES (?, ?)", (1, payload))
+            conn.commit()
+            row = conn.execute("SELECT blob FROM t WHERE id = 1").fetchone()
+            assert row[0] == payload
+        finally:
+            conn.close()
+
+
+class TestIconTripletCheckConstraint:
+    """N10 — DB-level enforcement of the icon-cache triplet invariant.
+
+    The CHECK constraint applies on SQLite fresh installs (via
+    metadata.create_all) and on PostgreSQL fresh + stale installs (via
+    ALTER TABLE ADD CONSTRAINT). Stale SQLite installs do not get it
+    (SQLite cannot ADD CONSTRAINT to an existing table) — documented
+    trade-off, application layer enforces.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_full_triplet_accepted(self, db_session: AsyncSession):
+        from backend.app.models.oidc_provider import OIDCProvider
+
+        prov = OIDCProvider(
+            name="TripletFullProv",
+            issuer_url="https://idp.example.com",
+            client_id="c",
+            scopes="openid",
+            is_enabled=True,
+        )
+        prov.client_secret = "secret"
+        prov.icon_data = _PNG_BYTES
+        prov.icon_content_type = "image/png"
+        prov.icon_etag = hashlib.sha256(_PNG_BYTES).hexdigest()
+        db_session.add(prov)
+        await db_session.commit()  # must not raise
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_all_null_triplet_accepted(self, db_session: AsyncSession):
+        from backend.app.models.oidc_provider import OIDCProvider
+
+        prov = OIDCProvider(
+            name="TripletEmptyProv",
+            issuer_url="https://idp.example.com",
+            client_id="c",
+            scopes="openid",
+            is_enabled=True,
+        )
+        prov.client_secret = "secret"
+        # All three icon columns left as default None.
+        db_session.add(prov)
+        await db_session.commit()  # must not raise
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_partial_triplet_rejected_by_check_constraint(self, db_session: AsyncSession):
+        """Direct UPDATE that sets only icon_content_type (no icon_data, no
+        icon_etag) must violate the CHECK constraint on a fresh SQLite
+        install (CHECK constraints fire on SQLite even when foreign keys
+        are off). Demonstrates the CHECK is the catch-net for raw-SQL
+        maintenance paths that bypass _fetch_icon_or_400.
+        """
+        from sqlalchemy import text
+        from sqlalchemy.exc import IntegrityError
+
+        from backend.app.models.oidc_provider import OIDCProvider
+
+        prov = OIDCProvider(
+            name="TripletPartialProv",
+            issuer_url="https://idp.example.com",
+            client_id="c",
+            scopes="openid",
+            is_enabled=True,
+        )
+        prov.client_secret = "secret"
+        db_session.add(prov)
+        await db_session.commit()
+        pid = prov.id
+
+        with pytest.raises(IntegrityError):
+            await db_session.execute(
+                text("UPDATE oidc_providers SET icon_content_type = :ct WHERE id = :pid"),
+                {"ct": "image/png", "pid": pid},
+            )
+            await db_session.commit()

+ 95 - 0
backend/tests/integration/test_oidc_icon_deferred_load.py

@@ -0,0 +1,95 @@
+"""Verify icon_data stays deferred on list queries (#1333).
+
+Regression guard: if ``deferred=True`` ever gets dropped from
+``OIDCProvider.icon_data``, every login-page hit pulls the full BLOB on
+the listing query, adding ~MB of bandwidth per anonymous request. These
+tests assert via SQLAlchemy's instance inspector that the column is
+**not** loaded by default and **is** loaded after an explicit
+``undefer()``.
+"""
+
+import hashlib
+
+import pytest
+from sqlalchemy import inspect, select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import undefer
+
+_PNG_BYTES = bytes.fromhex(
+    "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4"
+    "890000000d49444154789c63000100000005000100"
+    "0d0a2db40000000049454e44ae426082"
+)
+
+
+async def _seed_provider(db_session: AsyncSession, name: str = "DeferredProv"):
+    from backend.app.models.oidc_provider import OIDCProvider
+
+    provider = OIDCProvider(
+        name=name,
+        issuer_url="https://idp.example.com",
+        client_id="c",
+        scopes="openid",
+        is_enabled=True,
+    )
+    provider.client_secret = "secret"
+    provider.icon_url = "https://example.com/icon.png"
+    provider.icon_data = _PNG_BYTES
+    provider.icon_content_type = "image/png"
+    provider.icon_etag = hashlib.sha256(_PNG_BYTES).hexdigest()
+    db_session.add(provider)
+    await db_session.commit()
+    db_session.expire_all()  # force the next read to come from DB, not identity-map
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_default_list_query_does_not_load_icon_data(db_session: AsyncSession):
+    """`select(OIDCProvider)` without options keeps icon_data unloaded."""
+    from backend.app.models.oidc_provider import OIDCProvider
+
+    await _seed_provider(db_session)
+    result = await db_session.execute(select(OIDCProvider))
+    provider = result.scalar_one()
+
+    state = inspect(provider)
+    assert "icon_data" in state.unloaded, (
+        "icon_data should be deferred on the default list query — "
+        "without this guard every login page hit pulls the full BLOB."
+    )
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_undefer_loads_icon_data(db_session: AsyncSession):
+    """`select(...).options(undefer(...))` loads icon_data eagerly."""
+    from backend.app.models.oidc_provider import OIDCProvider
+
+    await _seed_provider(db_session, name="UndeferProv")
+    result = await db_session.execute(
+        select(OIDCProvider).options(undefer(OIDCProvider.icon_data)).where(OIDCProvider.name == "UndeferProv")
+    )
+    provider = result.scalar_one()
+
+    state = inspect(provider)
+    assert "icon_data" not in state.unloaded, "undefer() must eagerly load icon_data"
+    # And the bytes are accessible without raising MissingGreenlet.
+    assert provider.icon_data == _PNG_BYTES
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_icon_content_type_is_eager_indicator(db_session: AsyncSession):
+    """icon_content_type must NOT be deferred — it's the eager has-icon
+    indicator that route handlers consult instead of icon_data, so it must
+    be loaded on every default query."""
+    from backend.app.models.oidc_provider import OIDCProvider
+
+    await _seed_provider(db_session, name="IndicatorProv")
+    result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "IndicatorProv"))
+    provider = result.scalar_one()
+
+    state = inspect(provider)
+    assert "icon_content_type" not in state.unloaded
+    # Direct access does not raise MissingGreenlet (it was already loaded).
+    assert provider.icon_content_type == "image/png"

+ 299 - 0
backend/tests/integration/test_oidc_relogin.py

@@ -0,0 +1,299 @@
+"""E2E test for issue #1285: SSO user can re-login after admin deletion.
+
+Reproduces the exact symptom from the issue: a user logs in via OIDC
+(auto_create_users=True), gets created, is then deleted by the admin, and
+attempts to log in again. With the fix in delete_user (UserOIDCLink cleanup)
++ the orphan-cleanup migration, the second OIDC callback must trigger
+auto_create_users and produce a fresh user — instead of redirecting to
+"account_inactive" because of the orphan link.
+"""
+
+from __future__ import annotations
+
+import base64
+import secrets
+import time
+from datetime import datetime, timedelta, timezone
+from unittest.mock import patch
+
+import jwt as pyjwt
+import pytest
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric import rsa
+from httpx import AsyncClient
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.auth_ephemeral import AuthEphemeralToken
+from backend.app.models.oidc_provider import UserOIDCLink
+from backend.app.models.user import User
+
+
+def _make_rsa_key():
+    """Throwaway RSA + JWKS for the mocked IdP."""
+    priv = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+    pem = priv.private_bytes(
+        serialization.Encoding.PEM,
+        serialization.PrivateFormat.TraditionalOpenSSL,
+        serialization.NoEncryption(),
+    )
+    pub = priv.public_key().public_numbers()
+
+    def _b64url(n: int, length: int) -> str:
+        return base64.urlsafe_b64encode(n.to_bytes(length, "big")).rstrip(b"=").decode()
+
+    jwks = {
+        "keys": [
+            {
+                "kty": "RSA",
+                "use": "sig",
+                "alg": "RS256",
+                "kid": "test-kid-1",
+                "n": _b64url(pub.n, 256),
+                "e": _b64url(pub.e, 3),
+            }
+        ]
+    }
+    return pem, jwks
+
+
+class _MockResp:
+    def __init__(self, data):
+        self._data = data
+        self.status_code = 200
+        self.is_success = True
+        self.text = str(data)
+
+    def json(self):
+        return self._data
+
+    def raise_for_status(self):
+        pass
+
+
+def _mock_httpx_factory(discovery_doc, jwks_data, token_response):
+    class _MockHttpxClient:
+        def __init__(self, *args, **kwargs):
+            pass
+
+        async def __aenter__(self):
+            return self
+
+        async def __aexit__(self, *args):
+            pass
+
+        async def get(self, url, **kwargs):
+            if "jwks" in url:
+                return _MockResp(jwks_data)
+            return _MockResp(discovery_doc)
+
+        async def post(self, url, **kwargs):
+            return _MockResp(token_response)
+
+    return _MockHttpxClient
+
+
+async def _trigger_oidc_callback(
+    async_client: AsyncClient,
+    db_session: AsyncSession,
+    provider_id: int,
+    issuer: str,
+    client_id: str,
+    private_pem: bytes,
+    jwks_data: dict,
+    *,
+    sub: str,
+    email: str,
+) -> str:
+    """Run a full mocked OIDC callback and return the resulting access token."""
+    nonce = secrets.token_urlsafe(16)
+    state = secrets.token_urlsafe(32)
+    code_verifier = secrets.token_urlsafe(48)
+
+    now = int(time.time())
+    id_token = pyjwt.encode(
+        {
+            "sub": sub,
+            "iss": issuer,
+            "aud": client_id,
+            "nonce": nonce,
+            "email": email,
+            "email_verified": True,
+            "iat": now,
+            "exp": now + 300,
+        },
+        private_pem,
+        algorithm="RS256",
+        headers={"kid": "test-kid-1"},
+    )
+
+    db_session.add(
+        AuthEphemeralToken(
+            token=state,
+            token_type="oidc_state",
+            provider_id=provider_id,
+            nonce=nonce,
+            code_verifier=code_verifier,
+            expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),
+        )
+    )
+    await db_session.commit()
+
+    discovery = {
+        "issuer": issuer,
+        "authorization_endpoint": f"{issuer}/auth",
+        "token_endpoint": f"{issuer}/token",
+        "jwks_uri": f"{issuer}/.well-known/jwks.json",
+    }
+    token_response = {
+        "access_token": "mock-access",
+        "token_type": "Bearer",
+        "id_token": id_token,
+    }
+
+    with patch(
+        "backend.app.api.routes.mfa.httpx.AsyncClient",
+        _mock_httpx_factory(discovery, jwks_data, token_response),
+    ):
+        callback_resp = await async_client.get(
+            f"/api/v1/auth/oidc/callback?code=test-code&state={state}",
+            follow_redirects=False,
+        )
+
+    assert callback_resp.status_code == 302, callback_resp.text
+    location = callback_resp.headers.get("location", "")
+    assert "oidc_token=" in location, f"Expected oidc_token in redirect, got: {location}"
+
+    exchange_token = location.split("oidc_token=")[1].split("&")[0]
+    exchange_resp = await async_client.post(
+        "/api/v1/auth/oidc/exchange",
+        json={"oidc_token": exchange_token},
+    )
+    assert exchange_resp.status_code == 200, exchange_resp.text
+    return exchange_resp.json()["access_token"]
+
+
+class TestOidcReloginAfterDelete:
+    """Issue #1285: SSO user must be recreatable after admin deletion."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_relogin_after_delete_recreates_user_via_auto_create(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        """User created via OIDC → deleted by admin → second OIDC login creates a new user.
+
+        Without the delete_user UserOIDCLink-cleanup fix, the second callback finds
+        the orphan link, fails to load the now-deleted user, and redirects to
+        ``account_inactive`` — never reaching auto_create_users.
+        """
+        private_pem, jwks = _make_rsa_key()
+        issuer = "https://idp.relogin-test.example.com"
+        client_id = "relogin-test-client"
+        sub = "oidc-sub-relogin-1285"
+        email = "relogin@example.com"
+
+        # Admin setup + create OIDC provider
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "reloginadm",
+                "admin_password": "AdminPass1!",
+            },
+        )
+        login_resp = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "reloginadm", "password": "AdminPass1!"},
+        )
+        admin_token = login_resp.json()["access_token"]
+        headers = {"Authorization": f"Bearer {admin_token}"}
+
+        create_resp = await async_client.post(
+            "/api/v1/auth/oidc/providers",
+            json={
+                "name": "ReloginIdP",
+                "issuer_url": issuer,
+                "client_id": client_id,
+                "client_secret": "test-secret",
+                "scopes": "openid email profile",
+                "is_enabled": True,
+                "auto_create_users": True,
+            },
+            headers=headers,
+        )
+        assert create_resp.status_code == 201, create_resp.text
+        provider_id = create_resp.json()["id"]
+
+        # ── First OIDC login: creates user + link ──
+        await _trigger_oidc_callback(
+            async_client,
+            db_session,
+            provider_id,
+            issuer,
+            client_id,
+            private_pem,
+            jwks,
+            sub=sub,
+            email=email,
+        )
+
+        await db_session.commit()
+        first_user_row = await db_session.execute(select(User).where(User.email == email))
+        first_user = first_user_row.scalar_one()
+        first_user_id = first_user.id
+        first_user_created_at = first_user.created_at
+
+        first_link_row = await db_session.execute(select(UserOIDCLink).where(UserOIDCLink.provider_user_id == sub))
+        assert first_link_row.scalar_one().user_id == first_user_id
+
+        # ── Admin deletes the user ──
+        del_resp = await async_client.delete(
+            f"/api/v1/users/{first_user_id}",
+            headers=headers,
+        )
+        assert del_resp.status_code == 204, del_resp.text
+
+        await db_session.commit()
+        # With the fix the orphan link is gone too — verifying because that
+        # is exactly the precondition for auto_create to fire on retry.
+        link_after_delete = await db_session.execute(select(UserOIDCLink).where(UserOIDCLink.provider_user_id == sub))
+        assert link_after_delete.scalar_one_or_none() is None, (
+            "Orphan UserOIDCLink left after delete — would block re-login per #1285"
+        )
+        # And the user row itself is gone (#1285 prerequisite).
+        user_after_delete = await db_session.execute(select(User).where(User.email == email))
+        assert user_after_delete.scalar_one_or_none() is None
+
+        # ── Second OIDC login with the same sub: auto_create must run again ──
+        # The helper already asserts a 302 with oidc_token=… — that alone proves
+        # auto_create fired (otherwise the callback would have redirected to
+        # /?oidc_error=account_inactive and the helper would have failed).
+        await _trigger_oidc_callback(
+            async_client,
+            db_session,
+            provider_id,
+            issuer,
+            client_id,
+            private_pem,
+            jwks,
+            sub=sub,
+            email=email,
+        )
+
+        await db_session.commit()
+        second_row = await db_session.execute(select(User).where(User.email == email))
+        second_user = second_row.scalar_one()
+        # SQLite recycles primary-key ids when AUTOINCREMENT is not declared, so
+        # comparing ids is not a reliable freshness signal across delete+recreate.
+        # The decisive proof: a new user row was created (post-delete) and a
+        # fresh link points at it. created_at must not be earlier than the
+        # original — equality is acceptable on fast machines where seconds match.
+        assert second_user.created_at >= first_user_created_at, (
+            f"Re-created user has earlier created_at ({second_user.created_at}) "
+            f"than the deleted original ({first_user_created_at}) — bug regression"
+        )
+
+        # And a fresh link for the new user
+        link_after = await db_session.execute(select(UserOIDCLink).where(UserOIDCLink.provider_user_id == sub))
+        assert link_after.scalar_one().user_id == second_user.id

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

@@ -1833,3 +1833,85 @@ class TestAbortedStatusNormalisation:
         """Verify 404 for non-existent batch."""
         response = await async_client.get("/api/v1/queue/batches/9999")
         assert response.status_code == 404
+
+    # ========================================================================
+    # Soft-deleted archive handling (#1348 follow-up)
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_soft_delete_archive_cancels_pending_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."""
+        from backend.app.services.archive import ArchiveService
+
+        printer = await printer_factory()
+        archive = await archive_factory(thumbnail_path="archives/test/test/thumbnail.png")
+        pending = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="pending")
+        completed = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="completed")
+
+        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"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_queue_api_hides_archive_surface_when_soft_deleted(
+        self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory, db_session
+    ):
+        """Queue serializer must NOT populate archive_thumbnail / archive_name
+        when the archive is soft-deleted — otherwise the frontend renders a
+        broken <img> and 404-storms the thumbnail / plates / plate-thumbnail
+        endpoints. archive_deleted=True signals the soft-deleted state so
+        the UI can render a 'source deleted' badge."""
+        from datetime import datetime, timezone
+
+        printer = await printer_factory()
+        archive = await archive_factory(
+            print_name="Test Print",
+            thumbnail_path="archives/test/test/thumbnail.png",
+            deleted_at=datetime.now(timezone.utc),  # Pre-soft-deleted
+        )
+        item = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="cancelled")
+
+        resp = await async_client.get("/api/v1/queue/")
+        assert resp.status_code == 200
+        body = resp.json()
+        row = next((r for r in body if r["id"] == item.id), None)
+        assert row is not None
+        assert row["archive_deleted"] is True
+        assert row["archive_thumbnail"] is None, "must not expose stale thumbnail path for soft-deleted archive"
+        assert row["archive_name"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_queue_api_still_exposes_archive_surface_when_live(
+        self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory, db_session
+    ):
+        """Sanity guard: the soft-delete suppression must not affect live
+        archives. archive_name / archive_thumbnail still flow through and
+        archive_deleted stays False."""
+        printer = await printer_factory()
+        archive = await archive_factory(
+            print_name="Live Archive",
+            thumbnail_path="archives/test/live/thumbnail.png",
+        )
+        item = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="pending")
+
+        resp = await async_client.get("/api/v1/queue/")
+        assert resp.status_code == 200
+        row = next((r for r in resp.json() if r["id"] == item.id), None)
+        assert row is not None
+        assert row["archive_deleted"] is False
+        assert row["archive_name"] == "Live Archive"
+        assert row["archive_thumbnail"] == "archives/test/live/thumbnail.png"

+ 26 - 3
backend/tests/integration/test_security.py

@@ -1319,7 +1319,7 @@ class TestEncryptLegacyMigration:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_init_db_propagates_unexpected_migration_error(self, monkeypatch):
+    async def test_init_db_propagates_unexpected_migration_error(self, monkeypatch, tmp_path):
         """B3: an unexpected error from _migrate_encrypt_legacy_secrets must
         surface (re-raise) instead of being silently swallowed.
 
@@ -1331,7 +1331,27 @@ class TestEncryptLegacyMigration:
         rather than poking the inner read phase, because that is the contract
         boundary the rest of the codebase relies on (init_db -> migration).
         """
+        from sqlalchemy import event
+        from sqlalchemy.ext.asyncio import create_async_engine
+
         import backend.app.core.database as db_mod
+        from backend.app.core.config import settings
+
+        # init_db() uses the module-level `engine`, which was bound at import
+        # time to settings.database_url — that resolves to the real shared
+        # bambuddy.db at the project root (or, when DATABASE_URL is set, the
+        # configured Postgres). The autouse DATA_DIR fixture runs too late to
+        # influence either. Letting this test write to that real DB makes it
+        # (a) non-hermetic and (b) flake under `-n 30` with "database is
+        # locked" when two workers race on the file. Substitute an isolated
+        # per-test SQLite engine — and override settings.database_url for
+        # this test so the is_sqlite() / is_postgres() dialect guards inside
+        # run_migrations pick the SQLite path against this engine.
+        test_db_url = f"sqlite+aiosqlite:///{tmp_path / 'init_db_test.db'}"
+        test_engine = create_async_engine(test_db_url, echo=False)
+        event.listen(test_engine.sync_engine, "connect", db_mod._set_sqlite_pragmas)
+        monkeypatch.setattr(db_mod, "engine", test_engine)
+        monkeypatch.setattr(settings, "database_url", test_db_url)
 
         async def boom():
             raise RuntimeError("simulated startup-fatal failure")
@@ -1346,8 +1366,11 @@ class TestEncryptLegacyMigration:
         monkeypatch.setattr(db_mod, "seed_spool_catalog", lambda: _noop_async())
         monkeypatch.setattr(db_mod, "seed_color_catalog", lambda: _noop_async())
 
-        with pytest.raises(RuntimeError, match="simulated startup-fatal failure"):
-            await db_mod.init_db()
+        try:
+            with pytest.raises(RuntimeError, match="simulated startup-fatal failure"):
+                await db_mod.init_db()
+        finally:
+            await test_engine.dispose()
 
 
 async def _noop_async():

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

@@ -148,6 +148,39 @@ async def test_trusted_origins_applies_to_docs_branch(async_client: AsyncClient,
     assert "frame-ancestors 'self' https://ha.example.com;" in csp
 
 
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_default_block_img_src_excludes_https(async_client: AsyncClient, monkeypatch):
+    """#1333 regression guard: the default SPA CSP must NOT allow img-src https:.
+
+    Bambuddy's policy for external images is a backend proxy (see
+    /api/v1/makerworld/thumbnail and /api/v1/auth/oidc/providers/{id}/icon),
+    not a CSP relaxation. If a future change adds ``https:`` to img-src to
+    "fix" a broken-image, the proxy pattern silently degrades into a
+    do-nothing layer and the entire SPA gains a hot-link surface.
+    """
+    from backend.app import main as main_module
+
+    monkeypatch.setattr(main_module, "_TRUSTED_FRAME_ORIGINS", ())
+
+    resp = await async_client.get("/api/v1/auth/status")
+    csp = resp.headers.get("Content-Security-Policy", "")
+    # Extract the img-src directive — splits on ';' for safety against
+    # neighbouring directives that happen to contain the substring.
+    img_src_directive = next(
+        (d.strip() for d in csp.split(";") if d.strip().startswith("img-src")),
+        "",
+    )
+    assert img_src_directive, f"img-src directive missing from CSP: {csp!r}"
+    assert "https:" not in img_src_directive, (
+        f"img-src must not allow arbitrary https: hosts (proxy external images instead); got: {img_src_directive!r}"
+    )
+    # Sanity: the legitimately allowed scheme sources are still present.
+    assert "'self'" in img_src_directive
+    assert "data:" in img_src_directive
+    assert "blob:" in img_src_directive
+
+
 @pytest.mark.asyncio
 @pytest.mark.integration
 async def test_other_security_headers_unchanged(async_client: AsyncClient, monkeypatch):

+ 208 - 0
backend/tests/integration/test_settings_electricity_price.py

@@ -0,0 +1,208 @@
+"""Integration tests for #1356 — API keys writing electricity price.
+
+The contract these tests pin:
+
+  ``POST /settings/electricity-price`` is the *only* settings field writable
+  via API key, gated by an opt-in ``can_update_energy_cost`` scope. Full
+  ``PATCH /settings`` remains denied for API keys because it can rewrite
+  SMTP/LDAP/MQTT credentials. Two independent fences must pass:
+
+    1. Caller is a JWT user with SETTINGS_UPDATE permission, OR
+    2. Caller is an API key with ``can_update_energy_cost = True``.
+
+  Tests also confirm: (a) API keys without the flag get 403 with a
+  recognizable error, (b) the deny-list for ``PATCH /settings`` still fires
+  for keys that flipped only ``can_update_energy_cost`` on, so flipping the
+  narrow flag doesn't accidentally widen settings-write capability.
+"""
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import generate_api_key
+from backend.app.models.api_key import APIKey
+from backend.app.models.settings import Settings
+from backend.app.models.user import User
+
+
+async def _setup_auth_with_admin(client: AsyncClient) -> str:
+    """Enable auth + return an admin bearer token. Same pattern as #1182 tests."""
+    await client.post(
+        "/api/v1/auth/setup",
+        json={
+            "auth_enabled": True,
+            "admin_username": "energyadmin",
+            "admin_password": "AdminPass1!",  # pragma: allowlist secret
+        },
+    )
+    login = await client.post(
+        "/api/v1/auth/login",
+        json={"username": "energyadmin", "password": "AdminPass1!"},  # pragma: allowlist secret
+    )
+    return login.json()["access_token"]
+
+
+async def _make_api_key(
+    db: AsyncSession,
+    *,
+    owner_id: int | None,
+    can_update_energy_cost: bool,
+) -> str:
+    full_key, key_hash, key_prefix = generate_api_key()
+    api_key = APIKey(
+        name="energy-tariff",
+        key_hash=key_hash,
+        key_prefix=key_prefix,
+        user_id=owner_id,
+        can_update_energy_cost=can_update_energy_cost,
+    )
+    db.add(api_key)
+    await db.commit()
+    return full_key
+
+
+async def _read_setting(db: AsyncSession, key: str) -> str | None:
+    result = await db.execute(select(Settings).where(Settings.key == key))
+    row = result.scalar_one_or_none()
+    return row.value if row else None
+
+
+class TestCreateAPIKeyWithEnergyScope:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_stamps_energy_flag(self, async_client: AsyncClient):
+        token = await _setup_auth_with_admin(async_client)
+        resp = await async_client.post(
+            "/api/v1/api-keys/",
+            headers={"Authorization": f"Bearer {token}"},
+            json={"name": "tariff-push", "can_update_energy_cost": True},
+        )
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["can_update_energy_cost"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_without_flag_defaults_off(self, async_client: AsyncClient):
+        token = await _setup_auth_with_admin(async_client)
+        resp = await async_client.post(
+            "/api/v1/api-keys/",
+            headers={"Authorization": f"Bearer {token}"},
+            json={"name": "no-energy"},
+        )
+        assert resp.status_code == 200
+        assert resp.json()["can_update_energy_cost"] is False
+
+
+class TestElectricityPriceEndpoint:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_api_key_with_flag_updates_price(self, async_client: AsyncClient, db_session: AsyncSession):
+        """Happy path: API key with ``can_update_energy_cost=True`` POSTs a new
+        price and the setting persists."""
+        await _setup_auth_with_admin(async_client)
+        result = await db_session.execute(select(User).where(User.username == "energyadmin"))
+        admin = result.scalar_one()
+        full_key = await _make_api_key(db_session, owner_id=admin.id, can_update_energy_cost=True)
+
+        resp = await async_client.post(
+            "/api/v1/settings/electricity-price",
+            headers={"X-API-Key": full_key},
+            json={"energy_cost_per_kwh": 0.42},
+        )
+        assert resp.status_code == 200, resp.json()
+        # The route returns the full settings response — confirm the new value
+        # is reflected (the rest of the body is the standard scrubbed response).
+        assert resp.json()["energy_cost_per_kwh"] == 0.42
+
+        # Persisted in the settings table.
+        db_session.expire_all()
+        assert await _read_setting(db_session, "energy_cost_per_kwh") == "0.42"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_api_key_without_flag_rejected(self, async_client: AsyncClient, db_session: AsyncSession):
+        """Default API key (can_update_energy_cost=False) → 403."""
+        await _setup_auth_with_admin(async_client)
+        result = await db_session.execute(select(User).where(User.username == "energyadmin"))
+        admin = result.scalar_one()
+        full_key = await _make_api_key(db_session, owner_id=admin.id, can_update_energy_cost=False)
+
+        resp = await async_client.post(
+            "/api/v1/settings/electricity-price",
+            headers={"X-API-Key": full_key},
+            json={"energy_cost_per_kwh": 0.42},
+        )
+        assert resp.status_code == 403
+        # Don't pin the exact detail string — just that it identifies the
+        # missing permission. Keeps the test from being noise on copy tweaks.
+        assert "energy" in resp.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_admin_user_with_settings_update_allowed(self, async_client: AsyncClient, db_session: AsyncSession):
+        """JWT user with SETTINGS_UPDATE permission can still hit this route."""
+        token = await _setup_auth_with_admin(async_client)
+        resp = await async_client.post(
+            "/api/v1/settings/electricity-price",
+            headers={"Authorization": f"Bearer {token}"},
+            json={"energy_cost_per_kwh": 0.19},
+        )
+        assert resp.status_code == 200
+        assert resp.json()["energy_cost_per_kwh"] == 0.19
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unauthenticated_rejected(self, async_client: AsyncClient):
+        """No credentials when auth is enabled → 401."""
+        await _setup_auth_with_admin(async_client)
+        resp = await async_client.post(
+            "/api/v1/settings/electricity-price",
+            json={"energy_cost_per_kwh": 0.19},
+        )
+        assert resp.status_code == 401
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_negative_price_rejected(self, async_client: AsyncClient, db_session: AsyncSession):
+        """The Pydantic ``ge=0`` constraint catches obviously-wrong values
+        before they reach the settings table — a negative tariff is never
+        valid in any real market."""
+        await _setup_auth_with_admin(async_client)
+        result = await db_session.execute(select(User).where(User.username == "energyadmin"))
+        admin = result.scalar_one()
+        full_key = await _make_api_key(db_session, owner_id=admin.id, can_update_energy_cost=True)
+
+        resp = await async_client.post(
+            "/api/v1/settings/electricity-price",
+            headers={"X-API-Key": full_key},
+            json={"energy_cost_per_kwh": -0.05},
+        )
+        assert resp.status_code == 422  # FastAPI validation
+
+
+class TestPatchSettingsStillBlocked:
+    """Regression guard: flipping the narrow energy-cost flag must NOT widen
+    full ``PATCH /settings`` access. The general settings-update deny for
+    API keys (which protects SMTP/LDAP/MQTT credentials) stays in place."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_settings_still_denied_with_energy_flag(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        await _setup_auth_with_admin(async_client)
+        result = await db_session.execute(select(User).where(User.username == "energyadmin"))
+        admin = result.scalar_one()
+        full_key = await _make_api_key(db_session, owner_id=admin.id, can_update_energy_cost=True)
+
+        resp = await async_client.patch(
+            "/api/v1/settings/",
+            headers={"X-API-Key": full_key},
+            json={"energy_cost_per_kwh": 0.99},
+        )
+        # Still denied — the wider route uses the deny-list path.
+        assert resp.status_code == 403
+        assert "administrative" in resp.json()["detail"].lower()

+ 119 - 0
backend/tests/integration/test_settings_ui_preferences.py

@@ -0,0 +1,119 @@
+"""Tests for the public /settings/ui-preferences endpoint (#1293).
+
+Reporter @Tivonfeng: granting `printers:clear_plate` alone wasn't enough to
+make the Clear Plate button work — the frontend also needs `require_plate_clear`
+from /settings, which requires SETTINGS_READ (and also surfaces SMTP/LDAP/MQTT
+secrets). The fix is a public subset endpoint that returns only UI rendering
+fields, so the frontend doesn't have to demand SETTINGS_READ for non-admin UX.
+
+The two guarantees pinned here:
+1. The endpoint is accessible without SETTINGS_READ.
+2. The endpoint NEVER returns sensitive fields (SMTP/LDAP/MQTT credentials,
+   API tokens, HA bearer token, etc.) — even if a future commit accidentally
+   adds one of those keys to _UI_PREFERENCE_FIELDS, this test fails loudly.
+"""
+
+import pytest
+from httpx import AsyncClient
+
+# Anything in this list MUST NOT appear in the /ui-preferences response.
+# Mirror of _SENSITIVE_FIELDS_FOR_API_KEY in backend/app/api/routes/settings.py
+# plus a wider net for any *_password / *_token / *_key suffix.
+_SENSITIVE_KEYS = {
+    "smtp_password",
+    "smtp_username",
+    "smtp_from_email",
+    "smtp_host",
+    "smtp_port",
+    "mqtt_password",
+    "mqtt_username",
+    "mqtt_broker",
+    "ha_token",
+    "ha_url",
+    "prometheus_token",
+    "virtual_printer_access_code",
+    "ldap_bind_password",
+    "ldap_bind_dn",
+    "ldap_server_url",
+    "external_url",
+    "bambu_studio_api_url",
+    "orcaslicer_api_url",
+    "local_backup_path",
+    "github_token",
+    "gitea_token",
+    "obico_api_key",
+    "obico_endpoint_url",
+}
+
+
+class TestUiPreferencesEndpoint:
+    """The new public endpoint must work without SETTINGS_READ and must
+    never return sensitive fields."""
+
+    @pytest.mark.asyncio
+    async def test_endpoint_returns_200_without_auth(self, async_client: AsyncClient):
+        """No SETTINGS_READ required — that's the whole point of the endpoint."""
+        response = await async_client.get("/api/v1/settings/ui-preferences")
+        assert response.status_code == 200
+        data = response.json()
+        assert isinstance(data, dict)
+
+    @pytest.mark.asyncio
+    async def test_returns_require_plate_clear(self, async_client: AsyncClient):
+        """The field that drove #1293: PrintersPage gates the Clear Plate button
+        on this. Must be present in the response."""
+        response = await async_client.get("/api/v1/settings/ui-preferences")
+        assert response.status_code == 200
+        data = response.json()
+        assert "require_plate_clear" in data
+        # Type must be bool (frontend does === true checks)
+        assert isinstance(data["require_plate_clear"], bool)
+
+    @pytest.mark.asyncio
+    async def test_returns_expected_field_set(self, async_client: AsyncClient):
+        """Pin the exact set of fields the endpoint exposes — adding a sensitive
+        field to _UI_PREFERENCE_FIELDS by accident should fail this assert and
+        force the author to reconsider."""
+        response = await async_client.get("/api/v1/settings/ui-preferences")
+        data = response.json()
+        expected = {
+            "require_plate_clear",
+            "check_printer_firmware",
+            "camera_view_mode",
+            "time_format",
+            "date_format",
+            "drying_presets",
+            "ams_humidity_good",
+            "ams_humidity_fair",
+            "ams_temp_good",
+            "ams_temp_fair",
+            "bed_cooled_threshold",
+        }
+        assert set(data.keys()) == expected
+
+    @pytest.mark.asyncio
+    async def test_response_excludes_sensitive_fields(self, async_client: AsyncClient, db_session):
+        """Even with sensitive fields seeded in the DB, none of them must
+        appear in the response — the endpoint is opt-in, not opt-out."""
+        from backend.app.models.settings import Settings
+
+        # Seed every sensitive field with a unique recognizable value so a leak
+        # would be obvious in failure output.
+        for i, key in enumerate(_SENSITIVE_KEYS):
+            db_session.add(Settings(key=key, value=f"SECRET_VALUE_{i}_DO_NOT_LEAK"))
+        await db_session.commit()
+
+        response = await async_client.get("/api/v1/settings/ui-preferences")
+        assert response.status_code == 200
+        data = response.json()
+
+        # No sensitive key should appear in the response keys
+        leaked_keys = _SENSITIVE_KEYS & set(data.keys())
+        assert leaked_keys == set(), f"Leaked sensitive fields: {leaked_keys}"
+
+        # And the recognizable values shouldn't appear in any value either
+        response_text = response.text
+        for i in range(len(_SENSITIVE_KEYS)):
+            assert f"SECRET_VALUE_{i}_DO_NOT_LEAK" not in response_text, (
+                f"Sensitive value index {i} leaked into response body"
+            )

+ 36 - 0
backend/tests/integration/test_spoolman_inventory_api.py

@@ -321,6 +321,42 @@ class TestSpoolmanInventoryCRUD:
         assert response.status_code == 200
         mock_spoolman_client.update_spool_full.assert_called_once()
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_with_explicit_null_color_name_clears(
+        self,
+        async_client: AsyncClient,
+        spoolman_settings,
+        mock_spoolman_client,
+    ):
+        """#1319 follow-up: explicit color_name=null in the PATCH body means
+        "clear" — route translates it to "" so find_or_create_filament patches
+        the matched filament with color_name=None."""
+        payload = {"color_name": None}
+        response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
+        assert response.status_code == 200
+        mock_spoolman_client.find_or_create_filament.assert_called_once()
+        kwargs = mock_spoolman_client.find_or_create_filament.call_args.kwargs
+        assert kwargs["color_name"] == ""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_without_color_name_keeps_current(
+        self,
+        async_client: AsyncClient,
+        spoolman_settings,
+        mock_spoolman_client,
+    ):
+        """#1319 follow-up: when color_name is omitted from the PATCH body the
+        current value is kept — None passed to find_or_create_filament means
+        "don't touch"."""
+        payload = {"note": "only updating note"}
+        response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
+        assert response.status_code == 200
+        kwargs = mock_spoolman_client.find_or_create_filament.call_args.kwargs
+        # SAMPLE_SPOOLMAN_SPOOL has no color_name field, so cur fallback is None.
+        assert kwargs["color_name"] is None
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_update_spool_not_found(

+ 19 - 15
backend/tests/integration/test_spoolman_slot_assignment_mqtt.py

@@ -203,10 +203,10 @@ class TestAssignSlotMqtt:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_extrusion_cali_sel_not_called_on_nozzle_mismatch(
+    async def test_extrusion_cali_sel_resets_default_on_nozzle_mismatch(
         self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
     ):
-        """extrusion_cali_sel is NOT called when nozzle diameter does not match K-profile."""
+        """When nozzle diameter doesn't match K-profile (no usable kp), slot resets to Default K."""
         from backend.app.models.spoolman_k_profile import SpoolmanKProfile
 
         kp = SpoolmanKProfile(
@@ -257,14 +257,15 @@ class TestAssignSlotMqtt:
             )
 
         assert response.status_code == 200
-        mqtt_mock.extrusion_cali_sel.assert_not_called()
+        mqtt_mock.extrusion_cali_sel.assert_called_once()
+        assert mqtt_mock.extrusion_cali_sel.call_args[1]["cali_idx"] == -1
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_extrusion_cali_sel_not_called_when_cali_idx_none(
+    async def test_extrusion_cali_sel_resets_default_when_cali_idx_none(
         self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
     ):
-        """extrusion_cali_sel is NOT called when K-profile has cali_idx=None."""
+        """When stored K-profile has cali_idx=None (unusable), slot resets to Default K."""
         from backend.app.models.spoolman_k_profile import SpoolmanKProfile
 
         kp = SpoolmanKProfile(
@@ -315,7 +316,8 @@ class TestAssignSlotMqtt:
             )
 
         assert response.status_code == 200
-        mqtt_mock.extrusion_cali_sel.assert_not_called()
+        mqtt_mock.extrusion_cali_sel.assert_called_once()
+        assert mqtt_mock.extrusion_cali_sel.call_args[1]["cali_idx"] == -1
 
 
 # ---------------------------------------------------------------------------
@@ -474,10 +476,10 @@ class TestAssignSpoolmanSlotLiveCaliIdx:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_no_kprofile_uses_live_cali_idx(
+    async def test_no_kprofile_resets_to_default_k(
         self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
     ):
-        """When no K-profile exists, live tray cali_idx is sent via extrusion_cali_sel."""
+        """When no K-profile exists, slot resets to cali_idx=-1 (Default K) regardless of live value."""
         printer_state = self._make_printer_state(ams_id=0, tray_id=1, cali_idx=42)
 
         mqtt_mock = MagicMock()
@@ -514,16 +516,16 @@ class TestAssignSpoolmanSlotLiveCaliIdx:
         assert resp.status_code == 200
         mqtt_mock.extrusion_cali_sel.assert_called_once()
         call_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
-        assert call_kwargs["cali_idx"] == 42
+        assert call_kwargs["cali_idx"] == -1
         assert call_kwargs["ams_id"] == 0
         assert call_kwargs["tray_id"] == 1
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_no_kprofile_no_live_cali_idx_nothing_sent(
+    async def test_no_kprofile_no_live_cali_idx_sends_default(
         self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
     ):
-        """When no K-profile and tray has no cali_idx, extrusion_cali_sel is not called."""
+        """When no K-profile and tray has no cali_idx, extrusion_cali_sel is sent with cali_idx=-1 (Default)."""
         printer_state = self._make_printer_state(ams_id=0, tray_id=2, cali_idx=None)
 
         mqtt_mock = MagicMock()
@@ -558,7 +560,8 @@ class TestAssignSpoolmanSlotLiveCaliIdx:
             )
 
         assert resp.status_code == 200
-        mqtt_mock.extrusion_cali_sel.assert_not_called()
+        mqtt_mock.extrusion_cali_sel.assert_called_once()
+        assert mqtt_mock.extrusion_cali_sel.call_args[1]["cali_idx"] == -1
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -622,10 +625,10 @@ class TestAssignSpoolmanSlotLiveCaliIdx:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_live_cali_idx_not_used_if_negative(
+    async def test_live_cali_idx_negative_falls_back_to_default(
         self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
     ):
-        """A negative live cali_idx is invalid and must not be sent."""
+        """A negative live cali_idx falls through and is sent as Default (cali_idx=-1)."""
         printer_state = self._make_printer_state(ams_id=0, tray_id=0, cali_idx=-1)
 
         mqtt_mock = MagicMock()
@@ -660,7 +663,8 @@ class TestAssignSpoolmanSlotLiveCaliIdx:
             )
 
         assert resp.status_code == 200
-        mqtt_mock.extrusion_cali_sel.assert_not_called()
+        mqtt_mock.extrusion_cali_sel.assert_called_once()
+        assert mqtt_mock.extrusion_cali_sel.call_args[1]["cali_idx"] == -1
 
 
 # ---------------------------------------------------------------------------

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

@@ -105,6 +105,32 @@ class TestAssignSpoolmanSlot:
         assert rows[0]["ams_id"] == 0
         assert rows[0]["tray_id"] == 0
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_assign_accepts_ams_ht_id(self, async_client: AsyncClient, slot_settings, test_printer, mock_client):
+        """#1274: AMS-HT units report ams_id 128+. The pre-fix ck_ams_id_range
+        only allowed 0-7 / 255, so the upsert blew up with `CHECK constraint
+        failed: ck_ams_id_range` and the user couldn't link any spool to the
+        H2C/H2D AMS-HT slot. This guards the widened range from regressing.
+        """
+        response = await async_client.post(
+            "/api/v1/spoolman/inventory/slot-assignments",
+            json={
+                "spoolman_spool_id": 51,
+                "printer_id": test_printer.id,
+                "ams_id": 128,  # AMS-HT on the left nozzle (matches issue's failing INSERT)
+                "tray_id": 0,
+            },
+        )
+
+        assert response.status_code == 200, response.text
+        all_resp = await async_client.get(
+            "/api/v1/spoolman/inventory/slot-assignments/all",
+            params={"printer_id": test_printer.id},
+        )
+        rows = all_resp.json()
+        assert any(r["ams_id"] == 128 and r["spoolman_spool_id"] == 51 for r in rows)
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_assign_does_not_call_update_spool(

+ 289 - 0
backend/tests/integration/test_users_auth_cleanup.py

@@ -0,0 +1,289 @@
+"""Integration tests for OIDC/MFA cleanup on user deletion.
+
+These tests verify the fix for issue #1285: deleting a user via DELETE
+/api/v1/users/{id} must also remove their UserOIDCLink, UserTOTP, and
+UserOTPCode rows. On PostgreSQL the FK CASCADE handles this, but SQLite
+ships with FK enforcement off — without explicit DELETEs in the endpoint,
+orphan rows would block SSO re-login and leak MFA secrets.
+"""
+
+from datetime import datetime, timedelta, timezone
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+
+class TestDeleteUserCleansAuthRows:
+    """Verify delete_user removes OIDC link + TOTP + OTP rows owned by the user."""
+
+    @pytest.fixture
+    async def auth_token(self, async_client: AsyncClient):
+        """Setup auth and return admin token."""
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "cleanupadmin",
+                "admin_password": "AdminPass1!",
+            },
+        )
+        login_response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "cleanupadmin", "password": "AdminPass1!"},
+        )
+        return login_response.json()["access_token"]
+
+    async def _create_user(self, async_client: AsyncClient, auth_token: str, username: str) -> int:
+        """Helper: create a non-admin user via the API and return their id."""
+        create_resp = await async_client.post(
+            "/api/v1/users/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+            json={
+                "username": username,
+                "password": "Password123!",
+                "role": "user",
+            },
+        )
+        assert create_resp.status_code in (200, 201), create_resp.text
+        return create_resp.json()["id"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_user_removes_oidc_links(
+        self,
+        async_client: AsyncClient,
+        db_session: AsyncSession,
+        auth_token: str,
+    ):
+        """Deleting a user must also delete their UserOIDCLink rows."""
+        from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
+
+        user_id = await self._create_user(async_client, auth_token, "oidcclean")
+
+        # Use the client_secret property setter (mfa_encrypt) instead of poking
+        # _client_secret_enc directly — keeps the fixture in sync with the real
+        # encryption flow even though nothing decrypts it in this test
+        # (#1295 review nit).
+        provider = OIDCProvider(
+            name="CleanupProv",
+            issuer_url="https://cleanup.example.com",
+            client_id="cleanup_client",
+            scopes="openid email profile",
+            is_enabled=True,
+        )
+        provider.client_secret = "cleanup_secret"
+        db_session.add(provider)
+        await db_session.flush()
+        db_session.add(
+            UserOIDCLink(
+                user_id=user_id,
+                provider_id=provider.id,
+                provider_user_id="sub-cleanup-123",
+                provider_email="cleanup@example.com",
+            )
+        )
+        await db_session.commit()
+
+        # Sanity check: link exists before delete
+        pre = await db_session.execute(select(UserOIDCLink).where(UserOIDCLink.user_id == user_id))
+        assert pre.scalar_one_or_none() is not None
+
+        # Delete via API
+        resp = await async_client.delete(
+            f"/api/v1/users/{user_id}",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+        assert resp.status_code == 204
+
+        # Link must be gone (the bug from #1285 is when it persists on SQLite)
+        await db_session.commit()
+        post = await db_session.execute(select(UserOIDCLink).where(UserOIDCLink.user_id == user_id))
+        assert post.scalar_one_or_none() is None, "UserOIDCLink orphan left behind — #1285 regression"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_user_removes_user_totp(
+        self,
+        async_client: AsyncClient,
+        db_session: AsyncSession,
+        auth_token: str,
+    ):
+        """Deleting a user must also delete their UserTOTP row (MFA secret)."""
+        from backend.app.models.user_totp import UserTOTP
+
+        user_id = await self._create_user(async_client, auth_token, "totpclean")
+
+        totp = UserTOTP(user_id=user_id, is_enabled=True)
+        totp.secret = "JBSWY3DPEHPK3PXP"  # encrypts via property setter
+        db_session.add(totp)
+        await db_session.commit()
+
+        pre = await db_session.execute(select(UserTOTP).where(UserTOTP.user_id == user_id))
+        assert pre.scalar_one_or_none() is not None
+
+        resp = await async_client.delete(
+            f"/api/v1/users/{user_id}",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+        assert resp.status_code == 204
+
+        await db_session.commit()
+        post = await db_session.execute(select(UserTOTP).where(UserTOTP.user_id == user_id))
+        assert post.scalar_one_or_none() is None, "UserTOTP orphan — MFA secret leaked after user delete"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_user_removes_long_lived_tokens(
+        self,
+        async_client: AsyncClient,
+        db_session: AsyncSession,
+        auth_token: str,
+    ):
+        """Deleting a user must also delete their LongLivedToken rows.
+
+        Camera-stream tokens whose `secret_hash` is still valid would
+        otherwise be matchable by `verify()` via `lookup_prefix` even
+        after the user is gone (#1295 review feedback).
+        """
+        from backend.app.models.long_lived_token import LongLivedToken
+
+        user_id = await self._create_user(async_client, auth_token, "lltclean")
+
+        db_session.add(
+            LongLivedToken(
+                user_id=user_id,
+                name="HA card",
+                lookup_prefix="abcd1234",
+                secret_hash="$2b$12$dummybcrypthashabcdefghij1234567890",
+                scope="camera_stream",
+                expires_at=datetime.now(timezone.utc) + timedelta(days=30),
+            )
+        )
+        await db_session.commit()
+
+        pre = await db_session.execute(select(LongLivedToken).where(LongLivedToken.user_id == user_id))
+        assert pre.scalar_one_or_none() is not None
+
+        resp = await async_client.delete(
+            f"/api/v1/users/{user_id}",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+        assert resp.status_code == 204
+
+        await db_session.commit()
+        post = await db_session.execute(select(LongLivedToken).where(LongLivedToken.user_id == user_id))
+        assert post.scalar_one_or_none() is None, (
+            "LongLivedToken orphan — camera-stream secret still in DB after user delete"
+        )
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_user_removes_user_otp_codes(
+        self,
+        async_client: AsyncClient,
+        db_session: AsyncSession,
+        auth_token: str,
+    ):
+        """Deleting a user must also delete their UserOTPCode rows."""
+        from backend.app.models.user_otp_code import UserOTPCode
+
+        user_id = await self._create_user(async_client, auth_token, "otpclean")
+
+        # Two pending OTP codes so we verify the WHERE clause hits all rows
+        for _ in range(2):
+            db_session.add(
+                UserOTPCode(
+                    user_id=user_id,
+                    code_hash="$pbkdf2-sha256$dummy",
+                    expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
+                )
+            )
+        await db_session.commit()
+
+        pre = await db_session.execute(select(UserOTPCode).where(UserOTPCode.user_id == user_id))
+        assert len(pre.scalars().all()) == 2
+
+        resp = await async_client.delete(
+            f"/api/v1/users/{user_id}",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+        assert resp.status_code == 204
+
+        await db_session.commit()
+        post = await db_session.execute(select(UserOTPCode).where(UserOTPCode.user_id == user_id))
+        assert post.scalars().all() == [], "UserOTPCode orphans left behind"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_user_with_all_auth_rows(
+        self,
+        async_client: AsyncClient,
+        db_session: AsyncSession,
+        auth_token: str,
+    ):
+        """Combined: one user with OIDC link + TOTP + OTP + long-lived token — all cleaned up atomically."""
+        from backend.app.models.long_lived_token import LongLivedToken
+        from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
+        from backend.app.models.user_otp_code import UserOTPCode
+        from backend.app.models.user_totp import UserTOTP
+
+        user_id = await self._create_user(async_client, auth_token, "fullauth")
+
+        provider = OIDCProvider(
+            name="FullAuthProv",
+            issuer_url="https://fullauth.example.com",
+            client_id="fullauth_client",
+            scopes="openid email profile",
+            is_enabled=True,
+        )
+        provider.client_secret = "fullauth_secret"
+        db_session.add(provider)
+        await db_session.flush()
+
+        db_session.add(
+            UserOIDCLink(
+                user_id=user_id,
+                provider_id=provider.id,
+                provider_user_id="sub-fullauth",
+                provider_email="full@example.com",
+            )
+        )
+        totp = UserTOTP(user_id=user_id, is_enabled=True)
+        totp.secret = "JBSWY3DPEHPK3PXP"
+        db_session.add(totp)
+        db_session.add(
+            UserOTPCode(
+                user_id=user_id,
+                code_hash="$pbkdf2-sha256$dummy",
+                expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
+            )
+        )
+        db_session.add(
+            LongLivedToken(
+                user_id=user_id,
+                name="combined-test",
+                lookup_prefix="zz999999",
+                secret_hash="$2b$12$dummybcrypthashabcdefghij1234567890",
+                scope="camera_stream",
+                expires_at=datetime.now(timezone.utc) + timedelta(days=30),
+            )
+        )
+        await db_session.commit()
+
+        resp = await async_client.delete(
+            f"/api/v1/users/{user_id}",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+        assert resp.status_code == 204
+
+        await db_session.commit()
+        link_post = await db_session.execute(select(UserOIDCLink).where(UserOIDCLink.user_id == user_id))
+        totp_post = await db_session.execute(select(UserTOTP).where(UserTOTP.user_id == user_id))
+        otp_post = await db_session.execute(select(UserOTPCode).where(UserOTPCode.user_id == user_id))
+        llt_post = await db_session.execute(select(LongLivedToken).where(LongLivedToken.user_id == user_id))
+        assert link_post.scalar_one_or_none() is None
+        assert totp_post.scalar_one_or_none() is None
+        assert otp_post.scalars().all() == []
+        assert llt_post.scalar_one_or_none() is None

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

@@ -99,6 +99,68 @@ class TestReturnsFalseOnTimeout:
         assert result is False
         client.force_reconnect_stale_session.assert_called_once()
 
+    @pytest.mark.asyncio
+    async def test_returns_false_on_finish_to_idle_user_dismissed_prompt(self):
+        """Regression for #1370 in the direct-dispatch path: when pre_state is
+        FINISH and the printer transitions to IDLE during the verifier window,
+        that's the user dismissing a post-print prompt — NOT acceptance of our
+        project_file. The original ``state != pre_state`` check incorrectly
+        returned True on this transition, so the dispatch job was marked
+        successful even though no print was running. Must now report failure
+        so the caller raises RuntimeError and the user sees the actual error.
+        """
+        get_status = MagicMock(return_value=_status("IDLE", "OLD_SUBTASK"))
+        client = MagicMock()
+        get_client = MagicMock(return_value=client)
+
+        with (
+            patch(
+                "backend.app.services.background_dispatch.printer_manager.get_status",
+                get_status,
+            ),
+            patch(
+                "backend.app.services.background_dispatch.printer_manager.get_client",
+                get_client,
+            ),
+        ):
+            result = await BackgroundDispatchService._verify_print_response(
+                printer_id=42,
+                printer_name="P1S",
+                pre_state="FINISH",
+                pre_subtask_id="OLD_SUBTASK",
+                timeout=0.2,
+                poll_interval=0.05,
+            )
+
+        assert result is False, (
+            "FINISH -> IDLE is the user dismissing a screen prompt, not the "
+            "printer accepting project_file — verifier must report failure (#1370)"
+        )
+
+    @pytest.mark.asyncio
+    async def test_returns_true_on_each_active_print_state(self):
+        """Counterpart to the #1370 fix: transitions into the active-print
+        state set ARE valid "command landed" signals. PREPARE / SLICING /
+        RUNNING / PAUSE all return True.
+        """
+        for active_state in ("PREPARE", "SLICING", "RUNNING", "PAUSE"):
+            get_status = MagicMock(return_value=_status(active_state, "OLD_SUBTASK"))
+            with patch(
+                "backend.app.services.background_dispatch.printer_manager.get_status",
+                get_status,
+            ):
+                result = await BackgroundDispatchService._verify_print_response(
+                    printer_id=42,
+                    printer_name="P1S",
+                    pre_state="IDLE",
+                    pre_subtask_id="OLD_SUBTASK",
+                    timeout=0.2,
+                    poll_interval=0.05,
+                )
+            assert result is True, (
+                f"transition IDLE -> {active_state} must be treated as a valid 'command landed' signal"
+            )
+
     @pytest.mark.asyncio
     async def test_returns_false_when_pre_subtask_id_none_and_state_unchanged(self):
         """Backward-compat: callers without a captured pre_subtask_id (e.g. the

+ 19 - 5
backend/tests/unit/services/test_bambu_cloud.py

@@ -218,8 +218,18 @@ class TestBambuCloudTOTPVerification:
             assert "Invalid response" in result["message"]
 
     @pytest.mark.asyncio
-    async def test_verify_totp_includes_browser_headers(self, cloud_service):
-        """TOTP verification should include browser-like headers to bypass Cloudflare."""
+    async def test_verify_totp_uses_honest_bambuddy_user_agent(self, cloud_service):
+        """TOTP verification identifies as Bambuddy, not as a browser.
+
+        The TOTP endpoint previously sent a Chrome User-Agent + Origin/Referer
+        headers under the assumption Cloudflare would block non-browser
+        identification. Verified 2026-05-12 that ``https://bambulab.com/api/sign-in/tfa``
+        accepts ``Bambuddy/X.Y.Z`` cleanly — the expected application-level
+        response comes back, no Cloudflare interstitial. Browser impersonation
+        was removed to stay clearly on the right side of Bambu Lab's
+        "no falsified client identity" line from the 2026-05-12 cloud-access
+        blog post.
+        """
         mock_response = MagicMock()
         mock_response.status_code = 200
         mock_response.text = '{"token": "test-token"}'
@@ -231,11 +241,15 @@ class TestBambuCloudTOTPVerification:
 
             await cloud_service.verify_totp("test-tfa-key", "123456")
 
-            # Check headers include User-Agent
             call_args = mock_post.call_args
             headers = call_args[1]["headers"]
-            assert "User-Agent" in headers
-            assert "Mozilla" in headers["User-Agent"]
+            assert headers["User-Agent"].startswith("Bambuddy/")
+            # Browser-impersonation strings must not creep back in
+            assert "Mozilla" not in headers["User-Agent"]
+            assert "Chrome" not in headers["User-Agent"]
+            # Origin / Referer headers were spoofing bambulab.com origin — gone
+            assert "Origin" not in headers
+            assert "Referer" not in headers
 
 
 class TestBambuCloudRegion:

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

@@ -338,6 +338,9 @@ class TestRealisticMessageFlow:
 
         mqtt_client.on_print_start = on_start
         mqtt_client.on_print_complete = on_complete
+        # Seed a prior state so the first RUNNING push is treated as a real
+        # state transition rather than a Bambuddy-restart catch-up (#1304).
+        mqtt_client._previous_gcode_state = "IDLE"
 
         # 1. Print starts with timelapse
         mqtt_client._process_message(
@@ -870,6 +873,68 @@ class TestAMSDataMerging:
             "Without power_on_flag, clearing should proceed (defaults to True)"
         )
 
+    def test_idle_printer_with_power_off_and_nonzero_bits_clears_removed_slot(self, mqtt_client):
+        """Spool removal on an idle X1C must be detected even when power_on_flag=False (#1365).
+
+        On some X1C firmware (e.g. 01.08.02.00 reported by an3k) the AMS keeps
+        publishing push_status with `power_on_flag: False` while the printer
+        sits idle between prints — but `tray_exist_bits` continues to reflect
+        the real slot inventory. The original #765 guard skipped clearing
+        whenever power_on_flag was false, so the bit transition that would
+        mark a slot empty was discarded and the only way to refresh state
+        was a manual reconnect (pushall). The guard now skips clearing only
+        on the exact shutdown pattern (zero bits + power_on_flag=False).
+        """
+        # Initial state: two AMS units, slot 1 of AMS 0 loaded (the one
+        # we'll later remove).
+        initial_ams = {
+            "ams": [
+                {
+                    "id": 0,
+                    "tray": [
+                        {"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF", "remain": 80},
+                        {"id": 1, "tray_type": "PETG", "tray_color": "00FF00FF", "remain": 60},
+                    ],
+                },
+                {
+                    "id": 1,
+                    "tray": [
+                        {"id": 0, "tray_type": "PETG", "tray_color": "DBDDD9FF", "remain": 90},
+                    ],
+                },
+            ],
+            "tray_exist_bits": "13",  # 0b00010011 — AMS0 slots 0+1, AMS1 slot 0
+            "power_on_flag": True,
+        }
+        mqtt_client._handle_ams_data(initial_ams)
+        assert mqtt_client.state.raw_data["ams"][0]["tray"][1]["tray_type"] == "PETG"
+
+        # Spool pulled from AMS 0 slot 1 while the printer is idle.
+        # tray_exist_bits goes from 0x13 -> 0x11, but firmware still reports
+        # power_on_flag=False because the printer is between prints. The real
+        # push_status payloads on the affected X1C still carry the full `ams`
+        # list (matches the bug-report log) — the slot inventory shrinks via
+        # the bitfield rather than via per-tray content updates.
+        removal_ams = {
+            "ams": [
+                {"id": 0, "tray": [{"id": 0}, {"id": 1}]},
+                {"id": 1, "tray": [{"id": 0}]},
+            ],
+            "tray_exist_bits": "11",  # 0b00010001 — slot 1 now empty
+            "power_on_flag": False,
+            "insert_flag": True,
+        }
+        mqtt_client._handle_ams_data(removal_ams)
+
+        ams_data = mqtt_client.state.raw_data["ams"]
+        assert ams_data[0]["tray"][1]["tray_type"] == "", (
+            "Removal must be detected even with power_on_flag=False when bits are non-zero (#1365)"
+        )
+        assert ams_data[0]["tray"][1]["tray_color"] == "", "Removed slot color must be cleared"
+        # Other slots untouched.
+        assert ams_data[0]["tray"][0]["tray_type"] == "PLA", "AMS0 slot 0 preserved"
+        assert ams_data[1]["tray"][0]["tray_type"] == "PETG", "AMS1 slot 0 preserved"
+
 
 class TestAMSTrayStateClearning:
     """Tests for AMS tray state-based clearing (#784).
@@ -1603,6 +1668,9 @@ class TestRequestTopicAmsMapping:
 
         mqtt_client.on_print_start = on_start
         mqtt_client._captured_ams_mapping = [0, 4, -1, -1]
+        # Seed a prior state so the first RUNNING push is treated as a real
+        # state transition rather than a Bambuddy-restart catch-up (#1304).
+        mqtt_client._previous_gcode_state = "IDLE"
 
         # Trigger print start
         mqtt_client._process_message(
@@ -1625,6 +1693,9 @@ class TestRequestTopicAmsMapping:
             start_data.update(data)
 
         mqtt_client.on_print_start = on_start
+        # Seed a prior state so the first RUNNING push is treated as a real
+        # state transition rather than a Bambuddy-restart catch-up (#1304).
+        mqtt_client._previous_gcode_state = "IDLE"
 
         mqtt_client._process_message(
             {
@@ -1639,6 +1710,48 @@ class TestRequestTopicAmsMapping:
         assert "ams_mapping" in start_data
         assert start_data["ams_mapping"] is None
 
+    def test_first_running_push_after_bambuddy_restart_does_not_fire_print_start(self, mqtt_client):
+        """Regression for #1304: Bambuddy restart mid-print misfired plate check + archive.
+
+        When Bambuddy restarts while a print is already in progress, the freshly
+        constructed BambuMQTTClient has `_previous_gcode_state = None`. The first
+        push_status the printer sends reports `gcode_state: RUNNING`. Before the
+        fix, the (None → RUNNING) transition satisfied is_new_print's guard and
+        fired on_print_start, which then ran plate detection (objects on plate →
+        paused the live print) AND re-archived the file (duplicate archive).
+
+        With the fix in place the on_print_start callback must NOT be called for
+        this catch-up push, but `_was_running` still tracks the print so
+        completion detection works the same way as before.
+        """
+        start_data = {}
+
+        def on_start(data):
+            start_data.update(data)
+
+        mqtt_client.on_print_start = on_start
+        # Explicit: this simulates a fresh Bambuddy process attaching to a
+        # printer that's already in the middle of a print.
+        mqtt_client._previous_gcode_state = None
+        mqtt_client._was_running = False
+
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "gcode_state": "RUNNING",
+                    "gcode_file": "/data/Metadata/big_print.gcode",
+                    "subtask_name": "big_print",
+                }
+            }
+        )
+
+        assert start_data == {}, "on_print_start must not fire on Bambuddy-restart catch-up"
+        # Completion detection still needs to know we're tracking a running job.
+        assert mqtt_client._was_running is True
+        # And the state-update bookkeeping ran so the NEXT push won't keep
+        # treating the first RUNNING as fresh.
+        assert mqtt_client._previous_gcode_state == "RUNNING"
+
     def test_print_complete_callback_includes_ams_mapping(self, mqtt_client):
         """on_print_complete callback data includes captured ams_mapping."""
         complete_data = {}
@@ -4668,3 +4781,141 @@ class TestAmsLoadFilamentEncoding:
         mqtt_client.state.connected = False
         assert mqtt_client.ams_load_filament(0) is False
         mqtt_client._client.publish.assert_not_called()
+
+
+class TestAmsFilamentSettingExternalSpoolEncoding:
+    """Encoding of `ams_filament_setting` / `reset_ams_slot` for the external spool.
+
+    Regression coverage for #1279. The encoding is verified against a captured
+    BambuStudio → X1C exchange (May 2026):
+
+        REQ {"command":"ams_filament_setting","ams_id":255,"tray_id":254,"slot_id":0,...}
+        REP {"result":"success",...}
+
+    The previous code sent `tray_id: 0` for the single-external case, which the
+    P1S in #1279 rejected with `result: "fail"`.
+    """
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from unittest.mock import MagicMock
+
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST123",
+            access_code="12345678",
+        )
+        client._client = MagicMock()
+        client.state.connected = True
+        return client
+
+    def _published(self, mqtt_client):
+        call_args = mqtt_client._client.publish.call_args
+        return json.loads(call_args[0][1])["print"]
+
+    def test_single_external_uses_tray_id_254(self, mqtt_client):
+        """X1C/P1S/A1 (single external slot): ams_id=255, tray_id=254, slot_id=0."""
+        # Simulate a single-external printer: vt_tray is a single-element list.
+        mqtt_client.state.raw_data = {"vt_tray": [{"id": "255"}]}
+
+        assert mqtt_client.ams_set_filament_setting(
+            ams_id=255,
+            tray_id=0,
+            tray_info_idx="GFL99",
+            tray_type="PLA",
+            tray_sub_brands="Generic PLA",
+            tray_color="000000FF",
+            nozzle_temp_min=190,
+            nozzle_temp_max=230,
+        )
+
+        cmd = self._published(mqtt_client)
+        assert cmd["command"] == "ams_filament_setting"
+        assert cmd["ams_id"] == 255
+        assert cmd["tray_id"] == 254, (
+            "Single-external `ams_filament_setting` must send tray_id=254 "
+            "(verified via BambuStudio→X1C capture). Sending tray_id=0 "
+            "is what the P1S in #1279 rejects."
+        )
+        assert cmd["slot_id"] == 0
+
+    def test_single_external_reset_uses_tray_id_254(self, mqtt_client):
+        """reset_ams_slot shares the convention — same encoding."""
+        mqtt_client.state.raw_data = {"vt_tray": [{"id": "255"}]}
+
+        assert mqtt_client.reset_ams_slot(ams_id=255, tray_id=0)
+
+        cmd = self._published(mqtt_client)
+        assert cmd["command"] == "ams_filament_setting"
+        assert cmd["ams_id"] == 255
+        assert cmd["tray_id"] == 254
+        assert cmd["slot_id"] == 0
+        # Reset clears the filament identity
+        assert cmd["tray_info_idx"] == ""
+        assert cmd["tray_type"] == ""
+
+    def test_regular_ams_tray_unchanged(self, mqtt_client):
+        """Regular AMS slots (ams_id <= 3) keep their existing encoding."""
+        mqtt_client.state.raw_data = {"vt_tray": []}
+
+        assert mqtt_client.ams_set_filament_setting(
+            ams_id=0,
+            tray_id=2,
+            tray_info_idx="GFA01",
+            tray_type="PLA",
+            tray_sub_brands="PLA Matte",
+            tray_color="FFFFFFFF",
+            nozzle_temp_min=190,
+            nozzle_temp_max=230,
+        )
+
+        cmd = self._published(mqtt_client)
+        assert cmd["ams_id"] == 0
+        assert cmd["tray_id"] == 2
+        assert cmd["slot_id"] == 2
+
+    def test_ams_ht_unchanged(self, mqtt_client):
+        """AMS-HT (ams_id >= 128) keeps its single-tray-per-unit encoding."""
+        mqtt_client.state.raw_data = {"vt_tray": []}
+
+        assert mqtt_client.ams_set_filament_setting(
+            ams_id=128,
+            tray_id=0,
+            tray_info_idx="GFA01",
+            tray_type="PLA",
+            tray_sub_brands="PLA Matte",
+            tray_color="FFFFFFFF",
+            nozzle_temp_min=190,
+            nozzle_temp_max=230,
+        )
+
+        cmd = self._published(mqtt_client)
+        assert cmd["ams_id"] == 128
+        assert cmd["tray_id"] == 0
+        assert cmd["slot_id"] == 0
+
+    def test_dual_external_left_keeps_legacy_encoding(self, mqtt_client):
+        """H2D dual-external (`vt_tray` length > 1): not in the X1C capture, so
+        left at the legacy `mqtt_tray_id = 0` until verified separately."""
+        mqtt_client.state.raw_data = {"vt_tray": [{"id": "254"}, {"id": "255"}]}
+
+        assert mqtt_client.ams_set_filament_setting(
+            ams_id=255,
+            tray_id=0,  # Ext-L
+            tray_info_idx="GFA01",
+            tray_type="PLA",
+            tray_sub_brands="PLA Matte",
+            tray_color="FFFFFFFF",
+            nozzle_temp_min=190,
+            nozzle_temp_max=230,
+        )
+
+        cmd = self._published(mqtt_client)
+        # Ext-L → mqtt_ams_id = 254
+        assert cmd["ams_id"] == 254
+        # tray_id stays at 0 for dual external; this pins current behavior so
+        # a future capture-driven change shows up in the diff.
+        assert cmd["tray_id"] == 0
+        assert cmd["slot_id"] == 0

+ 192 - 1
backend/tests/unit/services/test_ldap_service.py

@@ -14,11 +14,14 @@ import pytest
 
 from backend.app.services.ldap_service import (
     LDAPConfig,
+    LDAPSearchResult,
     LDAPUserInfo,
     _ldap_escape,
     authenticate_ldap_user,
+    lookup_ldap_user,
     parse_ldap_config,
     resolve_group_mapping,
+    search_ldap_users,
 )
 
 
@@ -298,6 +301,7 @@ class _MockConnection:
     def __init__(self, *args, **kwargs):
         self.entries: list = []
         self.search_calls: list[str] = []
+        self.last_attrs: list | None = None
         _MockConnection._instances.append(self)
 
     def open(self):
@@ -312,8 +316,10 @@ class _MockConnection:
     def unbind(self):
         pass
 
-    def search(self, search_base=None, search_filter=None, search_scope=None, attributes=None):
+    def search(self, search_base=None, search_filter=None, search_scope=None, attributes=None, **kwargs):
+        # **kwargs absorbs ldap3 options like size_limit that the real client supports
         self.search_calls.append(search_filter or "")
+        self.last_attrs = list(attributes) if attributes is not None else None
         for needle, entries in _MockConnection._search_fixture.items():
             if needle in (search_filter or ""):
                 self.entries = entries
@@ -425,3 +431,188 @@ class TestAuthenticateLdapUserGroups:
         service_conn = _MockConnection._instances[0]
         gidnumber_searches = [call for call in service_conn.search_calls if "gidNumber=" in call]
         assert gidnumber_searches == []
+
+
+# ---------------------------------------------------------------------------
+# Manual provisioning helpers — search_ldap_users + lookup_ldap_user (#1298)
+# ---------------------------------------------------------------------------
+
+
+class TestSearchLdapUsers:
+    """Admin directory search for the manual-provision flow."""
+
+    def test_returns_empty_when_query_too_short(self, mock_ldap):
+        """Queries under 2 chars must not hit the directory at all."""
+        results = search_ldap_users(_base_config(), "a")
+        assert results == []
+        # No connection was opened — no Connection instance recorded.
+        assert _MockConnection._instances == []
+
+    def test_returns_empty_when_query_whitespace(self, mock_ldap):
+        results = search_ldap_users(_base_config(), "   ")
+        assert results == []
+        assert _MockConnection._instances == []
+
+    def test_filter_covers_all_common_attributes(self, mock_ldap):
+        """The fixed OR filter must cover sAMAccountName, uid, mail, displayName, cn."""
+        _MockConnection._search_fixture = {}  # any matching attr; empty result is fine
+        search_ldap_users(_base_config(), "jdoe")
+
+        assert len(_MockConnection._instances) == 1
+        sent = _MockConnection._instances[0].search_calls[0]
+        for attr in ("sAMAccountName=*jdoe*", "uid=*jdoe*", "mail=*jdoe*", "displayName=*jdoe*", "cn=*jdoe*"):
+            assert attr in sent, f"filter missing {attr}: {sent}"
+
+    def test_wildcard_in_query_is_escaped(self, mock_ldap):
+        """A typed * in the query must not enumerate the whole directory."""
+        _MockConnection._search_fixture = {}
+        search_ldap_users(_base_config(), "j*")
+
+        sent = _MockConnection._instances[0].search_calls[0]
+        # _ldap_escape replaces * with \2a; the outer wildcards (from our filter)
+        # must remain, but the user-supplied * must be escaped.
+        assert "*j\\2a*" in sent
+
+    def test_picks_samaccountname_first(self, mock_ldap):
+        entry = _MockEntry(
+            "cn=John Doe,dc=test,dc=com",
+            sAMAccountName="jdoe",
+            uid="jdoe-uid",
+            mail="jdoe@test.com",
+            displayName="John Doe",
+            cn="John Doe",
+        )
+        _MockConnection._search_fixture = {"sAMAccountName=*jdoe*": [entry]}
+
+        results = search_ldap_users(_base_config(), "jdoe")
+
+        assert len(results) == 1
+        assert isinstance(results[0], LDAPSearchResult)
+        assert results[0].username == "jdoe"  # sAMAccountName preferred
+        assert results[0].email == "jdoe@test.com"
+        assert results[0].display_name == "John Doe"
+        assert results[0].dn == "cn=John Doe,dc=test,dc=com"
+
+    def test_falls_back_to_uid_when_no_samaccountname(self, mock_ldap):
+        entry = _MockEntry("uid=alice,ou=people,dc=test,dc=com", uid="alice", cn="Alice")
+        _MockConnection._search_fixture = {"uid=*alice*": [entry]}
+
+        results = search_ldap_users(_base_config(), "alice")
+
+        assert len(results) == 1
+        assert results[0].username == "alice"
+
+    def test_falls_back_to_cn_when_neither_samaccountname_nor_uid(self, mock_ldap):
+        """Some OpenLDAP layouts only have cn — make sure we still surface them."""
+        entry = _MockEntry("cn=Bob,ou=people,dc=test,dc=com", cn="Bob")
+        _MockConnection._search_fixture = {"cn=*Bob*": [entry]}
+
+        results = search_ldap_users(_base_config(), "Bob")
+
+        assert len(results) == 1
+        assert results[0].username == "Bob"
+
+    def test_raises_when_service_bind_fails(self, mock_ldap, monkeypatch):
+        """Bind failures must propagate so the route can return 503 instead of [] (which
+        would look indistinguishable from 'no matches found' to the admin)."""
+
+        class _BindFailConn(_MockConnection):
+            def bind(self):
+                raise RuntimeError("simulated bind failure")
+
+        monkeypatch.setattr("backend.app.services.ldap_service.Connection", _BindFailConn)
+
+        with pytest.raises(RuntimeError):
+            search_ldap_users(_base_config(), "anyone")
+
+    def test_connection_skips_client_side_attribute_validation(self, mock_ldap, monkeypatch):
+        """OpenLDAP directories don't define sAMAccountName/displayName in their schema,
+        so ldap3 would raise LDAPAttributeError client-side before sending the query
+        — break the regression by asserting Connection is opened with check_names=False
+        for directory search."""
+        captured_kwargs: dict = {}
+
+        class _CapturingConn(_MockConnection):
+            def __init__(self, *args, **kwargs):
+                captured_kwargs.update(kwargs)
+                super().__init__(*args, **kwargs)
+
+        monkeypatch.setattr("backend.app.services.ldap_service.Connection", _CapturingConn)
+
+        search_ldap_users(_base_config(), "anyone")
+
+        assert captured_kwargs.get("check_names") is False, (
+            "search_ldap_users must open the connection with check_names=False — "
+            "otherwise ldap3 rejects sAMAccountName/displayName on OpenLDAP schemas"
+        )
+
+    def test_requests_all_user_attributes_to_bypass_schema_check(self, mock_ldap):
+        """ldap3's `build_attribute_selection` validates each named attribute against
+        the server schema regardless of check_names; only the `*` wildcard is in
+        its hard-coded exclusion list. So search_ldap_users MUST request `["*"]`
+        — not the explicit AD-flavoured names — or OpenLDAP servers raise
+        `LDAPAttributeError: invalid attribute type in attribute list: sAMAccountName`."""
+        _MockConnection._search_fixture = {}
+        search_ldap_users(_base_config(), "anyone")
+
+        # The mock's search() captures search_filter in search_calls but not
+        # attributes — so monkeypatch its signature briefly to capture both.
+        # Easier: re-grep ldap3 here. The mock's search() accepts kwargs via
+        # **kwargs; we just need to verify the attributes arg was the wildcard.
+        sent_attrs = _MockConnection._instances[0].last_attrs  # set by patched search
+        assert sent_attrs == ["*"], (
+            f"Expected attributes=['*'] to bypass ldap3 schema validation; got {sent_attrs!r}. "
+            "Explicit AD attribute names (sAMAccountName, displayName) make ldap3 throw on "
+            "OpenLDAP directories whose schema doesn't define them."
+        )
+
+
+class TestLookupLdapUser:
+    """Service-bind lookup used by the manual-provision route."""
+
+    def test_returns_none_when_user_missing(self, mock_ldap):
+        _MockConnection._search_fixture = {}  # nothing matches
+
+        result = lookup_ldap_user(_base_config(), "nobody")
+
+        assert result is None
+
+    def test_returns_user_info_with_groups(self, mock_ldap):
+        user_entry = _MockEntry(
+            "cn=John Doe,dc=test,dc=com",
+            uid="jdoe",
+            mail="jdoe@test.com",
+            displayName="John Doe",
+            memberOf=["cn=ops,ou=groups,dc=test,dc=com", "cn=qa,ou=groups,dc=test,dc=com"],
+        )
+        _MockConnection._search_fixture = {"(uid=jdoe)": [user_entry]}
+
+        info = lookup_ldap_user(_base_config(), "jdoe")
+
+        assert info is not None
+        assert info.username == "jdoe"
+        assert info.email == "jdoe@test.com"
+        assert info.display_name == "John Doe"
+        assert set(info.groups) == {"cn=ops,ou=groups,dc=test,dc=com", "cn=qa,ou=groups,dc=test,dc=com"}
+
+    def test_does_not_attempt_password_bind(self, mock_ldap):
+        """lookup_ldap_user MUST NOT call the user-DN bind that authenticate_ldap_user
+        does — admins are using their own session, not the LDAP user's password."""
+        user_entry = _MockEntry("cn=jdoe,dc=test,dc=com", uid="jdoe")
+        _MockConnection._search_fixture = {"(uid=jdoe)": [user_entry]}
+
+        lookup_ldap_user(_base_config(), "jdoe")
+
+        # authenticate_ldap_user creates TWO Connection objects (service + user-bind).
+        # lookup_ldap_user must create only ONE.
+        assert len(_MockConnection._instances) == 1
+
+    def test_raises_when_service_bind_fails(self, mock_ldap, monkeypatch):
+        class _BindFailConn(_MockConnection):
+            def bind(self):
+                raise RuntimeError("simulated bind failure")
+
+        monkeypatch.setattr("backend.app.services.ldap_service.Connection", _BindFailConn)
+
+        with pytest.raises(RuntimeError):
+            lookup_ldap_user(_base_config(), "anyone")

+ 21 - 5
backend/tests/unit/services/test_makerworld.py

@@ -104,10 +104,21 @@ class TestGetDesign:
         assert url == "https://api.bambulab.com/v1/design-service/design/1"
 
     @pytest.mark.asyncio
-    async def test_sends_browser_like_headers(self, service):
-        """Post-refactor the client uses a minimal Firefox-ish header set.
-        The old ``x-bbl-*`` Bambu-app identification headers are gone —
-        ``api.bambulab.com`` accepts browser-like headers cleanly."""
+    async def test_sends_honest_bambuddy_user_agent(self, service):
+        """The client identifies honestly as Bambuddy, not as Firefox.
+
+        Earlier iterations of this code stripped ``x-bbl-*`` Bambu-app
+        identification headers but kept a Firefox User-Agent. Verified
+        2026-05-12 that MakerWorld treats ``Bambuddy/X.Y.Z`` identically to
+        a Firefox UA at the Cloudflare edge — same response shape on
+        ``/api/v1/design-service/*`` paths. Honest identification keeps us
+        clearly outside Bambu Lab's "no falsified client identity" line
+        from the 2026-05-12 cloud-access blog post.
+
+        Referer is still sent because MakerWorld's CSRF / origin-check
+        middleware uses it on some endpoints — that is functional, not
+        client-impersonation.
+        """
         resp = MagicMock()
         resp.status_code = 200
         resp.json.return_value = {"id": 1}
@@ -115,7 +126,12 @@ class TestGetDesign:
 
         await service.get_design(1)
         headers = service._client.get.call_args.kwargs["headers"]
-        assert "Firefox" in headers["User-Agent"]
+        assert headers["User-Agent"].startswith("Bambuddy/")
+        # Browser-impersonation strings must not creep back in
+        assert "Mozilla" not in headers["User-Agent"]
+        assert "Firefox" not in headers["User-Agent"]
+        assert "Chrome" not in headers["User-Agent"]
+        # Functional headers stay
         assert headers["Accept-Language"].startswith("en-US")
         assert headers["Referer"] == "https://makerworld.com/"
         assert "Accept" in headers

+ 52 - 0
backend/tests/unit/services/test_notification_service.py

@@ -670,6 +670,58 @@ class TestNotificationProviderTypes:
             assert "image" not in payload
 
 
+class TestDiscordProvider:
+    """Discord webhook URL host validation (#1363)."""
+
+    @pytest.fixture
+    def service(self):
+        return NotificationService()
+
+    @pytest.mark.asyncio
+    async def test_discord_accepts_discord_com_url(self, service):
+        config = {"webhook_url": "https://discord.com/api/webhooks/123/abc"}
+        mock_response = MagicMock()
+        mock_response.status_code = 204
+        mock_client = AsyncMock()
+        mock_client.post = AsyncMock(return_value=mock_response)
+
+        with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client:
+            mock_get_client.return_value = mock_client
+            success, _ = await service._send_discord(config, "Title", "Body")
+
+        assert success is True
+        mock_client.post.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_discord_accepts_legacy_discordapp_com_url(self, service):
+        """Discord's 'Copy Webhook URL' button emits discordapp.com URLs (#1363)."""
+        config = {"webhook_url": "https://discordapp.com/api/webhooks/123/abc"}
+        mock_response = MagicMock()
+        mock_response.status_code = 204
+        mock_client = AsyncMock()
+        mock_client.post = AsyncMock(return_value=mock_response)
+
+        with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client:
+            mock_get_client.return_value = mock_client
+            success, _ = await service._send_discord(config, "Title", "Body")
+
+        assert success is True
+        mock_client.post.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_discord_rejects_non_discord_host(self, service):
+        config = {"webhook_url": "https://evil.example.com/api/webhooks/123/abc"}
+        success, message = await service._send_discord(config, "Title", "Body")
+        assert success is False
+        assert "Invalid Discord webhook URL" in message
+
+    @pytest.mark.asyncio
+    async def test_discord_rejects_empty_url(self, service):
+        success, message = await service._send_discord({"webhook_url": ""}, "Title", "Body")
+        assert success is False
+        assert "required" in message.lower()
+
+
 class TestNtfyPriority:
     """Per-event ntfy Priority header (#990)."""
 

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

@@ -741,6 +741,60 @@ class TestPrinterStateToDict:
         assert result["ams"][0]["tray"][0]["tag_uid"] is None
         assert result["ams"][0]["tray"][0]["tray_uuid"] is None
 
+    def test_bare_tray_emulates_state_9(self, mock_state):
+        """P1S / A1 Mini physically-empty-slot signal (#1322 follow-up by @RosdasHH):
+        the firmware sends only `{"id": N}` for a truly empty slot. Treat that as
+        the firmware's "no spool" state (state=9) so the inventory assign-spool
+        path can short-circuit the doomed MQTT publish.
+        """
+        mock_state.raw_data = {
+            "ams": [
+                {
+                    "id": 0,
+                    "tray": [
+                        {"id": 0, "state": 11, "tray_type": "PLA"},  # loaded slot
+                        {"id": 1},  # P1S empty-slot signal — only id
+                    ],
+                }
+            ]
+        }
+
+        result = printer_state_to_dict(mock_state)
+        trays = result["ams"][0]["tray"]
+
+        assert trays[0]["state"] == 11, "loaded slot keeps its firmware state"
+        assert trays[1]["state"] == 9, "bare {id} tray must be promoted to state=9"
+
+    def test_populated_payload_with_empty_state_3_is_not_promoted(self, mock_state):
+        """A1 Mini BMCU / P1S Standard AMS post-Reset-Slot case (#1322 root):
+        firmware sends state=3 + tray_type="" but with the FULL field set
+        populated. Must NOT be confused with the bare-tray empty signal —
+        else inventory.py would short-circuit MQTT and we'd reintroduce the
+        deadlock the #1322 fix removed.
+        """
+        mock_state.raw_data = {
+            "ams": [
+                {
+                    "id": 0,
+                    "tray": [
+                        {
+                            "id": 0,
+                            "state": 3,
+                            "tray_type": "",  # cleared
+                            "tray_color": "",
+                            "tag_uid": "0000000000000000",
+                            "remain": 0,
+                        }
+                    ],
+                }
+            ]
+        }
+
+        result = printer_state_to_dict(mock_state)
+        # state stays at 3 — the bare-tray promotion requires the dict to have
+        # ONLY the id key, not just empty/falsy values for the other fields.
+        assert result["ams"][0]["tray"][0]["state"] == 3
+
     def test_zero_tag_uid_becomes_none(self, mock_state):
         """Verify zero tag_uid is converted to None."""
         mock_state.raw_data = {
@@ -1143,6 +1197,48 @@ class TestSupportsChamberTemp:
         assert supports_chamber_temp("N1") is False
 
 
+class TestIsBedSlinger:
+    """Tests for is_bed_slinger helper function (#1334)."""
+
+    def test_a1_series_is_bed_slinger(self):
+        """A1 / A1 Mini are open-frame bed-slingers — Z axis is the toolhead."""
+        from backend.app.services.printer_manager import is_bed_slinger
+
+        assert is_bed_slinger("A1") is True
+        assert is_bed_slinger("A1 Mini") is True
+        assert is_bed_slinger("A1MINI") is True
+        assert is_bed_slinger("A1-MINI") is True
+
+    def test_a1_internal_codes_recognised(self):
+        """Internal MQTT/SSDP codes for A1 family must also classify as bed-slinger."""
+        from backend.app.services.printer_manager import is_bed_slinger
+
+        # A1 Mini
+        assert is_bed_slinger("N1") is True
+        # A1
+        assert is_bed_slinger("N2S") is True
+
+    def test_bed_on_z_models_not_bed_slingers(self):
+        """X1 / P1 / H2 / H2C / H2D / H2S / P2S all have the bed on Z."""
+        from backend.app.services.printer_manager import is_bed_slinger
+
+        for model in ("X1", "X1C", "X1E", "P1P", "P1S", "P2S", "H2C", "H2D", "H2DPRO", "H2S"):
+            assert is_bed_slinger(model) is False, f"{model} should NOT be classified as bed-slinger"
+
+    def test_none_model_returns_false(self):
+        from backend.app.services.printer_manager import is_bed_slinger
+
+        assert is_bed_slinger(None) is False
+        assert is_bed_slinger("") is False
+
+    def test_case_insensitive(self):
+        from backend.app.services.printer_manager import is_bed_slinger
+
+        assert is_bed_slinger("a1") is True
+        assert is_bed_slinger("a1 mini") is True
+        assert is_bed_slinger("x1c") is False
+
+
 class TestSupportsDrying:
     """Tests for supports_drying helper function."""
 

+ 229 - 0
backend/tests/unit/services/test_spoolman_service.py

@@ -671,3 +671,232 @@ class TestInitSpoolmanClientSSRFGuard:
             client = await init_spoolman_client("http://spoolman.example.com:7912/")
         mock_cls.assert_called_once_with("http://spoolman.example.com:7912/")
         assert client is mock_instance
+
+
+class TestFindOrCreateFilament:
+    """Tests for SpoolmanClient._find_or_create_filament — the auto-create path
+    that runs when AMS sync sees an RFID spool that isn't already in Spoolman.
+
+    Regression tests for #1309 (Bambu Lab RFID spools getting competitor names
+    like "3DXTECH™ Black" from the unfiltered SpoolmanDB lookup).
+    """
+
+    @pytest.fixture
+    def client(self):
+        return SpoolmanClient("http://localhost:7912")
+
+    @pytest.fixture
+    def tray_pla_black(self):
+        """A typical Bambu PLA Basic Black RFID read."""
+        return AMSTray(
+            ams_id=0,
+            tray_id=0,
+            tray_type="PLA",
+            tray_sub_brands="PLA Basic",
+            tray_color="000000FF",
+            remain=100,
+            tag_uid="",
+            tray_uuid="A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
+            tray_info_idx="GFA00",
+            tray_weight=1000,
+        )
+
+    @pytest.mark.asyncio
+    async def test_returns_existing_internal_bambu_lab_filament(self, client, tray_pla_black):
+        """When a Bambu Lab filament matching material+color already exists internally,
+        return it as-is — never touch the external library or create a new entry.
+
+        This is the short-circuit that makes the workaround on #1309 necessary: once
+        a wrong name is on disk, subsequent AMS reads keep reusing it and the user has
+        to delete the mis-named entry manually for the corrected name to take effect.
+        """
+        existing = {
+            "id": 6,
+            "name": "Black",
+            "material": "PLA",
+            "color_hex": "000000",  # alpha stripped by create_filament at insert time
+            "vendor_id": 2,
+        }
+        with (
+            patch.object(client, "ensure_bambu_vendor", AsyncMock(return_value=2)),
+            patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
+            patch.object(client, "get_external_filaments", AsyncMock()) as mock_external,
+            patch.object(client, "create_filament", AsyncMock()) as mock_create,
+        ):
+            result = await client._find_or_create_filament(tray_pla_black)
+
+        assert result is existing
+        mock_external.assert_not_called()
+        mock_create.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_skips_non_bambu_lab_external_entries(self, client, tray_pla_black):
+        """Regression for #1309: the external-library loop must filter out non-Bambu-Lab
+        manufacturers. PLA black 000000 is offered by 3DJAKE, 3DXTECH (and 60+ others)
+        in SpoolmanDB before Bambu Lab's entry; without the filter the first hit wins
+        and Bambu Lab spools get labeled with competitor names.
+        """
+        external = [
+            {
+                "id": "3djake_pla_black_1000_175_n",
+                "manufacturer": "3DJAKE",
+                "name": "Black",
+                "material": "PLA",
+                "color_hex": "000000",
+                "density": 1.24,
+            },
+            {
+                "id": "3dxtech_pla_carbonxcarbonfiberblack_500_175_p",
+                "manufacturer": "3DXTECH",
+                "name": "CarbonX™ Carbon Fiber Black",
+                "material": "PLA",
+                "color_hex": "000000",
+                "density": 1.29,
+            },
+            {
+                "id": "bambulab_pla_black_1000_175_n",
+                "manufacturer": "Bambu Lab",
+                "name": "Black",
+                "material": "PLA",
+                "color_hex": "000000",
+                "density": 1.26,
+            },
+        ]
+        with (
+            patch.object(client, "ensure_bambu_vendor", AsyncMock(return_value=2)),
+            patch.object(client, "get_filaments", AsyncMock(return_value=[])),
+            patch.object(client, "get_external_filaments", AsyncMock(return_value=external)),
+            patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
+        ):
+            await client._find_or_create_filament(tray_pla_black)
+
+        mock_create.assert_called_once()
+        kwargs = mock_create.call_args.kwargs
+        # The Bambu Lab entry must win — not 3DJAKE / 3DXTECH which sort earlier.
+        assert kwargs["name"] == "Black"
+        assert kwargs["density"] == 1.26
+
+    @pytest.mark.asyncio
+    async def test_prefers_external_entry_matching_tray_sub_brands(self, client, tray_pla_black):
+        """When SpoolmanDB has multiple Bambu Lab entries for the same material+color
+        (e.g. a "PLA Basic" variant alongside a generic "Black"), prefer the entry
+        whose `name` equals the AMS `tray_sub_brands` so the more specific variant wins.
+        Per maintainer's request on #1309.
+        """
+        external = [
+            {
+                "id": "bambulab_pla_black_1000_175_n",
+                "manufacturer": "Bambu Lab",
+                "name": "Black",
+                "material": "PLA",
+                "color_hex": "000000",
+                "density": 1.24,
+            },
+            {
+                "id": "bambulab_plabasic_black_1000_175_n",
+                "manufacturer": "Bambu Lab",
+                "name": "PLA Basic",
+                "material": "PLA",
+                "color_hex": "000000",
+                "density": 1.26,
+            },
+        ]
+        with (
+            patch.object(client, "ensure_bambu_vendor", AsyncMock(return_value=2)),
+            patch.object(client, "get_filaments", AsyncMock(return_value=[])),
+            patch.object(client, "get_external_filaments", AsyncMock(return_value=external)),
+            patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
+        ):
+            await client._find_or_create_filament(tray_pla_black)
+
+        mock_create.assert_called_once()
+        kwargs = mock_create.call_args.kwargs
+        # "PLA Basic" wins over generic "Black" because it matches tray_sub_brands.
+        assert kwargs["name"] == "PLA Basic"
+        assert kwargs["density"] == 1.26
+
+    @pytest.mark.asyncio
+    async def test_falls_back_to_create_when_no_bambu_match_anywhere(self, client, tray_pla_black):
+        """If no internal Bambu Lab filament exists AND SpoolmanDB has no Bambu Lab
+        entry for this material+color (e.g. the catalog hasn't been updated yet for a
+        brand-new BL product), fall back to creating a fresh filament from the tray's
+        own RFID data — without leaking a competitor's name in.
+        """
+        external = [
+            {
+                "id": "3djake_pla_black_1000_175_n",
+                "manufacturer": "3DJAKE",
+                "name": "Black",
+                "material": "PLA",
+                "color_hex": "000000",
+            },
+        ]
+        with (
+            patch.object(client, "ensure_bambu_vendor", AsyncMock(return_value=2)),
+            patch.object(client, "get_filaments", AsyncMock(return_value=[])),
+            patch.object(client, "get_external_filaments", AsyncMock(return_value=external)),
+            patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
+        ):
+            await client._find_or_create_filament(tray_pla_black)
+
+        mock_create.assert_called_once()
+        kwargs = mock_create.call_args.kwargs
+        # The 3DJAKE entry was rejected by the manufacturer filter; tray_sub_brands wins.
+        assert kwargs["name"] == "PLA Basic"
+        assert kwargs["material"] == "PLA"
+        assert kwargs["color_hex"] == "000000"  # alpha channel stripped from tray_color
+        assert kwargs["vendor_id"] == 2
+
+    @pytest.mark.asyncio
+    async def test_accepts_external_entry_via_id_prefix_when_manufacturer_missing(self, client, tray_pla_black):
+        """Defensive fallback: if `manufacturer` is absent or empty but the entry's `id`
+        starts with `bambulab_`, treat it as a Bambu Lab entry. Keeps the filter robust
+        against SpoolmanDB schema drift or stale catalog snapshots that omit the field.
+        """
+        external = [
+            {
+                "id": "bambulab_pla_black_1000_175_n",
+                "name": "Black",
+                "material": "PLA",
+                "color_hex": "000000",
+                "density": 1.24,
+            },  # no `manufacturer` key at all
+        ]
+        with (
+            patch.object(client, "ensure_bambu_vendor", AsyncMock(return_value=2)),
+            patch.object(client, "get_filaments", AsyncMock(return_value=[])),
+            patch.object(client, "get_external_filaments", AsyncMock(return_value=external)),
+            patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
+        ):
+            await client._find_or_create_filament(tray_pla_black)
+
+        mock_create.assert_called_once()
+        assert mock_create.call_args.kwargs["name"] == "Black"
+
+    @pytest.mark.asyncio
+    async def test_external_density_propagates_to_create_filament(self, client, tray_pla_black):
+        """The chosen external entry's `density` must be forwarded to `create_filament`
+        instead of being silently replaced by the PLA-default 1.24 fallback inside
+        `create_filament` itself. Verified end-to-end via the public
+        `_find_or_create_filament` entry point.
+        """
+        external = [
+            {
+                "id": "bambulab_pla_black_1000_175_n",
+                "manufacturer": "Bambu Lab",
+                "name": "Black",
+                "material": "PLA",
+                "color_hex": "000000",
+                "density": 1.31,
+            },
+        ]
+        with (
+            patch.object(client, "ensure_bambu_vendor", AsyncMock(return_value=2)),
+            patch.object(client, "get_filaments", AsyncMock(return_value=[])),
+            patch.object(client, "get_external_filaments", AsyncMock(return_value=external)),
+            patch.object(client, "create_filament", AsyncMock(return_value={"id": 99})) as mock_create,
+        ):
+            await client._find_or_create_filament(tray_pla_black)
+
+        mock_create.assert_called_once()
+        assert mock_create.call_args.kwargs["density"] == 1.31

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

@@ -91,6 +91,35 @@ class TestResolveGlobalTrayId:
         mapping = []
         assert _resolve_global_tray_id(1, mapping) == 0
 
+    def test_minus_one_resolves_to_external_spool_when_present(self):
+        """#1276 (regression of #853): -1 in slot_to_tray is BambuStudio's
+        encoding for "external spool used" — look up the external spool in
+        ams_trays rather than falling through to the position-based default
+        (which would credit an unrelated AMS tray). Reporter ojimpo's H2S
+        had AMS slot 0 occupied with PLA and ran a TPU external-spool print;
+        the bug credited the TPU usage to the PLA spool.
+        """
+        # Single external spool (most common: H2S/X1C/P1S + external)
+        assert _resolve_global_tray_id(1, [-1], ams_trays={254: {}}) == 254
+        # AMS occupied with material AND external in use — fix prevents
+        # crediting AMS slot 0 (the actual bug from #1276)
+        assert _resolve_global_tray_id(1, [-1], ams_trays={0: {}, 1: {}, 2: {}, 3: {}, 254: {}}) == 254
+        # H2D-style deputy nozzle at 255
+        assert _resolve_global_tray_id(1, [-1], ams_trays={0: {}, 255: {}}) == 255
+        # Both external slots present (multi-nozzle) — prefer 254 (main on
+        # single-nozzle, deputy on H2D — matches tray_now reporting)
+        assert _resolve_global_tray_id(1, [-1], ams_trays={254: {}, 255: {}}) == 254
+
+    def test_minus_one_falls_through_when_no_external_in_ams_trays(self):
+        """If -1 is seen but ams_trays has no external spool (254/255),
+        fall through to position-based default (legacy behavior preserved
+        for callers that don't pass ams_trays or pre-fix call sites).
+        """
+        # ams_trays without external — fall through to legacy behavior
+        assert _resolve_global_tray_id(1, [-1], ams_trays={0: {}, 1: {}}) == 0
+        # No ams_trays passed at all — legacy fallback
+        assert _resolve_global_tray_id(1, [-1]) == 0
+
 
 class TestFallbackTagHelpers:
     """Tests for frontend-mirrored fallback tag helpers."""
@@ -221,3 +250,53 @@ class TestStorePrintData:
         tracking = db.add.call_args.args[0]
         assert tracking.slot_to_tray == [1, -1, -1, -1]
         db.execute.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_stores_tracking_when_disable_weight_sync_is_false(self):
+        """#1119: per-print tracking must run regardless of disable_weight_sync.
+
+        Previously store_print_data short-circuited when the deprecated
+        `spoolman_disable_weight_sync` flag was off, leaving non-BL spools
+        with no weight-update path at all. Per-print tracking is now the
+        only weight writer for Spoolman, so it must run whenever Spoolman
+        is enabled.
+        """
+        db = AsyncMock()
+        db.execute = AsyncMock(return_value=MagicMock())
+        db.add = MagicMock()
+        db.commit = AsyncMock()
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}]}]}
+        )
+
+        mock_settings = MagicMock()
+        mock_path = MagicMock()
+        mock_path.exists.return_value = True
+        mock_settings.base_dir.__truediv__.return_value = mock_path
+
+        # Only spoolman_enabled is consulted now (disable_weight_sync is no
+        # longer read). The single side_effect entry proves no extra
+        # get_setting calls slip back in.
+        with (
+            patch("backend.app.services.spoolman_tracking.app_settings", mock_settings),
+            patch("backend.app.api.routes.settings.get_setting", AsyncMock(side_effect=["true"])),
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=[{"slot_id": 1, "used_g": 5.0, "type": "PLA", "color": "#FF0000"}],
+            ),
+            patch("backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf", return_value=None),
+            patch("backend.app.utils.threemf_tools.extract_filament_properties_from_3mf", return_value={}),
+        ):
+            await store_print_data(
+                printer_id=1,
+                archive_id=20,
+                file_path="archives/test.3mf",
+                db=db,
+                printer_manager=printer_manager,
+                ams_mapping=[0],
+            )
+
+        # Tracking row was inserted — the fix is working.
+        db.add.assert_called_once()

+ 29 - 0
backend/tests/unit/services/test_stl_thumbnail.py

@@ -180,6 +180,35 @@ endsolid cube"""
             result = generate_stl_thumbnail(stl_path, thumbnails_dir)
             assert result is None
 
+    @pytest.mark.skipif(
+        not _check_trimesh_available(),
+        reason="trimesh not installed",
+    )
+    def test_string_arguments_accepted_without_typeerror(self):
+        """Regression for #1299: external-scan path passed both args as str.
+
+        Before the fix, the function did ``thumbnails_dir / thumb_filename`` on
+        a ``str`` and raised ``TypeError: unsupported operand type(s) for /:
+        'str' and 'str'`` for every STL on an external folder scan. The fix
+        coerces both args to ``Path`` at entry. This test passes string args
+        and asserts the function either succeeds or returns ``None`` — but
+        never raises the TypeError.
+        """
+        from backend.app.services.stl_thumbnail import generate_stl_thumbnail
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            stl_path = Path(tmpdir) / "cube.stl"
+            # Minimal valid binary STL: header (80 bytes) + tri count (0)
+            stl_path.write_bytes(b"\x00" * 80 + (0).to_bytes(4, "little"))
+
+            # str args — the exact shape the external-scan call site used.
+            result = generate_stl_thumbnail(str(stl_path), str(tmpdir))
+
+            # Zero-triangle mesh either yields no thumbnail or fails the
+            # downstream render — both are acceptable; what's NOT acceptable
+            # is a TypeError leaking out, which is what the str/str bug did.
+            assert result is None or Path(result).exists()
+
 
 class TestStlThumbnailConstants:
     """Tests for STL thumbnail service constants."""

+ 63 - 3
backend/tests/unit/services/test_usage_tracker.py

@@ -193,6 +193,64 @@ class TestOnPrintCompleteAMSDelta:
 
         assert results == []
 
+    @pytest.mark.asyncio
+    async def test_skips_fallback_for_trays_outside_print_mapping(self):
+        """#1269: swapping a spool in an UNUSED slot mid-print must NOT charge the old spool.
+
+        Reproduces maugsburger's report: single-color print on AMS0-T3
+        (ams_mapping=[3]). User swaps spools in T1 and T2 during the print —
+        those slots report remain=0 at completion (new spool with no tag).
+        The fallback must skip T1 and T2 because they were never in the
+        print's tray mapping or runtime tray_change_log.
+        """
+        _active_sessions[1] = PrintSession(
+            printer_id=1,
+            print_name="splitter",
+            started_at=datetime.now(timezone.utc),
+            tray_remain_start={(0, 1): 100, (0, 2): 17, (0, 3): 100},
+            tray_now_at_start=3,
+            ams_mapping=[3],
+        )
+
+        # User swapped T1 and T2 mid-print → both report remain=0 now.
+        # T3 was actually used but it's also at 0 now. Without the fix the
+        # fallback would charge the originally-assigned spools at T1 and T2.
+        ams_data = [
+            {
+                "id": 0,
+                "tray": [
+                    {"id": 1, "remain": 0},
+                    {"id": 2, "remain": 0},
+                    {"id": 3, "remain": 0},
+                ],
+            }
+        ]
+        state = _make_printer_state(ams_data, tray_now=3)
+        state.tray_change_log = [(3, 0)]  # only T3 was loaded during the print
+        pm = _make_printer_manager(state)
+
+        # Only T3 should reach the spool lookup; T1 and T2 must be filtered
+        # out before any DB query is issued for them.
+        t3_spool = _make_spool(id=8, label_weight=1000, weight_used=0)
+        t3_assignment = _make_assignment(spool_id=8, ams_id=0, tray_id=3)
+        db = AsyncMock()
+        db.execute = AsyncMock(
+            side_effect=[
+                MagicMock(),  # _find_3mf_by_filename: library search
+                MagicMock(),  # _find_3mf_by_filename: archive search
+                MagicMock(scalar_one_or_none=MagicMock(return_value=t3_assignment)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=t3_spool)),
+            ]
+        )
+
+        results = await on_print_complete(1, {"status": "completed"}, pm, db)
+
+        # Only T3 should be charged. T1 (spool 27 in the report) and T2
+        # (spool 24) must NOT appear in the results.
+        assert len(results) == 1
+        assert results[0]["ams_id"] == 0
+        assert results[0]["tray_id"] == 3
+
 
 class TestTrackFrom3MF:
     """Tests for Path 2: 3MF per-filament fallback tracking."""
@@ -649,6 +707,9 @@ class TestSpoolAssignmentSnapshot:
         spool = _make_spool(id=8, label_weight=1000, weight_used=50)
         archive = MagicMock()
         archive.file_path = "archives/big_print.3mf"
+        # Explicit numeric so the #1344 top-up branch doesn't trip a
+        # MagicMock-vs-float comparison.
+        archive.filament_used_grams = 14.2
 
         # Session was created at print start WITH snapshot
         _active_sessions[1] = PrintSession(
@@ -678,9 +739,8 @@ class TestSpoolAssignmentSnapshot:
                 MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
                 MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
                 MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
-                # Cost aggregation: sum query (uses .scalar()), archive lookup
-                MagicMock(scalar=MagicMock(return_value=0)),
-                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
+                # Cost-update block re-selects the archive to mutate cost.
+                MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
             ]
         )
 

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

@@ -171,6 +171,41 @@ class TestVirtualPrinterInstance:
 
             mock_archive.assert_called_once_with(file_path, "192.168.1.100")
 
+    @pytest.mark.asyncio
+    async def test_on_file_received_signals_FINISH_to_slicer(self, instance):
+        """Regression #1280: when a slicer's Print flow uploads to a non-proxy VP,
+        the VP must transition gcode_state PREPARE → FINISH so the slicer's
+        in-flight-job lock releases. Going PREPARE → IDLE wedges Orca at
+        "Downloading...(0%)" and blocks the next dispatch with "busy with
+        another print job".
+
+        Send-flow slicers don't watch the post-upload state, so this is a
+        no-op behavior change for them.
+        """
+        instance.mode = "immediate"
+        instance._mqtt = MagicMock()
+        instance._mqtt.set_gcode_state = MagicMock()
+        file_path = Path("/tmp/test.3mf")  # nosec B108
+
+        with patch.object(instance, "_archive_file", new_callable=AsyncMock):
+            await instance.on_file_received(file_path, "192.168.1.100")
+
+        instance._mqtt.set_gcode_state.assert_called_once_with("FINISH", filename="test.3mf", prepare_percent="100")
+
+    @pytest.mark.asyncio
+    async def test_on_file_received_non_3mf_does_not_touch_state(self, instance):
+        """Non-3MF uploads (e.g., a job's auxiliary files) must not transition
+        the visible state — the slicer is only tracking the .3mf upload."""
+        instance.mode = "immediate"
+        instance._mqtt = MagicMock()
+        instance._mqtt.set_gcode_state = MagicMock()
+        file_path = Path("/tmp/test.gcode")  # nosec B108
+
+        with patch.object(instance, "_archive_file", new_callable=AsyncMock):
+            await instance.on_file_received(file_path, "192.168.1.100")
+
+        instance._mqtt.set_gcode_state.assert_not_called()
+
     @pytest.mark.asyncio
     async def test_archive_file_skips_non_3mf(self, instance):
         """Verify non-3MF files are skipped and cleaned up."""
@@ -182,6 +217,67 @@ class TestVirtualPrinterInstance:
 
             assert "verify_job" not in instance._pending_files
 
+    @pytest.mark.asyncio
+    async def test_archive_file_broadcasts_archive_created(self, tmp_path):
+        """#1282: VP immediate-mode archives must broadcast archive_created so
+        the Archives page refreshes without a tab switch. Real-printer prints
+        get this via main.py's MQTT print_start handler; the VP path used to
+        skip the broadcast entirely."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        mock_db = AsyncMock()
+        mock_db.commit = AsyncMock()
+        mock_session_factory = MagicMock()
+        mock_session_ctx = AsyncMock()
+        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+        mock_session_factory.return_value = mock_session_ctx
+
+        inst = VirtualPrinterInstance(
+            vp_id=30,
+            name="ImmediateBroadcast",
+            mode="immediate",
+            model="C12",
+            access_code="12345678",
+            serial_suffix="391800030",
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+        )
+
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(b"fake3mf")
+
+        mock_archive = MagicMock()
+        mock_archive.id = 99
+        mock_archive.printer_id = None
+        mock_archive.filename = "test.3mf"
+        mock_archive.print_name = "test"
+        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,
+            ) as mock_broadcast,
+        ):
+            await inst._archive_file(file_path, "192.168.1.100")
+
+        mock_broadcast.assert_awaited_once()
+        payload = mock_broadcast.await_args.args[0]
+        assert payload["id"] == 99
+        assert payload["filename"] == "test.3mf"
+        assert payload["status"] == "archived"
+
     # ========================================================================
     # Tests for auto_dispatch
     # ========================================================================
@@ -259,6 +355,68 @@ class TestVirtualPrinterInstance:
         queue_item = added_items[0]
         assert queue_item.manual_start is False
 
+    @pytest.mark.asyncio
+    async def test_add_to_print_queue_broadcasts_archive_created(self, tmp_path):
+        """#1282: VP queue-mode uploads must broadcast archive_created so the
+        Archives page picks up the new entry live. Pre-fix the page only
+        refreshed when the user manually switched tabs."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock()
+        mock_db.commit = AsyncMock()
+        mock_session_factory = MagicMock()
+        mock_session_ctx = AsyncMock()
+        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+        mock_session_factory.return_value = mock_session_ctx
+
+        inst = VirtualPrinterInstance(
+            vp_id=31,
+            name="QueueBroadcast",
+            mode="print_queue",
+            model="C12",
+            access_code="12345678",
+            serial_suffix="391800031",
+            auto_dispatch=True,
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+        )
+
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(b"fake3mf")
+
+        mock_archive = MagicMock()
+        mock_archive.id = 77
+        mock_archive.printer_id = None
+        mock_archive.filename = "test.3mf"
+        mock_archive.print_name = "test"
+        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,
+            ) as mock_broadcast,
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+        mock_broadcast.assert_awaited_once()
+        payload = mock_broadcast.await_args.args[0]
+        assert payload["id"] == 77
+        assert payload["print_name"] == "test"
+        assert payload["status"] == "archived"
+
     @pytest.mark.asyncio
     async def test_add_to_print_queue_with_auto_dispatch_off(self, tmp_path):
         """Verify queue items have manual_start=True when auto_dispatch=False."""
@@ -317,6 +475,142 @@ class TestVirtualPrinterInstance:
         queue_item = added_items[0]
         assert queue_item.manual_start is True
 
+    @pytest.mark.asyncio
+    async def test_add_to_print_queue_uses_workflow_defaults_from_settings(self, tmp_path):
+        """#1235: VP queue-mode constructed PrintQueueItem without specifying
+        bed_levelling / flow_cali / vibration_cali / layer_inspect / timelapse,
+        so SQLAlchemy applied the column-level defaults and ignored the user's
+        workflow preferences entirely. Every print sent from the slicer to the
+        VP came through with the OPPOSITE of what the workflow page said,
+        forcing the user to edit each queue item by hand.
+        """
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        added_items = []
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock(side_effect=added_items.append)
+        mock_db.commit = AsyncMock()
+        mock_session_factory = MagicMock()
+        mock_session_ctx = AsyncMock()
+        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+        mock_session_factory.return_value = mock_session_ctx
+
+        inst = VirtualPrinterInstance(
+            vp_id=22,
+            name="DefaultsTest",
+            mode="print_queue",
+            model="C12",
+            access_code="12345678",
+            serial_suffix="391800022",
+            auto_dispatch=True,
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+        )
+
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(b"fake3mf")
+
+        # The reporter set every workflow default to the OPPOSITE of the model's
+        # column default. Pre-fix the column defaults won; with the fix the
+        # settings values must flow through to the queue item exactly as stored.
+        settings_map = {
+            "virtual_printer_archive_name_source": None,
+            "default_bed_levelling": "false",  # model default: True
+            "default_flow_cali": "true",  # model default: False
+            "default_vibration_cali": "false",  # model default: True
+            "default_layer_inspect": "true",  # model default: False
+            "default_timelapse": "true",  # model default: False
+        }
+
+        async def fake_get_setting(_db, key):
+            return settings_map.get(key)
+
+        mock_archive = MagicMock()
+        mock_archive.id = 1
+        mock_archive.print_name = "test"
+
+        with (
+            patch(
+                "backend.app.api.routes.settings.get_setting",
+                new=fake_get_setting,
+            ),
+            patch(
+                "backend.app.services.archive.ArchiveService.archive_print",
+                new_callable=AsyncMock,
+                return_value=mock_archive,
+            ),
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+        assert len(added_items) == 1
+        queue_item = added_items[0]
+        assert queue_item.bed_levelling is False, "default_bed_levelling=false must flow through"
+        assert queue_item.flow_cali is True, "default_flow_cali=true must flow through"
+        assert queue_item.vibration_cali is False, "default_vibration_cali=false must flow through"
+        assert queue_item.layer_inspect is True, "default_layer_inspect=true must flow through"
+        assert queue_item.timelapse is True, "default_timelapse=true must flow through"
+
+    @pytest.mark.asyncio
+    async def test_add_to_print_queue_falls_back_to_schema_defaults_when_unset(self, tmp_path):
+        """#1235 fallback: when no workflow setting is in the DB, the queue
+        item should use the AppSettings (Pydantic) defaults — same values
+        the user sees in the workflow page on a fresh install.
+        """
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        added_items = []
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock(side_effect=added_items.append)
+        mock_db.commit = AsyncMock()
+        mock_session_factory = MagicMock()
+        mock_session_ctx = AsyncMock()
+        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+        mock_session_factory.return_value = mock_session_ctx
+
+        inst = VirtualPrinterInstance(
+            vp_id=23,
+            name="FreshInstallDefaults",
+            mode="print_queue",
+            model="C12",
+            access_code="12345678",
+            serial_suffix="391800023",
+            auto_dispatch=True,
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+        )
+
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(b"fake3mf")
+
+        mock_archive = MagicMock()
+        mock_archive.id = 1
+        mock_archive.print_name = "test"
+
+        with (
+            patch(
+                "backend.app.api.routes.settings.get_setting",
+                new_callable=AsyncMock,
+                return_value=None,  # No settings → fall back to schema defaults
+            ),
+            patch(
+                "backend.app.services.archive.ArchiveService.archive_print",
+                new_callable=AsyncMock,
+                return_value=mock_archive,
+            ),
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+        assert len(added_items) == 1
+        queue_item = added_items[0]
+        # These must match the AppSettings (Pydantic) defaults in schemas/settings.py
+        assert queue_item.bed_levelling is True
+        assert queue_item.flow_cali is False
+        assert queue_item.vibration_cali is True
+        assert queue_item.layer_inspect is False
+        assert queue_item.timelapse is False
+
     @pytest.mark.asyncio
     async def test_add_to_print_queue_populates_required_filament_types(self, tmp_path):
         """#1188: VP queue-mode used to create PrintQueueItems with no

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