Browse Source

Merge pull request #785 from maziggy/0.2.2.1

  # Bambuddy v0.2.2.1

  ## Virtual Printer — Breaking Changes

  ▎ Action required for existing Virtual Printer users. Please follow the migration guide before updating.

The Virtual Printer FTP server now binds directly to port 990 instead of using an iptables redirect from 990 → 9990. This fixes FTP being routed to the wrong VP when running multiple virtual printers on different bind IPs (#735).

  What you need to do:
  - Native/systemd: Remove old iptables redirect rules (990 → 9990) and verify CAP_NET_BIND_SERVICE is set in the systemd service.
  - Docker (bridge network): Change port mapping from 990:9990 to 990:990 in docker-compose.yml.
  - Docker (host network): Remove old iptables redirect rules on the host. No other changes needed.
  - Unraid/Synology/TrueNAS: Remove any iptables rules you added for 990 → 9990.

  Detailed instructions can be found here -> https://github.com/maziggy/bambuddy/blob/0.2.2.1/docs/migration-vp-ftp-port.md

  Proxy mode now supports cross-VLAN/subnet printing (#757) with transparent TCP proxying for FTP (990), file transfer (6000), and camera streaming (322). If you use proxy mode behind a firewall, ensure ports 6000 and 322 are open between the slicer and Bambuddy.

  X1C/X1 compatibility is fixed — both server mode (corrected SSDP model codes BL-P001/BL-P002) and proxy mode (end-to-end TLS passthrough preserving the printer's real certificate). Existing VPs are automatically migrated on startup.


  ## Highlights

  - Virtual Printer Overhaul (#735, #757) — Major rework of Virtual Printer networking. FTP, proxy mode, and X1C compatibility all significantly improved. Existing VP users: action required — see below.
  - Select Plates to Queue (#777) — Multi-plate 3MF files now support selecting a subset of plates to queue, with per-plate checkboxes.
  - HMS Error Visibility (#772) — Red "Problem" counter in the status bar, amber/red status pips, and HMS-first sorting for print farms.
  - Per-User Email Notifications (#693) — When Advanced Authentication is enabled, individual users can now receive email notifications for their own print jobs. Contributed by @cadtoolbox.
  - Spool Rotation During AMS Drying — Added a "Rotate spool during drying" checkbox to the manual drying popover for AMS 2 Pro and AMS-HT units.

  ## New Features

  - SpoolBuddy OTA Updates — SpoolBuddy devices can now be updated directly from the Settings → Updates tab without SSH access. Click "Check for Updates" to see if a newer version is available, then "Apply Update" to trigger the update. The daemon picks up the command via its heartbeat, pulls the latest code from GitHub, installs dependencies, and restarts automatically via systemd. Live progress is shown in the UI with status messages from the device. The status bar at the bottom automatically checks for updates every 5 minutes and shows a prominent message when one is available. Requires the device to be online.
  - Select Plates to Queue (#777) — Multi-plate 3MF files now support selecting a subset of plates to queue, instead of only "one plate" or "all plates". In add-to-queue mode, each plate has a checkbox for multi-select, with a "Select All / Deselect All" toggle. Reprint and edit modes remain single-select.
  - Camera Image Rotation (#672) — Added per-printer camera rotation (0°, 90°, 180°, 270°) for cameras mounted in portrait or upside-down orientations. Configurable in Settings → Camera for each printer. Rotation applies to live stream, embedded viewer, stream overlay, and notification snapshots.
  - Per-User Email Notifications (#693) — When Advanced Authentication is enabled, individual users can now receive email notifications for their own print jobs. Contributed by @cadtoolbox.
  - Quick Print Speed Control (#256) — Speed control badge on the printer card with Silent/Standard/Sport/Ludicrous presets.
  - Spool Name Column & Filter in Filament Inventory (#740) — Added a "Spool" column and spool name filter dropdown.
  - Spool Rotation During AMS Drying — Added a "Rotate spool during drying" checkbox to the manual drying popover for AMS 2 Pro and AMS-HT units.
  - Admin Set Default Nav-Menu Order (#761) — Admins can set their sidebar menu order as the default for new users. Contributed by @cadtoolbox.
  - Add Total Cost to Projects (#733) — Projects page now shows total cost (material + energy + BOM). Contributed by @Keybored02.
  - Material Mismatch & Insufficient Filament Checks (#720) — Warns on filament type/profile mismatch and insufficient material before printing. Contributed by @Keybored02.
  - Rework Archive Duplicates Tagging (#718) — Smarter duplicate detection (name + SHA256), reprint counter tags, parent print links. Contributed by @Keybored02.

  ## Improved

  - HMS Error Visibility on Printers Page (#772) — Red "Problem" counter in the status summary bar, red/amber status pips for errors/warnings, amber progress bars for paused prints, and HMS-first sorting.
  - Home Assistant Notifications (#750) — Added support for HA notify services. Contributed by @mrtncode.
  - Print Command Response Verification (#737) — Monitors whether the printer responds within 15 seconds after sending a print command; logs a warning if silently ignored.
  - Compact Assign Spool Modal (#725) — 3-column grid layout showing more spools without scrolling.
  - Reformatted AMS Drying Presets Table (#732) — Grouped by AMS type with inline unit labels.
  - Redesigned Bug Report Debug Log Flow — Interactive 3-step flow instead of a fixed 30-second timer.

  ## Fixed

  - Queue Print Command Not Reaching Printer (#778) — Fixed repeated MQTT reconnection cycles on printers that reject request topic subscription.
  - AMS Slot Search Shows Unrelated Profiles (#681) — Search filter now correctly applies to saved presets.
  - AMS Spools Removed After Printer Restart (#765) — Skips slot clearing on shutdown messages.
  - Carbon Rod Lubrication Maintenance Task Incorrect (#755) — Removed incorrect lubrication task for carbon rods.
  - Spurious "Job Waiting for Filament" Notification (#753) — Skips waiting notification when all printers are just busy.
  - File Rename Removes Extension (#751) — Extension is now non-editable.
  - Ntfy Notifications Fail With Non-ASCII Characters (#742) — Fixed UTF-8 encoding for header values.
  - Print Complete Notification Not Firing (#736) — Added 45-second timeout on photo capture.
  - Camera Window Overlapping Modals (#738) — Lowered camera z-index.
  - Webhook Notifications Missing Camera Snapshot (#679) — Added base64-encoded image field to webhook payloads.
  - White Filament Color Swatches Invisible in Light Theme (#726) — Changed to dark border across all views.
  - Mobile Sidebar Not Scrollable — Added overflow scrolling.
  - Send Bambu RFID Tags to Spoolman & Manual Mode Unlink (#719) — Proper RFID identifiers sent to Spoolman, unlink button in manual mode, fixed location clearing for generic spools. Contributed by @shrunbr.
  - SpoolBuddy Daemon Reports Stale Version — Version now read from backend APP_VERSION instead of hardcoded string.
  - Native Install Missing CAP_NET_BIND_SERVICE — Fixed systemd template for VP proxy on native installs.
  - UserEmailPreference Model Not Registered — Fixed SQLAlchemy model import order.

  ## Security

  - Bump pyOpenSSL 25.3.0 → 26.0.0 — Fixes CVE-2026-27448 and CVE-2026-27459.
  - Bump pyasn1 0.6.2 → 0.6.3 — Fixes CVE-2026-30922.
  - Bump flatted 3.4.1 → 3.4.2 — Fixes GHSA-rf6f-7fwh-wjgh (dev dependency).

  ## Contributors

  Thank you to everyone who contributed to this release!

  - @cadtoolbox — Admin default nav-menu order (#761), Per-user email notifications (#693)
  - @Keybored02 — Total cost in Projects (#733), Material mismatch checks (#720), Archive duplicates rework (#718)
  - @mrtncode — Home Assistant notify services (#750)
  - @shrunbr — Bambu RFID tags to Spoolman & manual unlink (#719)

  And thanks to everyone who reported issues and provided feedback!
MartinNYHC 2 months ago
parent
commit
4401f5ab4d
100 changed files with 6162 additions and 480 deletions
  1. 2 2
      .github/workflows/security.yml
  2. 3 0
      .trivyignore
  3. 40 5
      CHANGELOG.md
  4. 3 1
      Dockerfile
  5. 6 5
      README.md
  6. 94 6
      backend/app/api/routes/archives.py
  7. 23 18
      backend/app/api/routes/bug_report.py
  8. 52 0
      backend/app/api/routes/inventory.py
  9. 2 7
      backend/app/api/routes/maintenance.py
  10. 5 0
      backend/app/api/routes/notification_templates.py
  11. 11 0
      backend/app/api/routes/print_queue.py
  12. 29 1
      backend/app/api/routes/printers.py
  13. 5 1
      backend/app/api/routes/projects.py
  14. 16 0
      backend/app/api/routes/settings.py
  15. 73 0
      backend/app/api/routes/spoolbuddy.py
  16. 2 2
      backend/app/api/routes/updates.py
  17. 107 0
      backend/app/api/routes/user_notifications.py
  18. 2 1
      backend/app/api/routes/users.py
  19. 1 1
      backend/app/core/config.py
  20. 61 0
      backend/app/core/database.py
  21. 3 1
      backend/app/core/permissions.py
  22. 365 14
      backend/app/main.py
  23. 2 0
      backend/app/models/__init__.py
  24. 1 1
      backend/app/models/notification.py
  25. 26 1
      backend/app/models/notification_template.py
  26. 1 0
      backend/app/models/printer.py
  27. 2 0
      backend/app/models/spoolbuddy_device.py
  28. 10 0
      backend/app/models/user.py
  29. 37 0
      backend/app/models/user_email_pref.py
  30. 2 0
      backend/app/schemas/archive.py
  31. 1 1
      backend/app/schemas/notification.py
  32. 34 0
      backend/app/schemas/notification_template.py
  33. 4 0
      backend/app/schemas/printer.py
  34. 2 0
      backend/app/schemas/project.py
  35. 41 1
      backend/app/schemas/settings.py
  36. 2 0
      backend/app/schemas/spoolbuddy.py
  37. 24 0
      backend/app/schemas/user_notifications.py
  38. 25 11
      backend/app/services/archive.py
  39. 38 0
      backend/app/services/background_dispatch.py
  40. 28 5
      backend/app/services/bambu_mqtt.py
  41. 66 0
      backend/app/services/email_service.py
  42. 164 10
      backend/app/services/notification_service.py
  43. 21 2
      backend/app/services/print_scheduler.py
  44. 2 1
      backend/app/services/printer_manager.py
  45. 5 0
      backend/app/services/spoolman.py
  46. 22 10
      backend/app/services/virtual_printer/ftp_server.py
  47. 9 0
      backend/app/services/virtual_printer/manager.py
  48. 9 6
      backend/app/services/virtual_printer/mqtt_server.py
  49. 96 27
      backend/app/services/virtual_printer/ssdp_server.py
  50. 614 84
      backend/app/services/virtual_printer/tcp_proxy.py
  51. 1 0
      backend/tests/conftest.py
  52. 144 0
      backend/tests/integration/test_inventory_assign.py
  53. 270 0
      backend/tests/integration/test_spoolbuddy.py
  54. 80 0
      backend/tests/integration/test_user_notifications_api.py
  55. 280 0
      backend/tests/unit/services/test_bambu_mqtt.py
  56. 81 0
      backend/tests/unit/services/test_notification_service.py
  57. 8 4
      backend/tests/unit/services/test_spoolman_service.py
  58. 303 0
      backend/tests/unit/services/test_virtual_printer.py
  59. 130 26
      backend/tests/unit/test_bug_report.py
  60. 2 4
      backend/tests/unit/test_maintenance_rod_filtering.py
  61. 77 0
      backend/tests/unit/test_print_speed.py
  62. 31 0
      backend/tests/unit/test_scheduler_busy_only.py
  63. 123 0
      backend/tests/unit/test_user_notifications.py
  64. 4 2
      docker-compose.yml
  65. 41 38
      docker-publish-daily-beta.sh
  66. BIN
      docs/images/proxy-mode-diagram.png
  67. 15 3
      docs/migration-vp-ftp-port.md
  68. 3 3
      frontend/package-lock.json
  69. 2 0
      frontend/src/App.tsx
  70. 47 14
      frontend/src/__tests__/components/BugReportBubble.test.tsx
  71. 20 1
      frontend/src/__tests__/components/LinkSpoolModal.test.tsx
  72. 61 15
      frontend/src/__tests__/components/PrintModal.test.tsx
  73. 131 0
      frontend/src/__tests__/components/spoolbuddy/AmsUnitCard.test.tsx
  74. 60 0
      frontend/src/__tests__/components/spoolbuddy/SpoolBuddyBottomNav.test.tsx
  75. 89 0
      frontend/src/__tests__/components/spoolbuddy/SpoolBuddyLayout.test.tsx
  76. 63 0
      frontend/src/__tests__/components/spoolbuddy/SpoolBuddyStatusBar.test.tsx
  77. 80 0
      frontend/src/__tests__/components/spoolbuddy/SpoolBuddyTopBar.test.tsx
  78. 56 0
      frontend/src/__tests__/components/spoolbuddy/SpoolIcon.test.tsx
  79. 336 0
      frontend/src/__tests__/hooks/useSpoolBuddyState.test.ts
  80. 136 0
      frontend/src/__tests__/pages/NotificationsPage.test.tsx
  81. 25 0
      frontend/src/__tests__/pages/PrintersPageDrying.test.ts
  82. 321 0
      frontend/src/__tests__/pages/PrintersPageSpeed.test.tsx
  83. 6 3
      frontend/src/__tests__/pages/SettingsPage.test.tsx
  84. 147 0
      frontend/src/__tests__/pages/SpoolBuddyCalibrationPage.test.tsx
  85. 137 0
      frontend/src/__tests__/pages/SpoolBuddyDashboard.test.tsx
  86. 173 0
      frontend/src/__tests__/pages/SpoolBuddySettingsPage.test.tsx
  87. 59 6
      frontend/src/api/client.ts
  88. 3 1
      frontend/src/components/AddNotificationModal.tsx
  89. 188 28
      frontend/src/components/AssignSpoolModal.tsx
  90. 95 28
      frontend/src/components/BugReportBubble.tsx
  91. 1 1
      frontend/src/components/CalendarView.tsx
  92. 9 8
      frontend/src/components/ConfigureAmsSlotModal.tsx
  93. 2 2
      frontend/src/components/ConfirmModal.tsx
  94. 3 2
      frontend/src/components/EmbeddedCameraViewer.tsx
  95. 2 1
      frontend/src/components/FilamentHoverCard.tsx
  96. 1 1
      frontend/src/components/FilamentTrends.tsx
  97. 53 4
      frontend/src/components/Layout.tsx
  98. 5 5
      frontend/src/components/LinkSpoolModal.tsx
  99. 1 1
      frontend/src/components/LocalProfilesView.tsx
  100. 64 54
      frontend/src/components/PrintModal/PlateSelector.tsx

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

@@ -75,7 +75,7 @@ jobs:
         run: docker build -t bambuddy:security-scan .
 
       - name: Run Trivy vulnerability scanner
-        uses: aquasecurity/trivy-action@0.34.0
+        uses: aquasecurity/trivy-action@v0.35.0
         with:
           image-ref: 'bambuddy:security-scan'
           format: 'sarif'
@@ -91,7 +91,7 @@ jobs:
           category: trivy
 
       - name: Run Trivy for Dockerfile/IaC
-        uses: aquasecurity/trivy-action@0.34.0
+        uses: aquasecurity/trivy-action@v0.35.0
         with:
           scan-type: 'config'
           scan-ref: '.'

+ 3 - 0
.trivyignore

@@ -11,3 +11,6 @@ CVE-2026-3184
 CVE-2025-61143
 CVE-2025-61144
 CVE-2025-61145
+
+# iptables --syn flag bypass (LOW, no fix available, not relevant — container doesn't use iptables).
+CVE-2012-2663

+ 40 - 5
CHANGELOG.md

@@ -2,32 +2,66 @@
 
 All notable changes to Bambuddy will be documented in this file.
 
-<<<<<<< HEAD
-=======
-## [0.2.3b1] - Unreleased
+## [0.2.2.1] - 2026-03-22
 
 ### New Features
+- **SpoolBuddy OTA Updates** — SpoolBuddy devices can now be updated directly from the Settings → Updates tab without SSH access. Click "Check for Updates" to see if a newer version is available, then "Apply Update" to trigger the update. The daemon picks up the command via its heartbeat, pulls the latest code from GitHub, installs dependencies, and restarts automatically via systemd. Live progress is shown in the UI with status messages from the device. The status bar at the bottom automatically checks for updates every 5 minutes and shows a prominent message when one is available. Requires the device to be online.
+- **Select Plates to Queue** ([#777](https://github.com/maziggy/bambuddy/issues/777)) — Multi-plate 3MF files now support selecting a subset of plates to queue, instead of only "one plate" or "all plates". In add-to-queue mode, each plate has a checkbox for multi-select, with a "Select All / Deselect All" toggle. Reprint and edit modes remain single-select. Requested by @stringham.
 - **Camera Image Rotation** ([#672](https://github.com/maziggy/bambuddy/issues/672)) — Added per-printer camera rotation (0°, 90°, 180°, 270°) for cameras mounted in portrait or upside-down orientations. Configurable in Settings → Camera for each printer. Rotation applies to live stream, embedded viewer, stream overlay, and notification snapshots. Requested by @wrenoud.
 - **Per-User Email Notifications** ([#693](https://github.com/maziggy/bambuddy/pull/693)) — When Advanced Authentication is enabled, individual users can now receive email notifications for their own print jobs. A new "Notifications" page lets each user toggle notifications for print start, complete, failed, and stopped events. Only prints submitted by that user trigger their email — other users' prints are not affected. Requires SMTP to be configured and the "User Notifications" toggle enabled in Settings → Notifications. Administrators and Operators have access by default; Viewers do not. Contributed by @cadtoolbox.
 
 ### Fixed
+- **SpoolBuddy Daemon Reports Stale Version** — The SpoolBuddy daemon maintained its own hardcoded `__version__` that was never bumped to `0.2.3b1`, causing the update check to incorrectly show an update from `0.2.2b1` to the latest release. Fixed by reading the version at import time from the backend's `APP_VERSION` in `backend/app/core/config.py` — the single source of truth — so the daemon version is always in sync.
+- **SpoolBuddy Update Columns Missing from Database** — The OTA update feature added `update_status` and `update_message` to the device model but was missing the database migration, causing "no such column" errors on existing installations.
+- **Queue Print Command Not Reaching Printer** ([#778](https://github.com/maziggy/bambuddy/issues/778)) — When a queue item targeted a specific printer and the scheduler's power-on-wait loop triggered, each reconnection attempt created a new MQTT client that re-attempted subscribing to the request topic. On printers whose broker rejects this subscription (e.g. A1), this caused repeated connect/disconnect cycles for up to 170 seconds, leaving the MQTT connection in a fragile state where the print command could silently fail to reach the printer. Fixed by caching request topic support state per serial number at the class level, so new client instances skip the subscription immediately instead of rediscovering the rejection. Reported by @RubenKremer.
+- **AMS Slot Search Shows Unrelated Profiles** ([#681](https://github.com/maziggy/bambuddy/issues/681)) — Searching for a non-existent filament profile in the AMS slot configuration showed unrelated profiles instead of an empty result. The saved preset bypassed the search filter entirely, so stale mappings (e.g. a slot previously configured with "Bambu PLA Matte" that now holds a Silk spool) would always appear regardless of the search query. The saved preset now only bypasses the printer model filter, not the search filter. Reported by @RosdasHH.
 - **Virtual Printer FTP Routed to Wrong VP** ([#735](https://github.com/maziggy/bambuddy/issues/735)) — When running multiple virtual printers with different access codes on separate bind IPs, FTP connections were routed to the wrong VP. Root cause: the iptables `REDIRECT` rule rewrites the destination IP to the incoming interface's primary address, so all FTP traffic went to the first VP regardless of the intended target. Fix: FTP server now binds directly to port 990 (standard implicit FTPS), eliminating the need for iptables redirect. Requires `CAP_NET_BIND_SERVICE` (already set in the systemd service and Docker image). Also removed a global `set_exception_handler()` in the MQTT server that caused spurious error messages when running multiple VPs. See `docs/migration-vp-ftp-port.md` for migration steps. Reported by @VREmma.
 - **X1C Virtual Printer Not Accepting Sends** ([#735](https://github.com/maziggy/bambuddy/issues/735)) — X1C (and X1) virtual printers were advertised with legacy SSDP model codes (`3DPrinter-X1-Carbon` / `3DPrinter-X1`) that BambuStudio doesn't recognize, causing "incompatible printer preset" when sending. Fixed to use the correct codes (`BL-P001` / `BL-P002`). Also fixed proxy mode auto-inherit storing the printer's display name (e.g. `X1C`) instead of the SSDP code. Existing VPs are automatically migrated on startup. Reported by @RosdasHH.
 - **White Filament Color Swatches Invisible in Light Theme** ([#726](https://github.com/maziggy/bambuddy/issues/726)) — Filament color circles used a white border that was invisible against light theme backgrounds, making white spools indistinguishable. Changed to a dark border (`border-black/20`) across all views: Inventory, Archives, Assign Spool, Configure AMS Slot, Calendar, Projects, Filament Trends, Local Profiles, Link Spool, and Spoolman Settings. Reported by user.
+- **Camera Window Overlapping Modals** ([#738](https://github.com/maziggy/bambuddy/issues/738)) — Floating camera viewer rendered on top of modals (e.g. Assign Spool), making them unusable. Lowered camera z-index so modals always appear above it. Reported by @maziggy.
+- **Print Complete Notification Not Firing** ([#736](https://github.com/maziggy/bambuddy/issues/736)) — Print complete notifications could silently fail if the finish photo capture hung or timed out, because the notification was chained behind the photo task with no timeout. Added a 45-second timeout so notifications always send even if photo capture stalls. Also added diagnostic logging for MQTT state detection to trace completion triggers. Reported by @piatho.
 - **Webhook Notifications Missing Camera Snapshot** ([#679](https://github.com/maziggy/bambuddy/issues/679)) — Webhook notification providers did not include camera snapshots (e.g. from First Layer Complete notifications), even though providers like Telegram, Pushover, ntfy, and Discord already attached them. The webhook payload now includes a base64-encoded `image` field when a snapshot is available (generic format only, not Slack format). Reported by @Arn0uDz.
 - **Mobile Sidebar Not Scrollable** — On mobile devices with many navigation items, the sidebar did not scroll, making bottom items unreachable. Added overflow scrolling to the nav section while keeping the logo and footer pinned.
 - **User Notification Ruff/Lint Fixes** ([#693](https://github.com/maziggy/bambuddy/pull/693)) — Fixed missing `timezone` import in email timestamp, unused lambda argument, PEP 8 blank line spacing for `mark_printer_stopped_by_user`, and SQLAlchemy forward reference in `UserEmailPreference` model.
+- **Carbon Rod Lubrication Maintenance Task Incorrect** ([#755](https://github.com/maziggy/bambuddy/issues/755)) — X1/P1 series printers showed a "Lubricate Carbon Rods" maintenance task, but carbon rods use plain bearings and should never be lubricated — doing so degrades print quality. Removed the lubrication task; only "Clean Carbon Rods" remains. Existing "Lubricate Carbon Rods" entries are automatically removed on next startup. Reported by @RosdasHH.
+- **Ntfy Notifications Fail With Non-ASCII Characters** ([#742](https://github.com/maziggy/bambuddy/issues/742)) — Ntfy notifications with camera snapshots failed when the printer name or filename contained non-ASCII characters (e.g. accented letters, CJK). The `Title` and `Message` HTTP headers were passed as Python strings, causing httpx to reject them with `UnicodeEncodeError`. Fixed by encoding header values as UTF-8 bytes, which ntfy handles correctly. Test notifications were unaffected because they use a hardcoded ASCII title and no image attachment. Reported by @user.
+- **Virtual Printer Proxy Mode Printing Fails on Isolated Networks** ([#757](https://github.com/maziggy/bambuddy/issues/757)) — When the slicer and printer are on different VLANs/subnets, Bambu Studio could not send prints through the virtual printer proxy because: (1) the printer's real IP leaked through MQTT payloads (`rtsp_url`, `net.info[].ip`), causing BS to bypass the proxy; (2) the bind/detect protocol (port 3000/3002) was forwarded to the real printer, leaking its identity and name; (3) the file transfer tunnel (port 6000) used by BS for verify_job and uploads was not proxied; (4) FTP data connections for zero-byte uploads (verify_job) failed due to a TLS handshake race condition. Fixed by: rewriting IP addresses in MQTT PUBLISH payloads (both string and integer formats) with proper MQTT framing preservation, responding to bind/detect with the VP's own identity via BindServer, adding transparent TCP proxies for port 6000 (file transfer) and port 322 (RTSP camera), buffering slicer data during FTP data proxy connection setup, and advertising the configured VP name in SSDP. Also added cross-subnet SSDP support via a wildcard listener for VPN/multi-subnet setups. Reported by @Utility9298.
+- **Virtual Printer Proxy Mode X1C/X1 Print Upload Fails** ([#757](https://github.com/maziggy/bambuddy/issues/757)) — X1C and X1 printers failed to upload prints through proxy mode. After FTP verify_job succeeded (226), BambuStudio's closed-source `bambu_networking` DLL silently refused to proceed with the actual 3MF upload, showing a login modal instead. Root cause: the DLL validates the TLS connection parameters and rejects connections where the certificate doesn't match the printer's real BBL CA certificate. The TLS-terminating proxy presented Bambuddy's own "Virtual Printer CA" certificate, which the DLL rejected. Fixed by switching to transparent TCP proxying for FTP (port 990), FileTransfer (port 6000), Camera (port 322), and FTP passive data (ports 50000–50100) — raw bytes are forwarded without TLS termination, so the slicer gets end-to-end TLS directly with the printer's real certificate. Only MQTT (port 8883) remains TLS-terminated, which is required to rewrite the printer's real IP with the proxy's bind IP in MQTT payloads. Confirmed working on both H2D and X1C printers.
+- **UserEmailPreference Model Not Registered** — The `UserEmailPreference` SQLAlchemy model was not imported in `models/__init__.py`, causing mapper initialization failures when the `User` model's relationship resolved the string reference before the model class was registered with Base metadata.
+- **Native Install Missing CAP_NET_BIND_SERVICE** — The `install.sh` systemd service template was missing `AmbientCapabilities=CAP_NET_BIND_SERVICE`, causing Virtual Printer proxy mode to silently fail to bind privileged ports (322, 990) on native installations.
+- **Virtual Printer Proxy A1 Diagnostics** ([#757](https://github.com/maziggy/bambuddy/issues/757)) — Added diagnostic port probing (ports 21, 80, 443) on proxy VP bind IPs to detect if BambuStudio tries to connect on ports the proxy doesn't handle. Logs a warning when an unexpected connection is detected. Helps diagnose A1/A1 Mini proxy issues where the slicer may use a different connection flow.
+- **File Rename Removes Extension** ([#751](https://github.com/maziggy/bambuddy/issues/751)) — Renaming a file in the File Manager included the file extension in the editable text, so users could accidentally remove it (e.g. renaming `bracket.gcode.3mf` to `bracket`), making the file unprintable. The rename modal now only lets users edit the base name, with the extension shown as a non-editable suffix. Reported by @fleishmaab, confirmed by @cadtoolbox.
+- **Spurious "Job Waiting for Filament" Notification** ([#753](https://github.com/maziggy/bambuddy/issues/753)) — When all printers of a model were busy and a job was queued with ASAP timing, a "Job Waiting for Filament" notification fired immediately even though no filament issue existed. The job was simply waiting for a printer to finish. The scheduler now skips the waiting notification when all matching printers are just busy, since the job will auto-start when one finishes. Also renamed the default notification title from "Job Waiting for Filament" to "Queue Job Waiting" to accurately reflect all waiting reasons. Reported by @maziggy.
+- **AMS Spools Removed After Printer Restart** ([#765](https://github.com/maziggy/bambuddy/issues/765)) — AMS spool assignments and slot configurations were lost after restarting the printer. When the printer shuts down, it sends a final MQTT message with `tray_exist_bits=0` and `power_on_flag=false`, which caused Bambuddy to clear all AMS slot data and auto-unlink every spool assignment. On reconnect, the assignments were gone. Fixed by skipping `tray_exist_bits` slot clearing when `power_on_flag` is `false` (shutdown message), preserving AMS data across printer restarts. Reported by @Woyteck1.
+
+### Community Contributions
+- **Admin Set Default Nav-Menu Order** ([#761](https://github.com/maziggy/bambuddy/pull/761)) — Admins with authentication enabled can now set their current sidebar menu order as the default for new users. New users inherit this layout on first login and can customize it afterward. Contributed by @cadtoolbox.
+- **Improve Home Assistant Notifications** ([#750](https://github.com/maziggy/bambuddy/pull/750)) — Added support for Home Assistant `notify` services in addition to the existing REST-based integration. Contributed by @mrtncode.
+- **Add Total Cost to Projects** ([#733](https://github.com/maziggy/bambuddy/pull/733)) — The Projects page now shows a total cost that sums material, energy, and BOM costs. Contributed by @Keybored02.
+- **Material Mismatch & Insufficient Filament Checks** ([#720](https://github.com/maziggy/bambuddy/pull/720)) — When assigning non-Bambu Lab spools, a warning prompts if the filament type or profile doesn't match. Pre-print checks now also warn when the spool has insufficient material. Both warnings are dismissible, with a toggle in Settings. Contributed by @Keybored02.
+- **Send Bambu RFID Tags to Spoolman & Manual Mode Unlink** ([#719](https://github.com/maziggy/bambuddy/pull/719)) — Bambu Lab spool RFID identifiers (tray UUID) are now sent to Spoolman instead of generic placeholder tags. An "Unlink" button appears on Bambu spools when Spoolman is in manual sync mode. Fixed location clearing for generic spools during sync. Contributed by @shrunbr.
+- **Rework Archive Duplicates Tagging** ([#718](https://github.com/maziggy/bambuddy/pull/718)) — Duplicate detection now requires both matching filename and SHA256 hash. The tag shows reprint count instead of "Duplicate" text, links back to the parent print, and a new "Hide Duplicates" filter is available. Contributed by @Keybored02.
+
+### Added
+- **Quick Print Speed Control** ([#256](https://github.com/maziggy/bambuddy/issues/256)) — Added a print speed control badge to the printer card controls row, next to the fan status badges. Click to choose between Silent (50%), Standard (100%), Sport (124%), and Ludicrous (166%) speed presets. The badge shows the current speed percentage with a gauge icon, always visible but disabled when no print is active. Includes optimistic UI updates for instant feedback. Requested by @Sllepper.
+- **Spool Rotation During AMS Drying** — Added a "Rotate spool during drying" checkbox to the manual drying popover for AMS 2 Pro and AMS-HT units. Rotates the spool for more even heat distribution. Off by default; resets when opening the popover for a different AMS unit. The firmware silently disables rotation if filament is currently loaded from the unit.
+- **Spool Name Column & Filter in Filament Inventory** ([#740](https://github.com/maziggy/bambuddy/issues/740)) — Added a "Spool" column to the filament inventory table that displays the spool catalog entry name (e.g. "Bambu Lab AMS Tray", "Sunlu 1kg"). Enable it via the column visibility menu. Sortable and hidden by default. Also added a spool name filter dropdown next to the brand filter for quick filtering by spool type. Requested by @DMoenning.
 
 ### Changed
+- **Redesigned Bug Report Debug Log Flow** — Replaced the fixed 30-second debug log collection with an interactive 3-step flow: start debug logging, reproduce the issue at your own pace, then stop & submit. An elapsed timer shows recording duration with auto-stop at 5 minutes. Users now have full control over when to capture logs instead of racing a countdown. The backend splits log collection into separate start/stop endpoints, and the frontend shows a step progress indicator with pulsing active state.
 
 ### Improved
+- **HMS Error Visibility on Printers Page** ([#772](https://github.com/maziggy/bambuddy/issues/772)) — Improved visibility of printers with HMS errors for large print farms. Added a red "Problem" counter to the status summary bar showing how many connected printers have active HMS errors. The compact-mode status pip (colored dot) now turns red for fatal/serious errors (severity ≤ 2) or amber for common warnings, instead of only showing connection status. Progress bars turn amber when a print is paused. Sorting by status now places printers with HMS errors at the top, above printing and idle printers. Requested by @jimmy-brightz.
+- **Print Command Response Verification** ([#737](https://github.com/maziggy/bambuddy/issues/737)) — After sending a print command, BambuBuddy now monitors whether the printer's state changes within 15 seconds. If the printer silently ignores the command (observed on some P1S firmware versions where the MQTT command handler becomes unresponsive), a warning is logged for diagnostics. This aids debugging when users report prints not starting despite BambuBuddy showing success.
 - **Compact Assign Spool Modal** ([#725](https://github.com/maziggy/bambuddy/issues/725)) — The "Assign Spool" modal now uses a compact 3-column grid layout instead of a vertical list, showing more spools at once without scrolling. Each card displays the spool name, color, and remaining/total weight. The modal is wider with a taller scroll area. Requested by @RosdasHH.
 - **Reformatted AMS Drying Presets Table** ([#732](https://github.com/maziggy/bambuddy/issues/732)) — The drying presets table in Settings now groups columns by AMS type (AMS 2 Pro, AMS-HT) with inline °C and h unit labels next to each input, replacing the previous flat column layout. Requested by @cadtoolbox.
 
 ### Security
+- **Bump pyOpenSSL 25.3.0 → 26.0.0** — Fixes CVE-2026-27448 (exception swallowing in TLS servername callback) and CVE-2026-27459 (buffer overflow in DTLS cookie callback).
+- **Bump pyasn1 0.6.2 → 0.6.3** — Fixes CVE-2026-30922 (stack overflow from deeply nested ASN.1 structures).
+- **Bump flatted 3.4.1 → 3.4.2** — Fixes GHSA-rf6f-7fwh-wjgh (prototype pollution via `parse()`). Dev-only dependency (eslint).
 
 
->>>>>>> 4f617c21 (  [Fix] X1C Virtual Printer not accepting sends (#735))
 ## [0.2.2] - 2026-03-16
 
 ### New Features
@@ -77,7 +111,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **HMS Notifications for Unknown/Phantom Error Codes** — Printers send many undocumented or phantom HMS error codes that don't correspond to real errors (e.g. calibration status codes after firmware updates). These triggered email/push notifications even though the printer card correctly filtered them out. Flipped the notification logic from "notify all, suppress specific codes" to "only notify for errors with known descriptions", matching the frontend behavior. Also fixed the log message reporting incorrect notification counts.
 - **Ethernet Badge Shown on WiFi Printers / MQTT Disconnecting** ([#585](https://github.com/maziggy/bambuddy/issues/585)) — Three bugs in the ethernet badge feature: (1) `home_flag` bit 18 is set on all printers regardless of connection type, so every ethernet-capable model showed the ethernet badge even when connected via WiFi. Replaced bit 18 detection with wifi_signal-based heuristic: printers on ethernet with WiFi disabled report a hardcoded `-90 dBm` sentinel, while real WiFi signals vary. (2) The lazy import used `from app.utils.printer_models` which crashes with `ModuleNotFoundError` in paho-mqtt's background thread (correct path is `backend.app.utils.printer_models`). This killed the MQTT thread entirely, causing all printers to go stale after 60s and repeatedly disconnect/reconnect. (3) WiFi-only models (A1, P1P, etc.) that don't have an ethernet port are excluded via model-based gating. Reported by @cadtoolbox.
 - **Inventory Usage Tracker Missing External Spool Mapping** ([#677](https://github.com/maziggy/bambuddy/issues/677)) — When all higher-priority slot-to-tray mapping methods failed (MQTT mapping, print command mapping, queue mapping, color matching), the internal inventory usage tracker fell back to `slot_id - 1` which can never reach external spool IDs (254/255) or AMS-HT IDs (128+). Added position-based resolution using sorted available tray IDs from the printer's AMS state, matching the fix applied to Spoolman tracking in #686. Contributed by @shrunbr.
-- **Spool Assignment Applies Wrong Filament Profile** ([#681](https://github.com/maziggy/bambuddy/issues/681)) — Assigning a spool with a specific filament variant (e.g. "Generic PLA Silk") to an AMS slot applied the base profile instead (e.g. "Generic PLA"). The Bambu Cloud API returns only the base `filament_id` for versioned setting IDs (`GFSL99` → `GFL99`), ignoring variant suffixes (`GFSL99_01`). Added a cross-check that compares the resolved filament name against the spool's stored preset name and corrects the filament ID via reverse lookup when they don't match (e.g. `GFL99` → `GFL96` for "Generic PLA Silk"). Reported by @peter-k-de.
+- **Spool Assignment Applies Wrong Filament Profile** ([#681](https://github.com/maziggy/bambuddy/issues/681)) — Assigning a spool with a specific filament variant (e.g. "Generic PLA Silk") to an AMS slot applied the base profile instead (e.g. "Generic PLA"). The Bambu Cloud API returns only the base `filament_id` for versioned setting IDs (`GFSL99` → `GFL99`), ignoring variant suffixes (`GFSL99_01`). Added a cross-check that compares the resolved filament name against the spool's stored preset name and corrects the filament ID via reverse lookup when they don't match (e.g. `GFL99` → `GFL96` for "Generic PLA Silk"). Also fixed the UI showing a stale preset name (e.g. "Bambu PLA Matte" instead of "Bambu PLA Silk") after assignment — the slot preset mapping was only saved when assigning via SpoolBuddy, not via the PrintersPage hover card. The backend now saves the slot preset mapping using the spool's authoritative `slicer_filament_name` after every successful MQTT configuration, regardless of which UI path triggered the assignment. Reported by @peter-k-de, @RosdasHH.
 - **Debug Logging Endpoint 500 Error** — The `GET /api/v1/support/debug-logging` endpoint returned a 500 Internal Server Error when the database contained a timezone-aware timestamp written by a previous version. The duration calculation subtracted a timezone-aware datetime from a naive `datetime.now()`, raising `TypeError`. Now strips timezone info when reading the stored timestamp.
 - **Bed Cooled Notification Never Fires** ([#497](https://github.com/maziggy/bambuddy/issues/497)) — The bed cooldown monitor always timed out after 30 minutes without sending a notification. After print completion, P1S (and likely other models) sends partial MQTT status updates that don't include `bed_temper`, so the cached bed temperature stayed frozen at the end-of-print value and never dropped below the threshold. The monitor now sends periodic `pushall` commands to the printer to force fresh temperature data. Also added debug logging to the polling loop for future diagnostics.
 - **Notification Provider Missing Event Toggles on Create** ([#497](https://github.com/maziggy/bambuddy/issues/497)) — When creating a new notification provider, the `on_bed_cooled` toggle and all 7 queue event toggles (`on_queue_job_added`, `on_queue_job_assigned`, `on_queue_job_started`, `on_queue_job_waiting`, `on_queue_job_skipped`, `on_queue_job_failed`, `on_queue_completed`) were silently discarded. The create endpoint manually listed each field but omitted these 8 toggles, so they always defaulted to `false` regardless of user selection. Editing an existing provider worked correctly.
@@ -190,6 +224,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Prometheus Build Info Metric** ([#633](https://github.com/maziggy/bambuddy/pull/633)) — Added a `bambuddy_build_info` gauge metric to the Prometheus metrics endpoint, exposing the application version, Python version, platform, and architecture as labels. Follows the standard Prometheus `_build_info` convention for dashboards and version-change alerting. Contributed by @sw1nn.
 
 ### Fixed
+- **Beta Updates Shown When Disabled** ([#731](https://github.com/maziggy/bambuddy/issues/731)) — Daily beta builds (e.g. `v0.2.3b1-daily.20260316`) were offered as updates even with "Include beta versions" toggled off. The version parser only checked the last dot-separated segment for prerelease markers, but daily build tags put the beta indicator (`b1`) earlier with a numeric date suffix as the last segment. Now checks the entire version string. Reported by @Teolhyn.
 - **Debug Logging Endpoint 500 Error** — The `GET /api/v1/support/debug-logging` endpoint returned a 500 Internal Server Error when the database contained a timezone-aware timestamp written by a previous version. The duration calculation subtracted a timezone-aware datetime from a naive `datetime.now()`, raising `TypeError`. Now strips timezone info when reading the stored timestamp.
 - **Bed Cooled Notification Never Fires** ([#497](https://github.com/maziggy/bambuddy/issues/497)) — The bed cooldown monitor always timed out after 30 minutes without sending a notification. After print completion, P1S (and likely other models) sends partial MQTT status updates that don't include `bed_temper`, so the cached bed temperature stayed frozen at the end-of-print value and never dropped below the threshold. The monitor now sends periodic `pushall` commands to the printer to force fresh temperature data. Also added debug logging to the polling loop for future diagnostics.
 - **Notification Provider Missing Event Toggles on Create** ([#497](https://github.com/maziggy/bambuddy/issues/497)) — When creating a new notification provider, the `on_bed_cooled` toggle and all 7 queue event toggles (`on_queue_job_added`, `on_queue_job_assigned`, `on_queue_job_started`, `on_queue_job_waiting`, `on_queue_job_skipped`, `on_queue_job_failed`, `on_queue_completed`) were silently discarded. The create endpoint manually listed each field but omitted these 8 toggles, so they always defaulted to `false` regardless of user selection. Editing an existing provider worked correctly.

+ 3 - 1
Dockerfile

@@ -53,11 +53,13 @@ ENV DATA_DIR=/app/data
 ENV LOG_DIR=/app/logs
 ENV PORT=8000
 
+EXPOSE 322
+EXPOSE 990
 EXPOSE 3000
 EXPOSE 3002
+EXPOSE 6000
 EXPOSE 8000
 EXPOSE 8883
-EXPOSE 990
 EXPOSE 50000-50100
 
 # Health check (uses PORT env var via shell)

+ 6 - 5
README.md

@@ -39,11 +39,11 @@
 
 **Print from anywhere in the world** — Bambuddy's new Proxy Mode acts as a secure relay between your slicer and printer:
 
-- 🔒 **TLS-encrypted control channels** — MQTT and FTP control fully encrypted
+- 🔒 **End-to-end TLS encryption** — FTP, file transfer, and camera are transparently proxied with the printer's real TLS certificate
 - 🛡️ **VPN recommended** — Use Tailscale/WireGuard for full data encryption ([details](https://wiki.bambuddy.cool/features/virtual-printer/))
 - 🌍 **No cloud dependency** — Direct connection through your own Bambuddy server
 - 🔑 **Uses printer's access code** — No additional credentials needed
-- ⚡ **Full-speed printing** — FTP and MQTT protocols proxied transparently
+- ⚡ **Full-speed printing** — Transparent TCP proxy, only MQTT is decrypted for IP rewriting
 
 Perfect for remote print farms, traveling makers, or accessing your home printer from work.
 
@@ -85,13 +85,13 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - External camera support (MJPEG, RTSP, HTTP snapshot, USB/V4L2) with layer-based timelapse
 - **Build plate empty detection** - Auto-pause print if objects detected on plate (multi-reference calibration, ROI adjustment)
 - Fan status monitoring (part cooling, auxiliary, chamber)
-- Printer control (stop, pause, resume, chamber light)
+- Printer control (stop, pause, resume, chamber light, print speed)
 - Resizable printer cards (S/M/L/XL)
 - Skip objects during print
 - AMS slot RFID re-read
 - AMS slot configuration (model-filtered presets, K profiles, color picker, pre-population for configured slots)
 - AMS info card (hover for serial number, firmware version) with custom friendly names that persist across printers
-- **AMS remote drying** — Start, monitor, and stop drying sessions for AMS 2 Pro and AMS-HT directly from the Printers page with filament-based temperature/duration presets; automatic PSU detection and HMS power error reporting
+- **AMS remote drying** — Start, monitor, and stop drying sessions for AMS 2 Pro and AMS-HT directly from the Printers page with filament-based temperature/duration presets, optional spool rotation; automatic PSU detection and HMS power error reporting
 - **Queue auto-drying** — Automatically dry filament between scheduled prints when humidity exceeds threshold; configurable presets per filament type, optional blocking mode
 - **Ambient drying** — Automatically keep filament dry on idle printers based on humidity, regardless of whether prints are queued
 - Configurable drying presets per filament type (temperature & duration for AMS 2 Pro and AMS-HT)
@@ -198,7 +198,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Debug logging toggle with live indicator
 - Live application log viewer with filtering
 - Support bundle generator with comprehensive diagnostics (privacy-filtered)
-- **In-app bug reporting** — Submit bug reports directly from the UI with optional screenshot (upload, paste, or drag & drop), automatic diagnostic log collection (30s debug capture with printer push), and system info. Reports create GitHub issues via a secure relay. Privacy-first: all logs are sanitized and sensitive data (IPs, serials, credentials) is never included.
+- **In-app bug reporting** — Submit bug reports directly from the UI with optional screenshot (upload, paste, or drag & drop), interactive debug log capture (start logging, reproduce at your own pace, stop & submit), and system info. Reports create GitHub issues via a secure relay. Privacy-first: all logs are sanitized and sensitive data (IPs, serials, credentials) is never included.
 
 ### 🔒 Optional Authentication
 - Enable/disable authentication any time
@@ -213,6 +213,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Admin creates users with email — system sends secure random password automatically
 - Users can reset their own password from the login screen (no admin needed)
 - Customizable email templates (welcome email, password reset)
+- **Per-user email notifications** — Users receive email alerts for their own print jobs (start, complete, failed, stopped) with individual toggle controls
 
 </td>
 </tr>

+ 94 - 6
backend/app/api/routes/archives.py

@@ -2,13 +2,14 @@ import io
 import json
 import logging
 import zipfile
+from collections import defaultdict
 from datetime import date, datetime, time, 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 func, select
+from sqlalchemy import and_, func, or_, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import (
@@ -62,6 +63,8 @@ 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,
 ) -> dict:
     """Convert archive model to response dict with computed fields."""
     data = {
@@ -79,6 +82,8 @@ def archive_to_response(
         "f3d_path": archive.f3d_path,
         "duplicates": duplicates,
         "duplicate_count": duplicate_count if duplicates is None else len(duplicates),
+        "duplicate_sequence": duplicate_sequence,
+        "original_archive_id": original_archive_id,
         "print_name": archive.print_name,
         "print_time_seconds": archive.print_time_seconds,
         "filament_used_grams": archive.filament_used_grams,
@@ -141,16 +146,99 @@ async def list_archives(
         offset=offset,
     )
 
-    # Get sets of hashes and names that have duplicates (efficient single queries)
-    duplicate_hashes, duplicate_names = await service.get_duplicate_hashes_and_names()
+    # Get sets of duplicate hashes and duplicate (name, hash) pairs (efficient single queries)
+    duplicate_hashes, duplicate_name_hash_pairs = await service.get_duplicate_hashes_and_names()
 
-    # Mark archives that have duplicates (by hash or by print name)
+    # Batch-load duplicate groups once for the current page keys.
+    duplicate_hashes_in_page = {
+        a.content_hash for a in archives if a.content_hash and a.content_hash in duplicate_hashes
+    }
+    duplicate_name_hash_keys_in_page = {
+        (a.print_name.lower(), a.content_hash)
+        for a in archives
+        if a.print_name and a.content_hash and (a.print_name.lower(), a.content_hash) in duplicate_name_hash_pairs
+    }
+
+    duplicate_meta_by_archive_id: dict[int, tuple[int, int, int]] = {}
+
+    if duplicate_hashes_in_page or duplicate_name_hash_keys_in_page:
+        duplicate_group_conditions = []
+        if duplicate_hashes_in_page:
+            duplicate_group_conditions.append(PrintArchive.content_hash.in_(duplicate_hashes_in_page))
+        if duplicate_name_hash_keys_in_page:
+            name_hash_conditions = [
+                and_(func.lower(PrintArchive.print_name) == name, PrintArchive.content_hash == hash_)
+                for name, hash_ in duplicate_name_hash_keys_in_page
+            ]
+            duplicate_group_conditions.extend(name_hash_conditions)
+
+        duplicate_group_rows = await db.execute(
+            select(
+                PrintArchive.id,
+                PrintArchive.created_at,
+                PrintArchive.content_hash,
+                func.lower(PrintArchive.print_name).label("print_name_lower"),
+            ).where(or_(*duplicate_group_conditions))
+        )
+
+        duplicate_groups_by_hash: dict[str, list[tuple[int, datetime]]] = defaultdict(list)
+        duplicate_groups_by_name_hash: dict[tuple[str, str], list[tuple[int, datetime]]] = defaultdict(list)
+
+        for archive_id, created_at, content_hash, print_name_lower in duplicate_group_rows.all():
+            if content_hash and content_hash in duplicate_hashes_in_page:
+                duplicate_groups_by_hash[content_hash].append((archive_id, created_at))
+            if (
+                print_name_lower
+                and content_hash
+                and (print_name_lower, content_hash) in duplicate_name_hash_keys_in_page
+            ):
+                duplicate_groups_by_name_hash[(print_name_lower, content_hash)].append((archive_id, created_at))
+
+        for group in duplicate_groups_by_hash.values():
+            if len(group) < 2:
+                continue
+            group.sort(key=lambda x: x[1])
+            original_id = group[0][0]
+            duplicate_count = len(group) - 1
+            for sequence, (archive_id, _) in enumerate(group):
+                duplicate_meta_by_archive_id[archive_id] = (sequence, original_id, duplicate_count)
+
+        # Keep hash-based grouping precedence; name/hash groups only fill missing items.
+        for group in duplicate_groups_by_name_hash.values():
+            if len(group) < 2:
+                continue
+            group.sort(key=lambda x: x[1])
+            original_id = group[0][0]
+            duplicate_count = len(group) - 1
+            for sequence, (archive_id, _) in enumerate(group):
+                duplicate_meta_by_archive_id.setdefault(archive_id, (sequence, original_id, duplicate_count))
+
+    # Build response with duplicate sequence and original archive ID pre-computed
     result = []
     for a in archives:
         has_hash_dup = a.content_hash in duplicate_hashes if a.content_hash else False
-        has_name_dup = a.print_name and a.print_name.lower() in duplicate_names
+        has_name_dup = (
+            bool(a.print_name and a.content_hash)
+            and (a.print_name.lower(), a.content_hash) in duplicate_name_hash_pairs
+        )
         has_duplicate = has_hash_dup or has_name_dup
-        result.append(archive_to_response(a, duplicate_count=1 if has_duplicate else 0))
+
+        # Pre-compute duplicate sequence and original archive ID
+        duplicate_sequence = 0
+        original_archive_id: int | None = None
+        duplicate_count = 1 if has_duplicate else 0
+
+        if has_duplicate and a.id in duplicate_meta_by_archive_id:
+            duplicate_sequence, original_archive_id, duplicate_count = duplicate_meta_by_archive_id[a.id]
+
+        result.append(
+            archive_to_response(
+                a,
+                duplicate_count=duplicate_count,
+                duplicate_sequence=duplicate_sequence,
+                original_archive_id=original_archive_id,
+            )
+        )
     return result
 
 

+ 23 - 18
backend/app/api/routes/bug_report.py

@@ -1,9 +1,8 @@
 """Bug report endpoint for submitting user bug reports to GitHub."""
 
-import asyncio
 import logging
 
-from fastapi import APIRouter
+from fastapi import APIRouter, Query
 from pydantic import BaseModel
 
 from backend.app.api.routes.support import (
@@ -20,14 +19,13 @@ from backend.app.services.printer_manager import printer_manager
 router = APIRouter(prefix="/bug-report", tags=["bug-report"])
 logger = logging.getLogger(__name__)
 
-LOG_COLLECTION_SECONDS = 30
-
 
 class BugReportRequest(BaseModel):
     description: str
     email: str | None = None
     screenshot_base64: str | None = None
     include_support_info: bool = True
+    debug_logs: str | None = None
 
 
 class BugReportResponse(BaseModel):
@@ -37,40 +35,48 @@ class BugReportResponse(BaseModel):
     issue_number: int | None = None
 
 
-async def _collect_debug_logs() -> str:
-    """Enable debug logging, push all printers, wait, then collect logs."""
-    # Check if debug was already enabled
+class StartLoggingResponse(BaseModel):
+    started: bool
+    was_debug: bool
+
+
+class StopLoggingResponse(BaseModel):
+    logs: str
+
+
+@router.post("/start-logging", response_model=StartLoggingResponse)
+async def start_logging():
+    """Enable debug logging and push all printers for fresh data."""
     async with async_session() as db:
         was_debug, _ = await _get_debug_setting(db)
 
-    # Enable debug logging
     if not was_debug:
         async with async_session() as db:
             await _set_debug_setting(db, True)
         _apply_log_level(True)
-        logger.info("Bug report: temporarily enabled debug logging")
+        logger.info("Bug report: enabled debug logging")
 
-    # Send push_all to all connected printers
     for printer_id in list(printer_manager._clients.keys()):
         try:
             printer_manager.request_status_update(printer_id)
         except Exception:
             logger.debug("Failed to push_all for printer %s", printer_id)
 
-    # Wait for logs to accumulate
-    await asyncio.sleep(LOG_COLLECTION_SECONDS)
+    return StartLoggingResponse(started=True, was_debug=was_debug)
+
 
-    # Collect logs
+@router.post("/stop-logging", response_model=StopLoggingResponse)
+async def stop_logging(was_debug: bool = Query(default=False)):
+    """Collect logs and restore previous log level."""
     logs = await _get_recent_sanitized_logs()
 
-    # Restore previous log level if it wasn't debug before
     if not was_debug:
         async with async_session() as db:
             await _set_debug_setting(db, False)
         _apply_log_level(False)
         logger.info("Bug report: restored normal logging")
 
-    return logs
+    return StopLoggingResponse(logs=logs)
 
 
 @router.post("/submit", response_model=BugReportResponse)
@@ -80,9 +86,8 @@ async def submit_bug_report(report: BugReportRequest):
     if report.include_support_info:
         try:
             support_info = await _collect_support_info()
-            logs = await _collect_debug_logs()
-            if logs:
-                support_info["recent_logs"] = logs
+            if report.debug_logs:
+                support_info["recent_logs"] = report.debug_logs
         except Exception:
             logger.exception("Failed to collect support info for bug report")
 

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

@@ -1032,6 +1032,58 @@ async def assign_spool(
                 spool.id,
                 data.printer_id,
             )
+
+            # Save slot preset mapping so the UI shows the correct preset name.
+            # Use slicer_filament_name (authoritative) with fallback to tray_sub_brands.
+            try:
+                from backend.app.models.slot_preset import SlotPresetMapping
+
+                preset_name = spool.slicer_filament_name or tray_sub_brands or tray_type
+                preset_source = "cloud"
+                if sf:
+                    base_sf_mapping = sf.split("_")[0] if "_" in sf else sf
+                    try:
+                        local_id = int(base_sf_mapping)
+                        preset_id_to_save = f"local_{local_id}"
+                        preset_source = "local"
+                    except (ValueError, TypeError):
+                        # Cloud or builtin preset — convert filament_id to setting_id
+                        preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else setting_id
+                else:
+                    preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else ""
+
+                if preset_id_to_save:
+                    existing_mapping = await db.execute(
+                        select(SlotPresetMapping).where(
+                            SlotPresetMapping.printer_id == data.printer_id,
+                            SlotPresetMapping.ams_id == data.ams_id,
+                            SlotPresetMapping.tray_id == data.tray_id,
+                        )
+                    )
+                    mapping = existing_mapping.scalar_one_or_none()
+                    if mapping:
+                        mapping.preset_id = preset_id_to_save
+                        mapping.preset_name = preset_name
+                        mapping.preset_source = preset_source
+                    else:
+                        mapping = SlotPresetMapping(
+                            printer_id=data.printer_id,
+                            ams_id=data.ams_id,
+                            tray_id=data.tray_id,
+                            preset_id=preset_id_to_save,
+                            preset_name=preset_name,
+                            preset_source=preset_source,
+                        )
+                        db.add(mapping)
+                    await db.commit()
+                    logger.info(
+                        "Saved slot preset mapping: preset_id=%r, preset_name=%r",
+                        preset_id_to_save,
+                        preset_name,
+                    )
+            except Exception as e:
+                logger.warning("Failed to save slot preset mapping: %s", e)
+
     except Exception as e:
         logger.warning("MQTT auto-configure failed for spool %d: %s", spool.id, e)
 

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

@@ -35,12 +35,8 @@ router = APIRouter(prefix="/maintenance", tags=["maintenance"])
 # Default maintenance types
 DEFAULT_MAINTENANCE_TYPES = [
     # Carbon rod models only (X1/P1)
-    {
-        "name": "Lubricate Carbon Rods",
-        "description": "Apply lubricant to carbon rods for smooth motion",
-        "default_interval_hours": 50.0,
-        "icon": "Droplet",
-    },
+    # Note: carbon rods must NOT be lubricated — they use plain bearings
+    # and lubrication degrades print quality. Only cleaning is offered.
     {
         "name": "Clean Carbon Rods",
         "description": "Wipe carbon rods with a dry cloth",
@@ -104,7 +100,6 @@ DEFAULT_MAINTENANCE_TYPES = [
 # "carbon" = X1/P1 series (carbon rods), "steel_rod" = P2S (steel rods),
 # "linear_rail" = A1/H2 series. Types not listed here apply to all printers.
 _ROD_TYPE_REQUIREMENTS: dict[str, str] = {
-    "Lubricate Carbon Rods": "carbon",
     "Clean Carbon Rods": "carbon",
     "Lubricate Steel Rods": "steel_rod",
     "Clean Steel Rods": "steel_rod",

+ 5 - 0
backend/app/api/routes/notification_templates.py

@@ -46,6 +46,11 @@ EVENT_NAMES = {
     # User management
     "user_created": "Welcome Email",
     "password_reset": "Password Reset",
+    # User email print notifications
+    "user_print_start": "User Print Started Email",
+    "user_print_complete": "User Print Completed Email",
+    "user_print_failed": "User Print Failed Email",
+    "user_print_stopped": "User Print Stopped Email",
 }
 
 

+ 11 - 0
backend/app/api/routes/print_queue.py

@@ -783,6 +783,17 @@ async def stop_queue_item(
     except Exception as e:
         logger.error("Error sending stop command for queue item %s: %s", item_id, e)
 
+    # Mark this printer as user-stopped BEFORE the first await so that if the
+    # MQTT on_print_complete callback fires during the db.commit() yield the flag
+    # is already set and the "failed" status will be correctly overridden to
+    # "cancelled" (preventing a spurious "print failed" notification).
+    try:
+        from backend.app.main import mark_printer_stopped_by_user
+
+        mark_printer_stopped_by_user(printer_id)
+    except Exception as _mark_err:
+        logger.warning("Failed to mark printer %s as user-stopped: %s", printer_id, _mark_err)
+
     # Update queue item status regardless - if printer is off, print is already stopped
     item.status = "cancelled"
     item.completed_at = datetime.now(timezone.utc)

+ 29 - 1
backend/app/api/routes/printers.py

@@ -1470,6 +1470,7 @@ async def start_drying(
     temp: int = 45,
     duration: int = 4,
     filament: str = "",
+    rotate_tray: bool = False,
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
@@ -1490,7 +1491,9 @@ async def start_drying(
     if duration < 1 or duration > 24:
         raise HTTPException(400, "Duration must be 1-24 hours")
 
-    success = printer_manager.send_drying_command(printer_id, ams_id, temp, duration, mode=1, filament=filament)
+    success = printer_manager.send_drying_command(
+        printer_id, ams_id, temp, duration, mode=1, filament=filament, rotate_tray=rotate_tray
+    )
     if not success:
         raise HTTPException(400, "Printer not connected")
     return {"status": "drying_started", "ams_id": ams_id, "temp": temp, "duration": duration}
@@ -2299,6 +2302,31 @@ async def resume_print(
     return {"success": True, "message": "Print resume command sent"}
 
 
+@router.post("/{printer_id}/print-speed")
+async def set_print_speed(
+    printer_id: int,
+    mode: int = Query(..., description="Speed mode (1=silent, 2=standard, 3=sport, 4=ludicrous)"),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Set the print speed mode."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    success = client.set_print_speed(mode)
+    if not success:
+        raise HTTPException(500, "Failed to set print speed")
+
+    speed_names = {1: "Silent", 2: "Standard", 3: "Sport", 4: "Ludicrous"}
+    return {"success": True, "message": f"Print speed set to {speed_names.get(mode, 'Unknown')}"}
+
+
 @router.post("/{printer_id}/chamber-light")
 async def set_chamber_light(
     printer_id: int,

+ 5 - 1
backend/app/api/routes/projects.py

@@ -127,6 +127,7 @@ async def compute_project_stats(
             func.sum(case((ProjectBOMItem.quantity_acquired >= ProjectBOMItem.quantity_needed, 1), else_=0)).label(
                 "completed"
             ),
+            func.coalesce(func.sum(ProjectBOMItem.unit_price * ProjectBOMItem.quantity_needed), 0).label("bom_cost"),
         ).where(ProjectBOMItem.project_id == project_id)
     )
     bom_stats = bom_result.first()
@@ -149,6 +150,7 @@ async def compute_project_stats(
         remaining_parts=remaining_parts,
         bom_total_items=bom_stats.total or 0,
         bom_completed_items=int(bom_stats.completed or 0),
+        bom_cost=round(float(bom_stats.bom_cost or 0), 2),
     )
 
 
@@ -244,6 +246,7 @@ async def list_projects(
                 status=project.status,
                 target_count=project.target_count,
                 target_parts_count=project.target_parts_count,
+                budget=project.budget,
                 created_at=project.created_at,
                 archive_count=archive_count,
                 total_items=total_items,
@@ -346,6 +349,7 @@ async def list_templates(
                 color=project.color,
                 status=project.status,
                 target_count=project.target_count,
+                budget=project.budget,
                 created_at=project.created_at,
                 archive_count=archive_count,
                 queue_count=0,
@@ -561,7 +565,7 @@ async def update_project(
         if data.priority not in ["low", "normal", "high", "urgent"]:
             raise HTTPException(status_code=400, detail="Invalid priority")
         project.priority = data.priority
-    if data.budget is not None:
+    if "budget" in data.model_fields_set:
         project.budget = data.budget
     if data.parent_id is not None:
         # Verify parent exists and prevent circular reference

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

@@ -87,6 +87,7 @@ async def get_settings(
                 "spoolman_enabled",
                 "spoolman_disable_weight_sync",
                 "spoolman_report_partial_usage",
+                "disable_filament_warnings",
                 "check_updates",
                 "check_printer_firmware",
                 "include_beta_updates",
@@ -97,6 +98,7 @@ async def get_settings(
                 "ha_enabled",
                 "per_printer_mapping_expanded",
                 "prometheus_enabled",
+                "user_notifications_enabled",
                 "queue_drying_enabled",
                 "queue_drying_block",
                 "ambient_drying_enabled",
@@ -218,6 +220,20 @@ async def reset_settings(
     return DEFAULT_SETTINGS
 
 
+@router.get("/default-sidebar-order")
+async def get_default_sidebar_order(
+    db: AsyncSession = Depends(get_db),
+):
+    """Get the admin-set default sidebar order.
+
+    Intentionally unauthenticated: non-admin users need to read this value to apply
+    the default sidebar order, but may lack SETTINGS_READ permission.
+    The value is non-sensitive (sidebar item IDs only).
+    """
+    value = await get_setting(db, "default_sidebar_order")
+    return {"default_sidebar_order": value or ""}
+
+
 @router.get("/check-ffmpeg")
 async def check_ffmpeg():
     """Check if ffmpeg is installed and available."""

+ 73 - 0
backend/app/api/routes/spoolbuddy.py

@@ -68,6 +68,8 @@ def _device_to_response(device: SpoolBuddyDevice) -> DeviceResponse:
         nfc_ok=device.nfc_ok,
         scale_ok=device.scale_ok,
         uptime_s=device.uptime_s,
+        update_status=device.update_status,
+        update_message=device.update_message,
         online=_is_online(device),
         created_at=device.created_at,
         updated_at=device.updated_at,
@@ -636,6 +638,77 @@ async def check_daemon_update(
         }
 
 
+@router.post("/devices/{device_id}/update")
+async def trigger_daemon_update(
+    device_id: str,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
+    """Trigger a daemon update on the SpoolBuddy device via pending_command."""
+    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
+    device = result.scalar_one_or_none()
+    if not device:
+        raise HTTPException(status_code=404, detail="Device not registered")
+
+    if not _is_online(device):
+        raise HTTPException(status_code=409, detail="Device is offline")
+
+    if device.update_status == "updating":
+        return {"status": "already_updating", "message": "Update already in progress"}
+
+    device.pending_command = "update"
+    device.update_status = "pending"
+    device.update_message = "Waiting for device to pick up update command..."
+    await db.commit()
+
+    logger.info("SpoolBuddy %s: update command queued", device_id)
+    await ws_manager.broadcast(
+        {
+            "type": "spoolbuddy_update",
+            "device_id": device_id,
+            "update_status": "pending",
+        }
+    )
+
+    return {"status": "ok", "message": "Update command sent to device"}
+
+
+@router.post("/devices/{device_id}/update-status")
+async def report_update_status(
+    device_id: str,
+    req: dict,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Daemon reports update progress back to the backend."""
+    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
+    device = result.scalar_one_or_none()
+    if not device:
+        raise HTTPException(status_code=404, detail="Device not registered")
+
+    status = req.get("status", "")
+    message = req.get("message", "")
+
+    if status in ("updating", "complete", "error"):
+        device.update_status = status
+        device.update_message = message[:255] if message else None
+        if status == "complete":
+            device.pending_command = None
+        await db.commit()
+
+        logger.info("SpoolBuddy %s: update status=%s msg=%s", device_id, status, message)
+        await ws_manager.broadcast(
+            {
+                "type": "spoolbuddy_update",
+                "device_id": device_id,
+                "update_status": status,
+                "update_message": message,
+            }
+        )
+
+    return {"status": "ok"}
+
+
 # --- Background watchdog ---
 
 

+ 2 - 2
backend/app/api/routes/updates.py

@@ -99,8 +99,8 @@ def parse_version(version: str) -> tuple:
         micro = int(match.group(4)) if match.group(4) else 0
         prerelease_num = int(match.group(5)) if match.group(5) else 0
 
-        # Check if this is a prerelease (has b/beta/alpha/rc suffix)
-        is_prerelease = 1 if re.search(r"[a-zA-Z]", version.split(".")[-1]) else 0
+        # Check if this is a prerelease (has b/beta/alpha/rc/daily suffix anywhere)
+        is_prerelease = 1 if re.search(r"[a-zA-Z]", version) else 0
 
         return (major, minor, patch, micro, is_prerelease, prerelease_num)
 

+ 107 - 0
backend/app/api/routes/user_notifications.py

@@ -0,0 +1,107 @@
+"""API routes for user email notification preferences."""
+
+import logging
+
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.user import User
+from backend.app.models.user_email_pref import UserEmailPreference
+from backend.app.schemas.user_notifications import UserEmailPreferenceResponse, UserEmailPreferenceUpdate
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/user-notifications", tags=["user-notifications"])
+
+
+@router.get("/preferences", response_model=UserEmailPreferenceResponse)
+async def get_user_email_preferences(
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_USER_EMAIL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Get the current user's email notification preferences.
+
+    Returns defaults (all enabled) if no preferences are saved yet.
+    """
+    if current_user is None:
+        # Auth is disabled; no user context available, return defaults
+        return UserEmailPreferenceResponse(
+            notify_print_start=True,
+            notify_print_complete=True,
+            notify_print_failed=True,
+            notify_print_stopped=True,
+        )
+
+    result = await db.execute(
+        select(UserEmailPreference).where(UserEmailPreference.user_id == current_user.id)
+    )
+    pref = result.scalar_one_or_none()
+
+    if pref is None:
+        # Return defaults
+        return UserEmailPreferenceResponse(
+            notify_print_start=True,
+            notify_print_complete=True,
+            notify_print_failed=True,
+            notify_print_stopped=True,
+        )
+
+    return pref
+
+
+@router.put("/preferences", response_model=UserEmailPreferenceResponse)
+async def update_user_email_preferences(
+    data: UserEmailPreferenceUpdate,
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_USER_EMAIL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Update the current user's email notification preferences."""
+    if current_user is None:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Authentication must be enabled to save user notification preferences",
+        )
+
+    if not current_user.email:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="User must have an email address to receive notifications",
+        )
+
+    result = await db.execute(
+        select(UserEmailPreference).where(UserEmailPreference.user_id == current_user.id)
+    )
+    pref = result.scalar_one_or_none()
+
+    if pref is None:
+        pref = UserEmailPreference(
+            user_id=current_user.id,
+            notify_print_start=data.notify_print_start,
+            notify_print_complete=data.notify_print_complete,
+            notify_print_failed=data.notify_print_failed,
+            notify_print_stopped=data.notify_print_stopped,
+        )
+        db.add(pref)
+    else:
+        pref.notify_print_start = data.notify_print_start
+        pref.notify_print_complete = data.notify_print_complete
+        pref.notify_print_failed = data.notify_print_failed
+        pref.notify_print_stopped = data.notify_print_stopped
+
+    await db.commit()
+    await db.refresh(pref)
+
+    logger.info(
+        "Updated email notification preferences for user %s: start=%s, complete=%s, failed=%s, stopped=%s",
+        current_user.username,
+        pref.notify_print_start,
+        pref.notify_print_complete,
+        pref.notify_print_failed,
+        pref.notify_print_stopped,
+    )
+
+    return pref

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

@@ -278,7 +278,8 @@ async def update_user(
         user.groups = list(groups)
 
     await db.commit()
-    await db.refresh(user)
+    result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
+    user = result.scalar_one()
 
     return _user_to_response(user)
 

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

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

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

@@ -110,6 +110,7 @@ async def init_db():
         spool_usage_history,
         spoolbuddy_device,
         user,
+        user_email_pref,
         virtual_printer,
     )
 
@@ -1395,6 +1396,16 @@ async def run_migrations(conn):
     except OperationalError:
         pass  # Already applied
 
+    # Migration: Add OTA update tracking columns to spoolbuddy_devices
+    try:
+        await conn.execute(text("ALTER TABLE spoolbuddy_devices ADD COLUMN update_status VARCHAR(20)"))
+    except OperationalError:
+        pass  # Already applied
+    try:
+        await conn.execute(text("ALTER TABLE spoolbuddy_devices ADD COLUMN update_message VARCHAR(255)"))
+    except OperationalError:
+        pass  # Already applied
+
     # Migration: Convert ams_labels table from (printer_id, ams_id) key to ams_serial_number key
     # Labels are now keyed by AMS serial number so they persist when the AMS is moved to another printer.
     try:
@@ -1482,6 +1493,56 @@ async def run_migrations(conn):
     for key in obsolete_keys:
         await conn.execute(text("DELETE FROM settings WHERE key = :key"), {"key": key})
 
+    # Migration: Create user_email_preferences table for user-specific email notification settings
+    try:
+        await conn.execute(
+            text("""
+            CREATE TABLE IF NOT EXISTS user_email_preferences (
+                id INTEGER PRIMARY KEY,
+                user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
+                notify_print_start BOOLEAN NOT NULL DEFAULT 1,
+                notify_print_complete BOOLEAN NOT NULL DEFAULT 1,
+                notify_print_failed BOOLEAN NOT NULL DEFAULT 1,
+                notify_print_stopped BOOLEAN NOT NULL DEFAULT 1,
+                created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+            )
+        """)
+        )
+        await conn.execute(
+            text("CREATE INDEX IF NOT EXISTS ix_user_email_preferences_user_id ON user_email_preferences(user_id)")
+        )
+    except OperationalError:
+        pass  # Already applied
+
+    # Legacy migration: Add notify_print_stopped column (for any existing partial tables)
+    try:
+        await conn.execute(
+            text("ALTER TABLE user_email_preferences ADD COLUMN notify_print_stopped BOOLEAN NOT NULL DEFAULT 1")
+        )
+    except OperationalError:
+        pass  # Column already exists or table created with full schema
+
+    # Migration: Add camera_rotation column to printers
+    try:
+        await conn.execute(text("ALTER TABLE printers ADD COLUMN camera_rotation INTEGER DEFAULT 0"))
+    except OperationalError:
+        pass  # Already applied
+
+    # Seed default settings keys that must exist on fresh install
+    default_settings = [
+        ("advanced_auth_enabled", "false"),
+        ("smtp_auth_enabled", "true"),
+    ]
+    for key, value in default_settings:
+        try:
+            await conn.execute(
+                text("INSERT OR IGNORE INTO settings (key, value) VALUES (:key, :value)"),
+                {"key": key, "value": value},
+            )
+        except OperationalError:
+            pass
+
 
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""

+ 3 - 1
backend/app/core/permissions.py

@@ -97,7 +97,7 @@ class Permission(StrEnum):
     NOTIFICATIONS_CREATE = "notifications:create"
     NOTIFICATIONS_UPDATE = "notifications:update"
     NOTIFICATIONS_DELETE = "notifications:delete"
-
+    NOTIFICATIONS_USER_EMAIL = "notifications:user_email"  # Receive per-user print email notifications
     # Notification Templates
     NOTIFICATION_TEMPLATES_READ = "notification_templates:read"
     NOTIFICATION_TEMPLATES_UPDATE = "notification_templates:update"
@@ -244,6 +244,7 @@ PERMISSION_CATEGORIES = {
         Permission.NOTIFICATIONS_CREATE,
         Permission.NOTIFICATIONS_UPDATE,
         Permission.NOTIFICATIONS_DELETE,
+        Permission.NOTIFICATIONS_USER_EMAIL,
         Permission.NOTIFICATION_TEMPLATES_READ,
         Permission.NOTIFICATION_TEMPLATES_UPDATE,
     ],
@@ -381,6 +382,7 @@ DEFAULT_GROUPS = {
             Permission.NOTIFICATIONS_CREATE.value,
             Permission.NOTIFICATIONS_UPDATE.value,
             Permission.NOTIFICATIONS_DELETE.value,
+            Permission.NOTIFICATIONS_USER_EMAIL.value,
             Permission.NOTIFICATION_TEMPLATES_READ.value,
             Permission.NOTIFICATION_TEMPLATES_UPDATE.value,
             # External Links - full access

+ 365 - 14
backend/app/main.py

@@ -45,6 +45,7 @@ from backend.app.api.routes import (
     support,
     system,
     updates,
+    user_notifications,
     users,
     virtual_printers,
     webhook,
@@ -280,6 +281,28 @@ _timelapse_baselines: dict[int, set[str]] = {}
 # Track active bed cooldown monitoring tasks: {printer_id: asyncio.Task}
 _bed_cooldown_tasks: dict[int, asyncio.Task] = {}
 
+# Track printers where the user explicitly stopped the print from the queue UI.
+# When on_print_complete fires with status "failed" for these printers we treat it
+# as "cancelled" (stopped by user) so the correct notification email is sent.
+_user_stopped_printers: set[int] = set()
+
+# Track created_by_id for expected prints so the user email can be sent even when
+# the archive itself doesn't have created_by_id set (e.g. library-file-based prints).
+# {(printer_id, filename): created_by_id}
+_expected_print_creators: dict[tuple[int, str], int] = {}
+
+# TTL for expected-print entries: evict registrations older than this to prevent
+# unbounded growth when a print is registered but never starts (e.g. printer
+# disconnect, app restart, print started from the printer panel).
+_EXPECTED_PRINT_TTL_SECONDS: int = 2 * 60 * 60  # 2 hours
+
+# Registration timestamps used for TTL eviction: {(printer_id, filename): monotonic_time}
+_expected_print_registered_at: dict[tuple[int, str], float] = {}
+
+# Cleanup loop interval
+_EXPECTED_PRINT_CLEANUP_INTERVAL: int = 15 * 60  # 15 minutes
+_expected_prints_cleanup_task: asyncio.Task | None = None
+
 
 async def _get_plug_energy(plug, db) -> dict | None:
     """Get energy from plug regardless of type (Tasmota, Home Assistant, or MQTT).
@@ -308,7 +331,13 @@ async def _get_plug_energy(plug, db) -> dict | None:
         return await tasmota_service.get_energy(plug)
 
 
-def register_expected_print(printer_id: int, filename: str, archive_id: int, ams_mapping: list[int] | None = None):
+def register_expected_print(
+    printer_id: int,
+    filename: str,
+    archive_id: int,
+    ams_mapping: list[int] | None = None,
+    created_by_id: int | None = None,
+):
     """Register an expected print from reprint/scheduled so we don't create duplicate archives."""
     # Store with multiple filename variations to catch different naming patterns
     _expected_prints[(printer_id, filename)] = archive_id
@@ -320,6 +349,21 @@ def register_expected_print(printer_id: int, filename: str, archive_id: int, ams
     # Store AMS mapping for usage tracking at print completion
     if ams_mapping is not None:
         _print_ams_mappings[archive_id] = ams_mapping
+    # Store created_by_id so the user start email can be sent even when the archive
+    # itself has no created_by_id (e.g. library-file-based queue prints)
+    if created_by_id is not None:
+        _expected_print_creators[(printer_id, filename)] = created_by_id
+        if filename.endswith(".3mf"):
+            base = filename[:-4]
+            _expected_print_creators[(printer_id, base)] = created_by_id
+            _expected_print_creators[(printer_id, f"{base}.gcode")] = created_by_id
+    # Record registration time for TTL-based eviction
+    _registered_at = time.monotonic()
+    _expected_print_registered_at[(printer_id, filename)] = _registered_at
+    if filename.endswith(".3mf"):
+        base = filename[:-4]
+        _expected_print_registered_at[(printer_id, base)] = _registered_at
+        _expected_print_registered_at[(printer_id, f"{base}.gcode")] = _registered_at
     logging.getLogger(__name__).info(
         f"Registered expected print: printer={printer_id}, file={filename}, archive={archive_id}, ams_mapping={ams_mapping}"
     )
@@ -333,6 +377,17 @@ def _get_start_ams_mapping(data: dict, archive_id: int | None) -> list[int] | No
     return stored_ams_mapping
 
 
+def mark_printer_stopped_by_user(printer_id: int) -> None:
+    """Mark that the active print on this printer was stopped by the user from the queue UI.
+
+    When on_print_complete fires with status 'failed' for a printer in this set we
+    reclassify it as 'cancelled' so the correct 'print stopped' notification is sent
+    rather than a 'print failed' notification.
+    """
+    _user_stopped_printers.add(printer_id)
+    logging.getLogger(__name__).info("Marked printer %s as user-stopped from queue", printer_id)
+
+
 _last_status_broadcast: dict[int, str] = {}
 # Track printers where we've updated nozzle_count
 _nozzle_count_updated: set[int] = set()
@@ -978,7 +1033,7 @@ async def _capture_snapshot_for_notification(printer_id: int, printer, logger) -
             frame_data = await capture_frame(printer.external_camera_url, printer.external_camera_type or "mjpeg")
             if frame_data and len(frame_data) <= 2_500_000:
                 logger.info("[SNAPSHOT] External camera frame: %s bytes", len(frame_data))
-                return frame_data
+                return _apply_camera_rotation(frame_data, printer, logger)
 
         # Try buffered frame from active stream
         from backend.app.api.routes.camera import _active_chamber_streams, _active_streams, get_buffered_frame
@@ -990,7 +1045,7 @@ async def _capture_snapshot_for_notification(printer_id: int, printer, logger) -
         if (active_for_printer or active_chamber) and buffered_frame:
             logger.info("[SNAPSHOT] Using buffered frame for printer %s: %s bytes", printer_id, len(buffered_frame))
             if len(buffered_frame) <= 2_500_000:
-                return buffered_frame
+                return _apply_camera_rotation(buffered_frame, printer, logger)
 
         # Fresh capture from printer camera
         logger.info("[SNAPSHOT] Capturing fresh frame for printer %s", printer_id)
@@ -1001,7 +1056,7 @@ async def _capture_snapshot_for_notification(printer_id: int, printer, logger) -
         )
         if frame_data and len(frame_data) <= 2_500_000:
             logger.info("[SNAPSHOT] Fresh camera frame: %s bytes", len(frame_data))
-            return frame_data
+            return _apply_camera_rotation(frame_data, printer, logger)
 
     except Exception as e:
         logger.warning("[SNAPSHOT] Failed to capture snapshot for printer %s: %s", printer_id, e)
@@ -1009,6 +1064,30 @@ async def _capture_snapshot_for_notification(printer_id: int, printer, logger) -
     return None
 
 
+def _apply_camera_rotation(image_data: bytes, printer, logger) -> bytes:
+    """Apply camera rotation to snapshot image if configured."""
+    rotation = getattr(printer, "camera_rotation", 0)
+    if not rotation or rotation == 0:
+        return image_data
+
+    try:
+        from io import BytesIO
+
+        from PIL import Image
+
+        img = Image.open(BytesIO(image_data))
+        # PIL rotate is counter-clockwise, so negate for clockwise rotation
+        img = img.rotate(-rotation, expand=True)
+        buf = BytesIO()
+        img.save(buf, format="JPEG", quality=90)
+        rotated = buf.getvalue()
+        logger.info("[SNAPSHOT] Applied %d° rotation: %s → %s bytes", rotation, len(image_data), len(rotated))
+        return rotated
+    except Exception as e:
+        logger.warning("[SNAPSHOT] Failed to apply rotation: %s", e)
+        return image_data
+
+
 async def _send_print_start_notification(
     printer_id: int,
     data: dict,
@@ -1035,10 +1114,55 @@ async def _send_print_start_notification(
                 archive_data["image_data"] = image_data
 
             await notification_service.on_print_start(printer_id, printer_name, data, db, archive_data=archive_data)
+
+            # Send user-specific email notification for print start
+            if archive_data and archive_data.get("created_by_id"):
+                await notification_service.send_user_print_email(
+                    event_type="user_print_start",
+                    created_by_id=archive_data["created_by_id"],
+                    printer_name=printer_name,
+                    filename=data.get("subtask_name") or data.get("filename", "Unknown"),
+                    db=db,
+                )
     except Exception as e:
         logger.warning("Notification on_print_start failed: %s", e)
 
 
+async def _dispatch_user_print_email(
+    status: str,
+    created_by_id: int | None,
+    printer_name: str,
+    filename: str,
+    db,
+) -> None:
+    """Send a user-specific print-completion email based on print status.
+
+    Maps the normalised print status to the correct event type and delegates
+    to :meth:`NotificationService.send_user_print_email`.  A single helper
+    avoids duplicating the ``if status == "completed" / elif "failed" / elif
+    "stopped"`` dispatch block at every call site.
+
+    Does nothing if *created_by_id* is ``None``.
+    """
+    if created_by_id is None:
+        return
+    if status == "completed":
+        event_type = "user_print_complete"
+    elif status == "failed":
+        event_type = "user_print_failed"
+    elif status in ("stopped", "aborted", "cancelled"):
+        event_type = "user_print_stopped"
+    else:
+        return
+    await notification_service.send_user_print_email(
+        event_type=event_type,
+        created_by_id=created_by_id,
+        printer_name=printer_name,
+        filename=filename,
+        db=db,
+    )
+
+
 def _load_objects_from_archive(archive, printer_id: int, logger) -> None:
     """Extract printable objects from an archive's 3MF file and store in printer state."""
     try:
@@ -1067,6 +1191,9 @@ async def on_print_start(printer_id: int, data: dict):
 
     logger.info("[CALLBACK] on_print_start called for printer %s, data keys: %s", printer_id, list(data.keys()))
 
+    # Clear any stale user-stopped flag from previous print cycles
+    _user_stopped_printers.discard(printer_id)
+
     # Cancel any active bed cooldown task for this printer
     existing_task = _bed_cooldown_tasks.pop(printer_id, None)
     if existing_task and not existing_task.done():
@@ -1222,7 +1349,36 @@ async def on_print_start(printer_id: int, data: dict):
                 f"[CALLBACK] Skipping archive - printer: {printer is not None}, auto_archive: {printer.auto_archive if printer else 'N/A'}"
             )
             if not notification_sent:
-                await _send_print_start_notification(printer_id, data, logger=logger)
+                # Even with auto-archive disabled, try to recover created_by_id from
+                # a registered expected print (e.g. a library-file queue item) so the
+                # user start email can still be sent.
+                _fn = data.get("filename", "")
+                _sn = data.get("subtask_name", "")
+                _no_archive_creator_keys: list[tuple[int, str]] = []
+                if _sn:
+                    _no_archive_creator_keys += [
+                        (printer_id, _sn),
+                        (printer_id, f"{_sn}.3mf"),
+                        (printer_id, f"{_sn}.gcode.3mf"),
+                    ]
+                if _fn:
+                    _base_fn = _fn.split("/")[-1] if "/" in _fn else _fn
+                    _no_archive_creator_keys.append((printer_id, _base_fn))
+                    _no_archive_base = _base_fn.replace(".gcode", "").replace(".3mf", "")
+                    _no_archive_creator_keys += [
+                        (printer_id, _no_archive_base),
+                        (printer_id, f"{_no_archive_base}.3mf"),
+                    ]
+                _no_archive_creator: int | None = None
+                for _key in _no_archive_creator_keys:
+                    # Clean up all dicts for every key to avoid memory leaks
+                    _expected_prints.pop(_key, None)
+                    _expected_print_registered_at.pop(_key, None)
+                    popped_creator = _expected_print_creators.pop(_key, None)
+                    if _no_archive_creator is None:
+                        _no_archive_creator = popped_creator
+                _creator_data = {"created_by_id": _no_archive_creator} if _no_archive_creator else None
+                await _send_print_start_notification(printer_id, data, _creator_data, logger)
             return
 
         # Get the filename and subtask_name
@@ -1264,10 +1420,12 @@ async def on_print_start(printer_id: int, data: dict):
         expected_archive_id = None
         for key in expected_keys:
             expected_archive_id = _expected_prints.pop(key, None)
+            _expected_print_registered_at.pop(key, None)
             if expected_archive_id:
                 # Clean up other possible keys for this print
                 for other_key in expected_keys:
                     _expected_prints.pop(other_key, None)
+                    _expected_print_registered_at.pop(other_key, None)
                 break
 
         if expected_archive_id:
@@ -1320,7 +1478,19 @@ async def on_print_start(printer_id: int, data: dict):
 
                 # Send notification with archive data (reprint/scheduled)
                 if not notification_sent:
-                    archive_data = {"print_time_seconds": archive.print_time_seconds}
+                    # Use archive's created_by_id; fall back to the creator registered via
+                    # register_expected_print (handles library-file-based queue items where
+                    # the freshly-created archive has no created_by_id yet).
+                    # Pop ALL matching keys so no stale entries remain in the dict.
+                    fallback_creator = None
+                    for key in expected_keys:
+                        popped = _expected_print_creators.pop(key, None)
+                        if fallback_creator is None:
+                            fallback_creator = popped
+                    archive_data = {
+                        "print_time_seconds": archive.print_time_seconds,
+                        "created_by_id": archive.created_by_id or fallback_creator,
+                    }
                     await _send_print_start_notification(printer_id, data, archive_data, logger)
 
                 # Extract printable objects from the archived 3MF file
@@ -1401,7 +1571,10 @@ async def on_print_start(printer_id: int, data: dict):
                         logger.warning("Failed to record starting energy for existing archive: %s", e)
                 # Send notification with archive data (existing archive)
                 if not notification_sent:
-                    archive_data = {"print_time_seconds": existing_archive.print_time_seconds}
+                    archive_data = {
+                        "print_time_seconds": existing_archive.print_time_seconds,
+                        "created_by_id": existing_archive.created_by_id,
+                    }
                     await _send_print_start_notification(printer_id, data, archive_data, logger)
                 # Extract printable objects from the archived 3MF file
                 _load_objects_from_archive(existing_archive, printer_id, logger)
@@ -1749,7 +1922,10 @@ async def on_print_start(printer_id: int, data: dict):
 
                 # Send notification with archive data (new archive created)
                 if not notification_sent:
-                    archive_data = {"print_time_seconds": archive.print_time_seconds}
+                    archive_data = {
+                        "print_time_seconds": archive.print_time_seconds,
+                        "created_by_id": archive.created_by_id,
+                    }
                     await _send_print_start_notification(printer_id, data, archive_data, logger)
 
                 # Extract printable objects for skip object functionality
@@ -2060,6 +2236,19 @@ async def on_print_complete(printer_id: int, data: dict):
     # Clear current print user tracking (Issue #206)
     printer_manager.clear_current_print_user(printer_id)
 
+    # If the user explicitly stopped this print from the queue UI the printer will
+    # report "failed" or "aborted" via MQTT.  Override that to "cancelled" so the
+    # correct "print stopped" notification/email is sent instead of a failure alert.
+    _raw_status = data.get("status", "completed")
+    if printer_id in _user_stopped_printers and _raw_status in ("failed", "aborted"):
+        logger.info(
+            "[CALLBACK] Overriding status '%s' -> 'cancelled' for printer %s (print was stopped from queue by user)",
+            _raw_status,
+            printer_id,
+        )
+        data = {**data, "status": "cancelled"}
+    _user_stopped_printers.discard(printer_id)
+
     # MQTT relay - publish print complete
     try:
         printer_info = printer_manager.get_printer(printer_id)
@@ -2421,6 +2610,76 @@ async def on_print_complete(printer_id: int, data: dict):
 
     if not archive_id:
         logger.warning("Could not find archive for print complete: filename=%s, subtask=%s", filename, subtask_name)
+
+        # Still send print-complete/failed/stopped notifications even without an archive.
+        # Try to enrich with queue/library-file data so user-specific emails work too.
+        async def _notify_no_archive():
+            try:
+                async with async_session() as db:
+                    from backend.app.models.library import LibraryFile
+                    from backend.app.models.print_queue import PrintQueueItem
+                    from backend.app.models.printer import Printer
+
+                    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+                    printer_obj = result.scalar_one_or_none()
+                    p_name = printer_obj.name if printer_obj else f"Printer {printer_id}"
+
+                    # Try to find the most-recent queue item for this printer so we can
+                    # recover created_by_id and estimated print time.
+                    # NOTE: By the time this task runs the queue item status has already
+                    # been updated to a terminal state (completed/failed/cancelled), so
+                    # we look for recently-completed items (within the last 5 minutes).
+                    no_archive_data: dict | None = None
+                    try:
+                        cutoff = datetime.now(timezone.utc) - timedelta(minutes=5)
+                        q_result = await db.execute(
+                            select(PrintQueueItem)
+                            .where(PrintQueueItem.printer_id == printer_id)
+                            .where(PrintQueueItem.status.in_(["completed", "failed", "cancelled"]))
+                            .where(PrintQueueItem.completed_at >= cutoff)
+                            .order_by(PrintQueueItem.completed_at.desc())
+                            .limit(1)
+                        )
+                        queue_item = q_result.scalar_one_or_none()
+                        if queue_item:
+                            no_archive_data = {"created_by_id": queue_item.created_by_id}
+                            # Pull estimated time from library file when available
+                            if queue_item.library_file_id:
+                                lib_result = await db.execute(
+                                    select(LibraryFile).where(LibraryFile.id == queue_item.library_file_id)
+                                )
+                                lib_file = lib_result.scalar_one_or_none()
+                                if lib_file and lib_file.print_time_seconds:
+                                    no_archive_data["print_time_seconds"] = lib_file.print_time_seconds
+                    except Exception as lookup_err:
+                        logger.debug(
+                            "[NOTIFY-BG] Could not look up queue item for no-archive notification: %s", lookup_err
+                        )
+
+                    ps = data.get("status", "completed")
+                    logger.info(
+                        "[NOTIFY-BG] Sending notification without archive: printer=%s, status=%s", printer_id, ps
+                    )
+                    await notification_service.on_print_complete(
+                        printer_id, p_name, ps, data, db, archive_data=no_archive_data
+                    )
+
+                    # Send user-specific email if we have a created_by_id
+                    if no_archive_data and no_archive_data.get("created_by_id"):
+                        raw_filename = data.get("subtask_name") or data.get("filename", "Unknown")
+                        await _dispatch_user_print_email(
+                            ps,
+                            no_archive_data["created_by_id"],
+                            p_name,
+                            raw_filename,
+                            db,
+                        )
+                    logger.info("[NOTIFY-BG] Completed (no-archive path)")
+            except Exception as e:
+                logger.warning("[NOTIFY-BG] Failed to send notification without archive: %s", e, exc_info=True)
+
+        task = asyncio.create_task(_notify_no_archive())
+        task.add_done_callback(lambda _t: None)
         return
 
     log_timing("Archive lookup")
@@ -2760,6 +3019,7 @@ async def on_print_complete(printer_id: int, data: dict):
                             "print_time_seconds": archive.print_time_seconds,
                             "actual_filament_grams": archive.filament_used_grams,
                             "failure_reason": archive.failure_reason,
+                            "created_by_id": archive.created_by_id,
                         }
 
                         # Scale filament usage for partial prints
@@ -2822,9 +3082,22 @@ async def on_print_complete(printer_id: int, data: dict):
                 await notification_service.on_print_complete(
                     printer_id, printer_name, print_status, data, db, archive_data=archive_data
                 )
+
+                # Send user-specific email notification
+                if archive_data:
+                    created_by_id = archive_data.get("created_by_id")
+                    raw_filename = data.get("subtask_name") or data.get("filename", "Unknown")
+                    await _dispatch_user_print_email(
+                        print_status,
+                        created_by_id,
+                        printer_name,
+                        raw_filename,
+                        db,
+                    )
+
                 logger.info("[NOTIFY-BG] Completed")
         except Exception as e:
-            logger.warning("[NOTIFY-BG] Failed: %s", e)
+            logger.error("[NOTIFY-BG] Failed: %s", e, exc_info=True)
 
     async def _background_maintenance_check():
         """Check for maintenance due in background."""
@@ -2872,17 +3145,21 @@ async def on_print_complete(printer_id: int, data: dict):
     asyncio.create_task(_background_smart_plug())
     asyncio.create_task(_background_maintenance_check())
 
-    # Notification task waits for photo capture to complete first
+    # Notification task waits for photo capture to complete first (with timeout)
     async def _photo_then_notify():
         """Wait for photo capture, then send notification with photo URL."""
+        finish_photo = None
         try:
-            finish_photo = await photo_task
+            finish_photo = await asyncio.wait_for(photo_task, timeout=45)
             logger.info("[PHOTO-NOTIFY] Photo task returned: %s", finish_photo)
+        except TimeoutError:
+            logger.warning("[PHOTO-NOTIFY] Photo capture timed out after 45s, sending notification without photo")
+        except Exception as e:
+            logger.warning("[PHOTO-NOTIFY] Photo task failed: %s", e)
+        try:
             await _background_notifications(finish_photo)
         except Exception as e:
-            logger.warning("[PHOTO-NOTIFY] Failed: %s", e)
-            # Still try to send notification without photo
-            await _background_notifications(None)
+            logger.error("[PHOTO-NOTIFY] Notification sending failed: %s", e, exc_info=True)
 
     asyncio.create_task(_photo_then_notify())
 
@@ -3315,6 +3592,74 @@ def stop_camera_cleanup():
         logging.getLogger(__name__).info("Camera stream cleanup stopped")
 
 
+# ---------------------------------------------------------------------------
+# Expected-print TTL eviction
+# ---------------------------------------------------------------------------
+
+
+def _evict_stale_expected_prints() -> None:
+    """Remove entries from _expected_prints / _expected_print_creators that are
+    older than _EXPECTED_PRINT_TTL_SECONDS.
+
+    This prevents unbounded growth when a print is registered (via
+    register_expected_print) but on_print_start never fires — e.g. because the
+    printer disconnects, the app restarts, or the print is started directly from
+    the printer panel without going through the queue.
+    """
+    # Use monotonic time so the TTL is unaffected by system clock adjustments
+    # (e.g. NTP sync, DST changes).
+    cutoff = time.monotonic() - _EXPECTED_PRINT_TTL_SECONDS
+    stale_keys = [k for k, t in _expected_print_registered_at.items() if t < cutoff]
+    if not stale_keys:
+        return
+
+    evicted_archive_ids: set[int] = set()
+    for key in stale_keys:
+        archive_id = _expected_prints.pop(key, None)
+        if archive_id is not None:
+            evicted_archive_ids.add(archive_id)
+        _expected_print_creators.pop(key, None)
+        _expected_print_registered_at.pop(key, None)
+
+    # Also clean up _print_ams_mappings for archive_ids that have no remaining
+    # live keys in _expected_prints (i.e. all variants were just evicted).
+    live_archive_ids = set(_expected_prints.values())
+    for archive_id in evicted_archive_ids:
+        if archive_id not in live_archive_ids:
+            _print_ams_mappings.pop(archive_id, None)
+
+    logging.getLogger(__name__).info(
+        "Evicted %d stale expected-print entries (TTL=%ds)", len(stale_keys), _EXPECTED_PRINT_TTL_SECONDS
+    )
+
+
+async def _expected_prints_cleanup_loop() -> None:
+    """Background task: periodically evict stale expected-print entries."""
+    while True:
+        try:
+            _evict_stale_expected_prints()
+        except asyncio.CancelledError:
+            raise
+        except Exception as e:
+            logging.getLogger(__name__).warning("Expected prints cleanup failed: %s", e)
+        await asyncio.sleep(_EXPECTED_PRINT_CLEANUP_INTERVAL)
+
+
+def start_expected_prints_cleanup() -> None:
+    global _expected_prints_cleanup_task
+    if _expected_prints_cleanup_task is None:
+        _expected_prints_cleanup_task = asyncio.create_task(_expected_prints_cleanup_loop())
+        logging.getLogger(__name__).info("Expected prints cleanup started")
+
+
+def stop_expected_prints_cleanup() -> None:
+    global _expected_prints_cleanup_task
+    if _expected_prints_cleanup_task:
+        _expected_prints_cleanup_task.cancel()
+        _expected_prints_cleanup_task = None
+        logging.getLogger(__name__).info("Expected prints cleanup stopped")
+
+
 @asynccontextmanager
 async def lifespan(app: FastAPI):
     # Startup
@@ -3469,6 +3814,10 @@ async def lifespan(app: FastAPI):
     # Start camera stream orphan cleanup
     start_camera_cleanup()
 
+    # Start expected-print TTL eviction (prevents memory leak when prints are
+    # registered but on_print_start never fires)
+    start_expected_prints_cleanup()
+
     # Initialize virtual printer manager and sync from DB
     from backend.app.services.virtual_printer import virtual_printer_manager
 
@@ -3491,6 +3840,7 @@ async def lifespan(app: FastAPI):
     stop_runtime_tracking()
     stop_spoolbuddy_watchdog()
     stop_camera_cleanup()
+    stop_expected_prints_cleanup()
     printer_manager.disconnect_all()
     await close_spoolman_client()
 
@@ -3686,6 +4036,7 @@ app.include_router(background_dispatch_routes.router, prefix=app_settings.api_pr
 app.include_router(kprofiles.router, prefix=app_settings.api_prefix)
 app.include_router(notifications.router, prefix=app_settings.api_prefix)
 app.include_router(notification_templates.router, prefix=app_settings.api_prefix)
+app.include_router(user_notifications.router, prefix=app_settings.api_prefix)
 app.include_router(spoolman.router, prefix=app_settings.api_prefix)
 app.include_router(updates.router, prefix=app_settings.api_prefix)
 app.include_router(maintenance.router, prefix=app_settings.api_prefix)

+ 2 - 0
backend/app/models/__init__.py

@@ -25,6 +25,7 @@ from backend.app.models.spool_k_profile import SpoolKProfile
 from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
 from backend.app.models.user import User
+from backend.app.models.user_email_pref import UserEmailPreference
 
 __all__ = [
     "Printer",
@@ -59,4 +60,5 @@ __all__ = [
     "SpoolUsageHistory",
     "ColorCatalogEntry",
     "SpoolBuddyDevice",
+    "UserEmailPreference",
 ]

+ 1 - 1
backend/app/models/notification.py

@@ -91,7 +91,7 @@ class NotificationProvider(Base):
     on_queue_job_added = Column(Boolean, default=False)  # Job added to queue
     on_queue_job_assigned = Column(Boolean, default=False)  # Model-based job assigned to printer
     on_queue_job_started = Column(Boolean, default=False)  # Queue job started printing
-    on_queue_job_waiting = Column(Boolean, default=True)  # Job waiting for filament
+    on_queue_job_waiting = Column(Boolean, default=True)  # Job waiting for filament or printer
     on_queue_job_skipped = Column(Boolean, default=True)  # Job skipped (previous print failed)
     on_queue_job_failed = Column(Boolean, default=True)  # Job failed to start
     on_queue_completed = Column(Boolean, default=False)  # All pending jobs finished

+ 26 - 1
backend/app/models/notification_template.py

@@ -137,7 +137,7 @@ DEFAULT_TEMPLATES = [
     {
         "event_type": "queue_job_waiting",
         "name": "Queue Job Waiting",
-        "title_template": "Job Waiting for Filament",
+        "title_template": "Queue Job Waiting",
         "body_template": "{job_name} waiting for {target_model}\n{waiting_reason}",
     },
     {
@@ -170,4 +170,29 @@ DEFAULT_TEMPLATES = [
         "title_template": "{app_name} - Password Reset",
         "body_template": "Hello {username},\n\nYour password has been reset.\nNew Password: {password}\n\nLogin at: {login_url}",
     },
+    # User email notification templates (sent to the print job owner)
+    {
+        "event_type": "user_print_start",
+        "name": "User Print Started",
+        "title_template": "Your Print Has Started",
+        "body_template": "Hello {username},\n\nYour print job has started on {printer}.\n\nFile: {filename}\n\nYou will be notified when it completes.",
+    },
+    {
+        "event_type": "user_print_complete",
+        "name": "User Print Completed",
+        "title_template": "Your Print Is Complete",
+        "body_template": "Hello {username},\n\nYour print job has completed on {printer}.\n\nFile: {filename}",
+    },
+    {
+        "event_type": "user_print_failed",
+        "name": "User Print Failed",
+        "title_template": "Your Print Has Failed",
+        "body_template": "Hello {username},\n\nYour print job has failed on {printer}.\n\nFile: {filename}",
+    },
+    {
+        "event_type": "user_print_stopped",
+        "name": "User Print Stopped",
+        "title_template": "Your Print Has Been Stopped",
+        "body_template": "Hello {username},\n\nYour print job was stopped on {printer}.\n\nFile: {filename}",
+    },
 ]

+ 1 - 0
backend/app/models/printer.py

@@ -28,6 +28,7 @@ class Printer(Base):
     external_camera_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
     external_camera_type: Mapped[str | None] = mapped_column(String(20), nullable=True)  # mjpeg, rtsp, snapshot
     external_camera_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
+    camera_rotation: Mapped[int] = mapped_column(default=0)  # 0, 90, 180, 270 degrees
     # Plate detection - check if build plate is empty before starting print
     plate_detection_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
     # ROI for plate detection (percentages: 0.0-1.0)

+ 2 - 0
backend/app/models/spoolbuddy_device.py

@@ -29,6 +29,8 @@ class SpoolBuddyDevice(Base):
     last_seen: Mapped[datetime | None] = mapped_column(DateTime)
     pending_command: Mapped[str | None] = mapped_column(String(50))
     pending_write_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
+    update_status: Mapped[str | None] = mapped_column(String(20), nullable=True)
+    update_message: Mapped[str | None] = mapped_column(String(255), nullable=True)
     nfc_ok: Mapped[bool] = mapped_column(Boolean, default=False)
     scale_ok: Mapped[bool] = mapped_column(Boolean, default=False)
     uptime_s: Mapped[int] = mapped_column(Integer, default=0)

+ 10 - 0
backend/app/models/user.py

@@ -10,6 +10,7 @@ from backend.app.core.database import Base
 
 if TYPE_CHECKING:
     from backend.app.models.group import Group
+    from backend.app.models.user_email_pref import UserEmailPreference
 
 
 class User(Base):
@@ -45,6 +46,15 @@ class User(Base):
         lazy="selectin",
     )
 
+    # Relationship to email notification preferences
+    email_preferences: Mapped[UserEmailPreference | None] = relationship(
+        "UserEmailPreference",
+        back_populates="user",
+        uselist=False,
+        cascade="all, delete-orphan",
+        lazy="select",
+    )
+
     @property
     def is_admin(self) -> bool:
         """Check if user is an admin.

+ 37 - 0
backend/app/models/user_email_pref.py

@@ -0,0 +1,37 @@
+"""User email notification preference model."""
+
+from __future__ import annotations
+
+from datetime import datetime
+from typing import TYPE_CHECKING
+
+from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+if TYPE_CHECKING:
+    from backend.app.models.user import User
+
+
+class UserEmailPreference(Base):
+    """Stores per-user email notification preferences for their own print jobs."""
+
+    __tablename__ = "user_email_preferences"
+
+    id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
+    user_id: Mapped[int] = mapped_column(
+        Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True, index=True
+    )
+
+    # Print lifecycle notifications (only for jobs submitted by this user)
+    notify_print_start: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+    notify_print_complete: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+    notify_print_failed: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+    notify_print_stopped: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+
+    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())
+
+    # Relationship
+    user: Mapped[User] = relationship(back_populates="email_preferences")

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

@@ -48,6 +48,8 @@ class ArchiveResponse(BaseModel):
     # Duplicate detection
     duplicates: list[ArchiveDuplicate] | None = None
     duplicate_count: int = 0  # Quick count for list views
+    duplicate_sequence: int = 0  # 0 = original, 1+ = nth duplicate
+    original_archive_id: int | None = None  # ID of the first/original archive
 
     # Object count (computed from extra_data.printable_objects)
     object_count: int | None = None

+ 1 - 1
backend/app/schemas/notification.py

@@ -65,7 +65,7 @@ class NotificationProviderBase(BaseModel):
     on_queue_job_added: bool = Field(default=False, description="Notify when job is added to queue")
     on_queue_job_assigned: bool = Field(default=False, description="Notify when model-based job is assigned to printer")
     on_queue_job_started: bool = Field(default=False, description="Notify when queue job starts printing")
-    on_queue_job_waiting: bool = Field(default=True, description="Notify when job is waiting for filament")
+    on_queue_job_waiting: bool = Field(default=True, description="Notify when job is waiting for filament or printer")
     on_queue_job_skipped: bool = Field(default=True, description="Notify when job is skipped")
     on_queue_job_failed: bool = Field(default=True, description="Notify when job fails to start")
     on_queue_completed: bool = Field(default=False, description="Notify when all queue jobs finish")

+ 34 - 0
backend/app/schemas/notification_template.py

@@ -81,6 +81,11 @@ EVENT_VARIABLES: dict[str, list[str]] = {
     # User management notifications
     "user_created": ["username", "password", "login_url", "app_name", "timestamp"],
     "password_reset": ["username", "password", "login_url", "app_name", "timestamp"],
+    # User email print notifications
+    "user_print_start": ["username", "printer", "filename", "timestamp", "app_name"],
+    "user_print_complete": ["username", "printer", "filename", "timestamp", "app_name"],
+    "user_print_failed": ["username", "printer", "filename", "timestamp", "app_name"],
+    "user_print_stopped": ["username", "printer", "filename", "timestamp", "app_name"],
 }
 
 # Sample data for previewing templates
@@ -252,6 +257,35 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "app_name": "Bambuddy",
         "timestamp": "2024-01-15 14:30",
     },
+    # User email print notifications
+    "user_print_start": {
+        "username": "john_doe",
+        "printer": "Bambu X1C",
+        "filename": "Benchy.3mf",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
+    "user_print_complete": {
+        "username": "john_doe",
+        "printer": "Bambu X1C",
+        "filename": "Benchy.3mf",
+        "timestamp": "2024-01-15 15:48",
+        "app_name": "Bambuddy",
+    },
+    "user_print_failed": {
+        "username": "john_doe",
+        "printer": "Bambu X1C",
+        "filename": "Benchy.3mf",
+        "timestamp": "2024-01-15 15:15",
+        "app_name": "Bambuddy",
+    },
+    "user_print_stopped": {
+        "username": "john_doe",
+        "printer": "Bambu X1C",
+        "filename": "Benchy.3mf",
+        "timestamp": "2024-01-15 15:15",
+        "app_name": "Bambuddy",
+    },
 }
 
 

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

@@ -18,6 +18,7 @@ class PrinterBase(BaseModel):
     external_camera_url: str | None = None
     external_camera_type: str | None = None  # "mjpeg", "rtsp", "snapshot", "usb"
     external_camera_enabled: bool = False
+    camera_rotation: int = 0  # 0, 90, 180, 270 degrees
 
 
 class PrinterCreate(PrinterBase):
@@ -49,6 +50,7 @@ class PrinterUpdate(BaseModel):
     external_camera_url: str | None = None
     external_camera_type: str | None = None
     external_camera_enabled: bool | None = None
+    camera_rotation: int | None = None  # 0, 90, 180, 270 degrees
     plate_detection_enabled: bool | None = None
     plate_detection_roi: PlateDetectionROI | None = None
 
@@ -61,6 +63,7 @@ class PrinterResponse(PrinterBase):
     external_camera_url: str | None = None
     external_camera_type: str | None = None
     external_camera_enabled: bool = False
+    camera_rotation: int = 0  # 0, 90, 180, 270 degrees
     plate_detection_enabled: bool = False
     plate_detection_roi: PlateDetectionROI | None = None
     created_at: datetime
@@ -84,6 +87,7 @@ class PrinterResponse(PrinterBase):
             "external_camera_url": printer.external_camera_url,
             "external_camera_type": printer.external_camera_type,
             "external_camera_enabled": printer.external_camera_enabled,
+            "camera_rotation": printer.camera_rotation,
             "is_active": printer.is_active,
             "nozzle_count": printer.nozzle_count,
             "print_hours_offset": printer.print_hours_offset,

+ 2 - 0
backend/app/schemas/project.py

@@ -58,6 +58,7 @@ class ProjectStats(BaseModel):
     # BOM stats (Phase 7)
     bom_total_items: int = 0
     bom_completed_items: int = 0
+    bom_cost: float = 0.0  # Total cost of BOM items (sum of unit_price * quantity_needed)
 
 
 class ProjectChildPreview(BaseModel):
@@ -120,6 +121,7 @@ class ProjectListResponse(BaseModel):
     status: str
     target_count: int | None
     target_parts_count: int | None = None
+    budget: float | None = None
     created_at: datetime
     # Quick stats
     archive_count: int = 0  # Number of print jobs

+ 41 - 1
backend/app/schemas/settings.py

@@ -1,4 +1,6 @@
-from pydantic import BaseModel, Field
+import json
+
+from pydantic import BaseModel, Field, field_validator
 
 
 class AppSettings(BaseModel):
@@ -31,6 +33,10 @@ class AppSettings(BaseModel):
         default=True,
         description="Report Partial Usage for Failed Prints. When a print fails or is cancelled, report the estimated filament used up to that point based on layer progress.",
     )
+    disable_filament_warnings: bool = Field(
+        default=False,
+        description="Disable insufficient filament warnings when printing or queueing prints",
+    )
 
     # Updates
     check_updates: bool = Field(default=True, description="Automatically check for updates on startup")
@@ -174,6 +180,18 @@ class AppSettings(BaseModel):
         description="Low stock threshold percentage (%) for inventory filtering and display",
     )
 
+    # User email notifications (requires Advanced Authentication)
+    user_notifications_enabled: bool = Field(
+        default=True,
+        description="Enable user email notifications for print job events (requires Advanced Authentication)",
+    )
+
+    # Default sidebar order (admin-set for all users)
+    default_sidebar_order: str = Field(
+        default="",
+        description="JSON object with 'order' key containing array of sidebar item IDs (empty = no default)",
+    )
+
 
 class AppSettingsUpdate(BaseModel):
     """Schema for updating settings (all fields optional)."""
@@ -190,6 +208,7 @@ class AppSettingsUpdate(BaseModel):
     spoolman_sync_mode: str | None = None
     spoolman_disable_weight_sync: bool | None = None
     spoolman_report_partial_usage: bool | None = None
+    disable_filament_warnings: bool | None = None
     check_updates: bool | None = None
     check_printer_firmware: bool | None = None
     include_beta_updates: bool | None = None
@@ -240,3 +259,24 @@ class AppSettingsUpdate(BaseModel):
     prometheus_enabled: bool | None = None
     prometheus_token: str | None = None
     low_stock_threshold: float | None = Field(default=None, ge=0.1, le=99.9)
+    user_notifications_enabled: bool | None = None
+    default_sidebar_order: str | None = None
+
+    @field_validator("default_sidebar_order")
+    @classmethod
+    def validate_default_sidebar_order(cls, v: str | None) -> str | None:
+        if v is None or v == "":
+            return v
+        try:
+            parsed = json.loads(v)
+        except json.JSONDecodeError:
+            raise ValueError("default_sidebar_order must be valid JSON or empty")
+        if isinstance(parsed, dict):
+            order = parsed.get("order")
+        elif isinstance(parsed, list):
+            order = parsed
+        else:
+            raise ValueError("default_sidebar_order must be a JSON object with 'order' key or a JSON array")
+        if not isinstance(order, list) or not all(isinstance(item, str) for item in order):
+            raise ValueError("sidebar order must be an array of strings")
+        return v

+ 2 - 0
backend/app/schemas/spoolbuddy.py

@@ -40,6 +40,8 @@ class DeviceResponse(BaseModel):
     nfc_ok: bool
     scale_ok: bool
     uptime_s: int
+    update_status: str | None = None
+    update_message: str | None = None
     online: bool = False
     created_at: datetime
     updated_at: datetime

+ 24 - 0
backend/app/schemas/user_notifications.py

@@ -0,0 +1,24 @@
+"""Schemas for user email notification preferences."""
+
+from pydantic import BaseModel
+
+
+class UserEmailPreferenceResponse(BaseModel):
+    """Response schema for user email notification preferences."""
+
+    notify_print_start: bool
+    notify_print_complete: bool
+    notify_print_failed: bool
+    notify_print_stopped: bool
+
+    class Config:
+        from_attributes = True
+
+
+class UserEmailPreferenceUpdate(BaseModel):
+    """Update schema for user email notification preferences."""
+
+    notify_print_start: bool
+    notify_print_complete: bool
+    notify_print_failed: bool
+    notify_print_stopped: bool

+ 25 - 11
backend/app/services/archive.py

@@ -727,10 +727,14 @@ class ArchiveService:
                 sha256.update(chunk)
         return sha256.hexdigest()
 
-    async def get_duplicate_hashes_and_names(self) -> tuple[set[str], set[str]]:
-        """Get all content hashes and print names that appear more than once.
+    async def get_duplicate_hashes_and_names(self) -> tuple[set[str], set[tuple[str, str]]]:
+        """Get all content hashes and (print name, hash) pairs that appear more than once.
 
-        Returns a tuple of (duplicate_hashes, duplicate_names).
+        For hashes: returns all hashes with > 1 archive (true duplicates).
+        For name/hash pairs: returns only pairs that have > 1 archive
+                     (i.e., same file archived multiple times, not different files with same name).
+
+        Returns a tuple of (duplicate_hashes, duplicate_name_hash_pairs).
         """
         from sqlalchemy import func
 
@@ -742,15 +746,17 @@ class ArchiveService:
         )
         duplicate_hashes = {row[0] for row in result.all()}
 
+        # Find print names that have multiple archives with the SAME hash
+        # This avoids marking different files with the same name as duplicates
         result = await self.db.execute(
-            select(func.lower(PrintArchive.print_name))
-            .where(PrintArchive.print_name.isnot(None))
-            .group_by(func.lower(PrintArchive.print_name))
+            select(func.lower(PrintArchive.print_name), PrintArchive.content_hash)
+            .where(PrintArchive.print_name.isnot(None), PrintArchive.content_hash.isnot(None))
+            .group_by(func.lower(PrintArchive.print_name), PrintArchive.content_hash)
             .having(func.count(PrintArchive.id) > 1)
         )
-        duplicate_names = {row[0] for row in result.all()}
+        duplicate_name_hash_pairs = {(row[0], row[1]) for row in result.all()}
 
-        return duplicate_hashes, duplicate_names
+        return duplicate_hashes, duplicate_name_hash_pairs
 
     async def find_duplicates(
         self,
@@ -789,15 +795,23 @@ class ArchiveService:
                 )
 
         # Then, find similar matches by print name or MakerWorld ID
+        # 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]
 
             name_conditions = []
             if print_name:
-                # Match if print names are similar (ignoring case)
-                name_conditions.append(PrintArchive.print_name.ilike(print_name))
+                if content_hash:
+                    # Match if print names are similar AND have the same hash (same file)
+                    name_conditions.append(
+                        and_(PrintArchive.print_name.ilike(print_name), PrintArchive.content_hash == content_hash)
+                    )
+                else:
+                    # Fallback for archives without hash data: match by print name only.
+                    name_conditions.append(PrintArchive.print_name.ilike(print_name))
             if makerworld_model_id:
-                # Match by MakerWorld model ID stored in extra_data
+                # Match by MakerWorld model ID stored in extra_data (same design from MakerWorld)
                 # Use json_extract for SQLite compatibility (astext is PostgreSQL-only)
                 from sqlalchemy import func
 

+ 38 - 0
backend/app/services/background_dispatch.py

@@ -672,6 +672,10 @@ class BackgroundDispatchService:
                     )
                     raise RuntimeError("Failed to start print")
 
+                pre_state = getattr(printer_manager.get_status(job.printer_id), "state", None)
+                if pre_state:
+                    asyncio.create_task(self._verify_print_response(job.printer_id, printer_name, pre_state))
+
                 if job.requested_by_user_id and job.requested_by_username:
                     printer_manager.set_current_print_user(
                         job.printer_id,
@@ -837,12 +841,46 @@ class BackgroundDispatchService:
                     await db.rollback()
                     raise RuntimeError("Failed to start print")
 
+                pre_state = getattr(printer_manager.get_status(job.printer_id), "state", None)
+                if pre_state:
+                    asyncio.create_task(self._verify_print_response(job.printer_id, printer_name, pre_state))
+
                 await db.commit()
             except DispatchJobCancelled:
                 await db.rollback()
                 await self._set_active_message(job, f"Cancelled upload on {printer_name}.")
                 raise
 
+    @staticmethod
+    async def _verify_print_response(
+        printer_id: int,
+        printer_name: str,
+        pre_state: str,
+        timeout: float = 15.0,
+        poll_interval: float = 3.0,
+    ):
+        """Check if the printer responded to a print command.
+
+        Runs as a fire-and-forget background task after start_print() succeeds.
+        If the printer's gcode_state hasn't changed within the timeout, logs a
+        warning for diagnostics (visible in support packages).
+        """
+        deadline = time.monotonic() + timeout
+        while time.monotonic() < deadline:
+            await asyncio.sleep(poll_interval)
+            state = printer_manager.get_status(printer_id)
+            if not state:
+                return  # Printer disconnected
+            if state.state != pre_state:
+                return  # Printer responded
+        logger.warning(
+            "Printer %s (%d) did not respond to print command within %.0fs (state still %s) — printer may need restart",
+            printer_name,
+            printer_id,
+            timeout,
+            pre_state,
+        )
+
     @staticmethod
     async def _cleanup_sd_card_file(
         printer_ip: str,

+ 28 - 5
backend/app/services/bambu_mqtt.py

@@ -265,6 +265,10 @@ class BambuMQTTClient:
 
     MQTT_PORT = 8883
 
+    # Class-level cache: serial_number -> False when request topic is known unsupported.
+    # Persists across client instances so reconnects don't re-trigger failed subscriptions.
+    _request_topic_cache: dict[str, bool] = {}
+
     def __init__(
         self,
         ip_address: str,
@@ -332,9 +336,10 @@ class BambuMQTTClient:
         self._captured_ams_mapping: list[int] | None = None
 
         # Request topic subscription tracking
-        # Some printer MQTT brokers (e.g. P1S) reject subscriptions to the request
+        # Some printer MQTT brokers (e.g. P1S, A1) reject subscriptions to the request
         # topic by killing the TCP connection. We detect this and gracefully degrade.
-        self._request_topic_supported: bool = True
+        # Check class-level cache first so new client instances don't retry known-bad subscriptions.
+        self._request_topic_supported: bool = BambuMQTTClient._request_topic_cache.get(self.serial_number, True)
         self._request_topic_sub_mid: int | None = None
         self._request_topic_sub_time: float = 0.0
         self._request_topic_confirmed: bool = False
@@ -387,6 +392,7 @@ class BambuMQTTClient:
                         self.serial_number,
                     )
                     self._request_topic_supported = False
+                    BambuMQTTClient._request_topic_cache[self.serial_number] = False
             # Request full status update (includes nozzle info in push_status response)
             self._request_push_all()
             # Request firmware version info
@@ -414,6 +420,7 @@ class BambuMQTTClient:
                         rc.getName(),
                     )
                     self._request_topic_supported = False
+                    BambuMQTTClient._request_topic_cache[self.serial_number] = False
                 else:
                     logger.info(
                         "[%s] Request topic subscription accepted. "
@@ -421,6 +428,7 @@ class BambuMQTTClient:
                         self.serial_number,
                     )
                     self._request_topic_confirmed = True
+                    BambuMQTTClient._request_topic_cache[self.serial_number] = True
             self._request_topic_sub_mid = None
             self._request_topic_sub_time = 0.0
 
@@ -449,6 +457,7 @@ class BambuMQTTClient:
                 self.serial_number,
             )
             self._request_topic_supported = False
+            BambuMQTTClient._request_topic_cache[self.serial_number] = False
         self._request_topic_sub_mid = None
         self._request_topic_sub_time = 0.0
 
@@ -1399,8 +1408,11 @@ 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)
         tray_exist_bits_str = ams_data.get("tray_exist_bits") if isinstance(ams_data, dict) else None
-        if tray_exist_bits_str:
+        power_on = ams_data.get("power_on_flag", True) if isinstance(ams_data, dict) else True
+        if tray_exist_bits_str and power_on:
             try:
                 tray_exist_bits = int(tray_exist_bits_str, 16)
                 for ams_unit in merged_ams:
@@ -2404,6 +2416,14 @@ class BambuMQTTClient:
         ):
             should_trigger_completion = True
 
+        # Log when we see a terminal state but DON'T trigger completion (diagnostics)
+        if not should_trigger_completion and self.state.state in ("FINISH", "FAILED"):
+            logger.info(
+                f"[{self.serial_number}] State is {self.state.state} but completion NOT triggered: "
+                f"prev={self._previous_gcode_state}, was_running={self._was_running}, "
+                f"already_triggered={self._completion_triggered}, has_callback={bool(self.on_print_complete)}"
+            )
+
         if should_trigger_completion:
             if self.state.state == "FINISH":
                 status = "completed"
@@ -2932,7 +2952,9 @@ class BambuMQTTClient:
         """Check if logging is enabled."""
         return self._logging_enabled
 
-    def send_drying_command(self, ams_id: int, temp: int, duration: int, mode: int = 1, filament: str = ""):
+    def send_drying_command(
+        self, ams_id: int, temp: int, duration: int, mode: int = 1, filament: str = "", rotate_tray: bool = False
+    ):
         """Send AMS drying start/stop command.
 
         Args:
@@ -2941,6 +2963,7 @@ class BambuMQTTClient:
             duration: Drying duration in hours
             mode: 1=start, 0=stop
             filament: Filament type string (e.g. "PLA", "PETG")
+            rotate_tray: Whether to rotate the spool during drying for even heat
         """
         if not self._client:
             return False
@@ -2955,7 +2978,7 @@ class BambuMQTTClient:
                 "duration": duration,
                 "humidity": 0,
                 "mode": mode,
-                "rotate_tray": False,
+                "rotate_tray": rotate_tray,
                 "filament": filament,
                 "close_power_conflict": False,
             }

+ 66 - 0
backend/app/services/email_service.py

@@ -8,6 +8,7 @@ import re
 import secrets
 import smtplib
 import string
+from datetime import datetime, timezone
 from email.mime.multipart import MIMEMultipart
 from email.mime.text import MIMEText
 from typing import Any
@@ -514,3 +515,68 @@ async def create_password_reset_email_from_template(
         # Fallback to hardcoded template
         logger.warning("No password reset email template found in database, using default")
         return create_password_reset_email(username, password, login_url)
+
+
+async def send_user_print_notification(
+    db: AsyncSession,
+    event_type: str,
+    user_email: str,
+    username: str,
+    variables: dict,
+) -> None:
+    """Send a print notification email to a user using Advanced Auth SMTP settings.
+
+    Args:
+        db: Database session
+        event_type: One of 'user_print_start', 'user_print_complete', 'user_print_failed', 'user_print_stopped'
+        user_email: Recipient email address
+        username: Username of the recipient
+        variables: Template variables (printer, filename, etc.)
+    """
+    # Check that advanced auth is enabled (SMTP settings must be configured)
+    smtp_settings = await get_smtp_settings(db)
+    if not smtp_settings:
+        logger.warning("Cannot send user print notification: SMTP settings not configured")
+        return
+
+    # Get the template
+    template = await get_notification_template(db, event_type)
+    if template is None:
+        logger.warning("No template found for event type: %s", event_type)
+        return
+
+    # Add common variables (username, timestamp, app_name) merged with caller-supplied variables
+    all_variables = {
+        "username": username,
+        "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
+        "app_name": "Bambuddy",
+        **variables,
+    }
+
+    subject = render_template(template.title_template, all_variables)
+    text_body = render_template(template.body_template, all_variables)
+
+    # Build HTML body — content comes entirely from the database template
+    escaped_text_body = html.escape(text_body).replace("\n", "<br>\n")
+    html_body = f"""<!DOCTYPE html>
+<html>
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+</head>
+<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
+    <div style="background: linear-gradient(135deg, #1db954 0%, #158a3e 100%); background-color: #1db954; padding: 20px; border-radius: 8px 8px 0 0;">
+        <h1 style="color: #ffffff; margin: 0; font-size: 24px; text-shadow: 0 1px 2px rgba(0,0,0,0.3);">{html.escape(subject)}</h1>
+    </div>
+    <div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;">
+        <div style="font-size: 16px;">{escaped_text_body}</div>
+    </div>
+</body>
+</html>
+"""
+
+    try:
+        send_email(smtp_settings, user_email, subject, text_body, html_body)
+        logger.info("Sent %s notification email to %s", event_type, user_email)
+    except Exception as e:
+        logger.error("Failed to send %s notification to %s: %s", event_type, user_email, e)

+ 164 - 10
backend/app/services/notification_service.py

@@ -217,7 +217,11 @@ class NotificationService:
             return False, "Topic is required"
 
         url = f"{server}/{topic}"
-        headers = {"Title": title}
+        # ntfy reads Title/Message from HTTP headers. httpx enforces ASCII
+        # for str header values, but printer names and filenames can contain
+        # non-ASCII characters (e.g. accented letters, CJK). Passing bytes
+        # bypasses the ASCII check — ntfy handles UTF-8 headers correctly.
+        headers: dict[str, str | bytes] = {"Title": title.encode("utf-8")}
 
         if auth_token:
             headers["Authorization"] = f"Bearer {auth_token}"
@@ -229,16 +233,16 @@ class NotificationService:
             # HTTP headers cannot contain newlines, but ntfy interprets
             # literal \n (backslash-n) as newlines in the Message header.
             headers["Filename"] = "photo.jpg"
-            headers["Message"] = message.replace("\n", "\\n")
+            headers["Message"] = message.replace("\n", "\\n").encode("utf-8")
             response = await client.put(url, content=image_data, headers=headers)
 
             if response.status_code == 400 and "attachments not allowed" in response.text:
                 # Server has attachments disabled — retry without the image
                 headers.pop("Filename", None)
                 headers.pop("Message", None)
-                response = await client.post(url, content=message, headers=headers)
+                response = await client.post(url, content=message.encode("utf-8"), headers=headers)
         else:
-            response = await client.post(url, content=message, headers=headers)
+            response = await client.post(url, content=message.encode("utf-8"), headers=headers)
 
         if response.status_code in (200, 204):
             return True, "Message sent successfully"
@@ -425,7 +429,9 @@ class NotificationService:
         else:
             return False, f"HTTP {response.status_code}: {response.text[:200]}"
 
-    async def _send_webhook(self, config: dict, title: str, message: str) -> tuple[bool, str]:
+    async def _send_webhook(
+        self, config: dict, title: str, message: str, image_data: bytes | None = None
+    ) -> tuple[bool, str]:
         """Send notification via generic webhook (POST JSON).
 
         Supports two payload formats:
@@ -454,6 +460,12 @@ class NotificationService:
                 "source": "Bambuddy",
             }
 
+        # Attach base64-encoded image when available (generic format only)
+        if image_data and payload_format != "slack":
+            import base64
+
+            data["image"] = base64.b64encode(image_data).decode("ascii")
+
         headers = {"Content-Type": "application/json"}
         if auth_header:
             # Support "Bearer token" or just "token" format
@@ -476,10 +488,11 @@ class NotificationService:
     async def _send_homeassistant(
         self, config: dict, title: str, message: str, db: AsyncSession | None = None
     ) -> tuple[bool, str]:
-        """Send notification via Home Assistant persistent notifications.
+        """Send notification via Home Assistant.
 
-        Uses the globally configured HA URL/token from settings,
-        and calls POST /api/services/persistent_notification/create.
+        Uses the globally configured HA URL/token from settings.
+        Defaults to persistent_notification/create, but supports
+        custom services via config["service"] (e.g. notify.mobile_app_myphone).
         """
         # Get HA connection settings from global config
         ha_url = ""
@@ -506,7 +519,34 @@ class NotificationService:
                 "Home Assistant is not configured. Please set HA URL and token in Settings → Network → Home Assistant."
             )
 
-        url = f"{ha_url.rstrip('/')}/api/services/persistent_notification/create"
+        # Determine which HA service to call - Default: persistent_notification.create
+        service = (config.get("service") or "").strip()
+        if service:
+            # Allow in different forms:
+            # - notify.mobile_app_<device>
+            # - notify/mobile_app_<device>
+            # - api/services/notify/mobile_app_<device>
+            service_str = service.lstrip("/")
+            if service_str.startswith("api/services/"):
+                endpoint = service_str
+            elif "/" in service_str:
+                endpoint = f"api/services/{service_str}"
+            elif "." in service_str:
+                domain, svc = service_str.split(".", 1)
+                endpoint = f"api/services/{domain}/{svc}"
+            else:
+                return False, (
+                    "Invalid Home Assistant service name. Use e.g. 'notify.mobile_app_yourdevice' or 'notify/your_service'."
+                )
+
+            if not re.match(r"^api/services/[a-zA-Z0-9_]+/[a-zA-Z0-9_]+$", endpoint):
+                return False, (
+                    "Invalid Home Assistant service name. Domain and service must only contain letters, numbers, and underscores."
+                )
+        else:
+            endpoint = "api/services/persistent_notification/create"
+
+        url = f"{ha_url.rstrip('/')}/{endpoint}"
         headers = {
             "Authorization": f"Bearer {ha_token}",
             "Content-Type": "application/json",
@@ -556,7 +596,7 @@ class NotificationService:
             elif provider.provider_type == "discord":
                 return await self._send_discord(config, title, message, image_data=image_data)
             elif provider.provider_type == "webhook":
-                return await self._send_webhook(config, title, message)
+                return await self._send_webhook(config, title, message, image_data=image_data)
             elif provider.provider_type == "homeassistant":
                 return await self._send_homeassistant(config, title, message, db=db)
             else:
@@ -1171,6 +1211,120 @@ class NotificationService:
         """Clear the template cache. Call this when templates are updated."""
         self._template_cache.clear()
 
+    async def send_user_print_email(
+        self,
+        event_type: str,
+        created_by_id: int | None,
+        printer_name: str,
+        filename: str,
+        db: AsyncSession,
+    ) -> None:
+        """Send a print event email notification to the user who submitted the job.
+
+        Args:
+            event_type: 'user_print_start', 'user_print_complete', 'user_print_failed', or 'user_print_stopped'
+            created_by_id: User ID who submitted the print job (from archive)
+            printer_name: Name of the printer
+            filename: Raw filename or subtask name
+            db: Database session
+        """
+        if created_by_id is None:
+            logger.debug("[EMAIL] Skipping user print email (%s): no created_by_id", event_type)
+            return
+
+        try:
+            # Check if advanced auth is enabled - required for user email notifications
+            from backend.app.models.settings import Settings
+
+            result = await db.execute(select(Settings).where(Settings.key == "advanced_auth_enabled"))
+            setting = result.scalar_one_or_none()
+            if not setting or setting.value.lower() != "true":
+                logger.debug("[EMAIL] Skipping user print email (%s): advanced_auth not enabled", event_type)
+                return
+
+            # Check if user notifications are enabled (admin-controlled toggle)
+            notif_enabled_result = await db.execute(
+                select(Settings).where(Settings.key == "user_notifications_enabled")
+            )
+            notif_enabled_setting = notif_enabled_result.scalar_one_or_none()
+            if notif_enabled_setting and notif_enabled_setting.value.lower() == "false":
+                logger.debug("[EMAIL] Skipping user print email (%s): user_notifications_enabled is false", event_type)
+                return
+
+            # Check SMTP settings are configured - required for sending emails
+            from backend.app.services.email_service import get_smtp_settings, send_user_print_notification
+
+            smtp_settings = await get_smtp_settings(db)
+            if not smtp_settings:
+                logger.debug("[EMAIL] Skipping user print email (%s): SMTP settings not configured", event_type)
+                return
+
+            # Load user preferences
+            from backend.app.models.user import User
+            from backend.app.models.user_email_pref import UserEmailPreference
+
+            user_result = await db.execute(select(User).where(User.id == created_by_id))
+            user = user_result.scalar_one_or_none()
+            if user is None or not user.email:
+                logger.debug(
+                    "[EMAIL] Skipping user print email (%s): user %s not found or has no email address",
+                    event_type,
+                    created_by_id,
+                )
+                return
+
+            # Load user's notification preferences
+            pref_result = await db.execute(
+                select(UserEmailPreference).where(UserEmailPreference.user_id == created_by_id)
+            )
+            pref = pref_result.scalar_one_or_none()
+
+            # Determine if this event type should be sent
+            should_send = False
+            if event_type == "user_print_start":
+                should_send = pref is None or pref.notify_print_start
+            elif event_type == "user_print_complete":
+                should_send = pref is None or pref.notify_print_complete
+            elif event_type == "user_print_failed":
+                should_send = pref is None or pref.notify_print_failed
+            elif event_type == "user_print_stopped":
+                should_send = pref is None or pref.notify_print_stopped
+
+            if not should_send:
+                logger.debug(
+                    "[EMAIL] Skipping user print email (%s): user %s has notifications disabled for this event",
+                    event_type,
+                    created_by_id,
+                )
+                return
+
+            logger.info(
+                "[EMAIL] Sending user print email: event=%s, user=%s (%s), printer=%s, file=%s",
+                event_type,
+                user.username,
+                user.email,
+                printer_name,
+                filename,
+            )
+
+            # Build variables
+            variables = {
+                "printer": printer_name,
+                "filename": self._clean_filename(filename),
+            }
+
+            # Send the email
+            await send_user_print_notification(
+                db=db,
+                event_type=event_type,
+                user_email=user.email,
+                username=user.username,
+                variables=variables,
+            )
+            logger.info("[EMAIL] User print email sent: event=%s → %s", event_type, user.email)
+        except Exception as e:
+            logger.warning("Failed to send user print email notification: %s", e, exc_info=True)
+
     # ==================== Queue Notifications ====================
 
     async def on_queue_job_added(

+ 21 - 2
backend/app/services/print_scheduler.py

@@ -258,7 +258,8 @@ class PrintScheduler:
                         await db.commit()
 
                         # Send waiting notification only when transitioning to waiting state
-                        if waiting_reason and not was_waiting:
+                        # and the reason requires user action (not just "all printers busy")
+                        if waiting_reason and not was_waiting and not self._is_busy_only(waiting_reason):
                             job_name = await self._get_job_name(db, item)
                             await notification_service.on_queue_job_waiting(
                                 job_name=job_name,
@@ -518,6 +519,17 @@ class PrintScheduler:
 
         return None, " | ".join(reasons) if reasons else f"No available {model} printers{location_suffix}"
 
+    @staticmethod
+    def _is_busy_only(waiting_reason: str) -> bool:
+        """Check if the waiting reason only contains 'Busy' entries.
+
+        When all matching printers are simply busy printing, the queued job
+        will start automatically once a printer finishes — no user action
+        is required, so we skip the notification.
+        """
+        parts = [p.strip() for p in waiting_reason.split(" | ")]
+        return all(p.startswith("Busy:") for p in parts)
+
     def _get_missing_force_color_slots(self, printer_id: int, force_overrides: list[dict]) -> list[str]:
         """Return descriptive strings for force_color_match slots not satisfied by the printer.
 
@@ -1517,6 +1529,7 @@ class PrintScheduler:
                     printer_id=item.printer_id,
                     source_file=file_path,
                     original_filename=filename,
+                    created_by_id=item.created_by_id,
                 )
                 if archive:
                     item.archive_id = archive.id
@@ -1650,7 +1663,13 @@ class PrintScheduler:
         if archive:
             from backend.app.main import register_expected_print
 
-            register_expected_print(item.printer_id, remote_filename, archive.id, ams_mapping=ams_mapping)
+            register_expected_print(
+                item.printer_id,
+                remote_filename,
+                archive.id,
+                ams_mapping=ams_mapping,
+                created_by_id=item.created_by_id,
+            )
 
         # IMPORTANT: Set status to "printing" BEFORE sending the print command.
         # This prevents phantom reprints if the backend crashes/restarts after the

+ 2 - 1
backend/app/services/printer_manager.py

@@ -452,11 +452,12 @@ class PrinterManager:
         duration: int,
         mode: int = 1,
         filament: str = "",
+        rotate_tray: bool = False,
     ) -> bool:
         """Send AMS drying command to printer."""
         if printer_id not in self._clients:
             return False
-        return self._clients[printer_id].send_drying_command(ams_id, temp, duration, mode, filament)
+        return self._clients[printer_id].send_drying_command(ams_id, temp, duration, mode, filament, rotate_tray)
 
     def request_status_update(self, printer_id: int) -> bool:
         """Request a full status update from the printer.

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

@@ -9,6 +9,7 @@ import httpx
 
 logger = logging.getLogger(__name__)
 
+BAMBU_RFID_TAG_LENGTH = 32
 
 @dataclass
 class SpoolmanSpool:
@@ -553,6 +554,10 @@ class SpoolmanClient:
             else:
                 spool_uuid = ""
 
+            # Only clear location for Bambu Lab spools (those with a stored 32-character RFID tag).
+            if len(spool_uuid) != BAMBU_RFID_TAG_LENGTH:
+                continue
+
             # If this spool's UUID is not in the current AMS, clear its location
             if spool_uuid not in current_tray_uuids:
                 logger.info(

+ 22 - 10
backend/app/services/virtual_printer/ftp_server.py

@@ -39,6 +39,7 @@ class FTPSession:
         passive_port_range: tuple[int, int] = (50000, 50100),
         pasv_address: str = "",
         bind_address: str = "0.0.0.0",  # nosec B104
+        vp_name: str = "",
     ):
         self.reader = reader
         self.writer = writer
@@ -49,6 +50,8 @@ class FTPSession:
         self.passive_port_range = passive_port_range
         self.pasv_address = pasv_address
         self.bind_address = bind_address
+        self.vp_name = vp_name
+        self._log_prefix = f"[{vp_name}] " if vp_name else ""
 
         self.authenticated = False
         self.username: str | None = None
@@ -69,7 +72,7 @@ class FTPSession:
     async def send(self, code: int, message: str) -> None:
         """Send an FTP response."""
         response = f"{code} {message}\r\n"
-        logger.info("FTP -> %s: %s", self.remote_ip, response.strip())
+        logger.debug("%sFTP -> %s: %s", self._log_prefix, self.remote_ip, response.strip())
         self.writer.write(response.encode("utf-8"))
         await self.writer.drain()
 
@@ -86,7 +89,7 @@ class FTPSession:
                         timeout=300,  # 5 minute timeout
                     )
                 except TimeoutError:
-                    logger.debug("FTP session timeout from %s", self.remote_ip)
+                    logger.debug("%sFTP session timeout from %s", self._log_prefix, self.remote_ip)
                     break
 
                 if not line:
@@ -100,7 +103,11 @@ class FTPSession:
                 if not command_line:
                     continue
 
-                logger.info("FTP <- %s: %s", self.remote_ip, command_line)
+                # Never log passwords
+                if command_line.upper().startswith("PASS"):
+                    logger.debug("%sFTP <- %s: PASS ********", self._log_prefix, self.remote_ip)
+                else:
+                    logger.debug("%sFTP <- %s: %s", self._log_prefix, self.remote_ip, command_line)
 
                 # Parse command and argument
                 parts = command_line.split(" ", 1)
@@ -112,15 +119,15 @@ class FTPSession:
                 if handler:
                     await handler(arg)
                 else:
-                    logger.warning("FTP command not implemented: %s", cmd)
+                    logger.debug("%sFTP command not implemented: %s", self._log_prefix, cmd)
                     await self.send(502, f"Command {cmd} not implemented")
 
         except asyncio.CancelledError:
-            logger.info("FTP session cancelled from %s", self.remote_ip)
+            logger.info("%sFTP session cancelled from %s", self._log_prefix, self.remote_ip)
         except Exception as e:
-            logger.error("FTP session error from %s: %s", self.remote_ip, e)
+            logger.error("%sFTP session error from %s: %s", self._log_prefix, self.remote_ip, e)
         finally:
-            logger.info("FTP session ended from %s", self.remote_ip)
+            logger.info("%sFTP session ended from %s", self._log_prefix, self.remote_ip)
             await self._cleanup()
 
     async def _cleanup(self) -> None:
@@ -158,10 +165,10 @@ class FTPSession:
             if arg == self.access_code:
                 self.authenticated = True
                 await self.send(230, "Login successful")
-                logger.info("FTP login from %s", self.remote_ip)
+                logger.info("%sFTP login from %s", self._log_prefix, self.remote_ip)
             else:
                 await self.send(530, "Login incorrect")
-                logger.warning("FTP failed login from %s", self.remote_ip)
+                logger.warning("%sFTP failed login from %s (access code mismatch)", self._log_prefix, self.remote_ip)
         else:
             await self.send(503, "Login with USER first")
 
@@ -531,6 +538,7 @@ class VirtualPrinterFTPServer:
         port: int = FTP_PORT,
         on_file_received: Callable[[Path, str], None] | None = None,
         bind_address: str = "0.0.0.0",  # nosec B104
+        vp_name: str = "",
     ):
         """Initialize the FTPS server.
 
@@ -542,6 +550,7 @@ class VirtualPrinterFTPServer:
             port: Port to listen on (default 990)
             on_file_received: Callback when file upload completes (path, source_ip)
             bind_address: IP address to bind to (default 0.0.0.0)
+            vp_name: Virtual printer name for log identification
         """
         self.upload_dir = upload_dir
         self.access_code = access_code
@@ -550,6 +559,7 @@ class VirtualPrinterFTPServer:
         self.port = port
         self.on_file_received = on_file_received
         self.bind_address = bind_address
+        self.vp_name = vp_name
         self._server: asyncio.Server | None = None
         self._running = False
         self._ssl_context: ssl.SSLContext | None = None
@@ -617,7 +627,8 @@ class VirtualPrinterFTPServer:
     async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
         """Handle a new FTP client connection."""
         peername = writer.get_extra_info("peername")
-        logger.info("FTP connection from %s", peername)
+        log_prefix = f"[{self.vp_name}] " if self.vp_name else ""
+        logger.info("%sFTP connection from %s", log_prefix, peername)
 
         session = FTPSession(
             reader=reader,
@@ -629,6 +640,7 @@ class VirtualPrinterFTPServer:
             passive_port_range=(self.PASSIVE_PORT_MIN, self.PASSIVE_PORT_MAX),
             pasv_address=self._pasv_address,
             bind_address=self.bind_address,
+            vp_name=self.vp_name,
         )
 
         # Track the session task so we can cancel it on stop

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

@@ -385,6 +385,7 @@ class VirtualPrinterInstance:
             key_path=key_path,
             on_file_received=self.on_file_received,
             bind_address=bind_addr,
+            vp_name=self.name,
         )
         self._tasks.append(
             asyncio.create_task(
@@ -402,6 +403,7 @@ class VirtualPrinterInstance:
             on_print_command=self.on_print_command,
             model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
             bind_address=bind_addr,
+            vp_name=self.name,
         )
         self._tasks.append(
             asyncio.create_task(
@@ -471,6 +473,12 @@ class VirtualPrinterInstance:
             key_path=key_path,
             on_activity=lambda n, m: logger.info("[VP %s] Proxy %s: %s", self.name, n, m),
             bind_address=self.bind_ip or "0.0.0.0",  # nosec B104
+            bind_identity={
+                "serial": self.target_printer_serial or self.serial,
+                "model": self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
+                "name": self.name,
+                "version": "01.00.00.00",
+            },
         )
 
         async def run_with_logging(coro, svc_name):
@@ -492,6 +500,7 @@ class VirtualPrinterInstance:
                     local_interface_ip=local_iface["ip"],
                     remote_interface_ip=self.remote_interface_ip,
                     target_printer_ip=self.target_printer_ip,
+                    name=self.name,
                 )
                 self._tasks.append(
                     asyncio.create_task(

+ 9 - 6
backend/app/services/virtual_printer/mqtt_server.py

@@ -186,6 +186,7 @@ class SimpleMQTTServer:
         on_print_command: Callable[[str, dict], None] | None = None,
         model: str = "",
         bind_address: str = "0.0.0.0",  # nosec B104
+        vp_name: str = "",
     ):
         self.serial = serial
         self.access_code = access_code
@@ -195,6 +196,8 @@ class SimpleMQTTServer:
         self.port = port
         self.on_print_command = on_print_command
         self.bind_address = bind_address
+        self.vp_name = vp_name
+        self._log_prefix = f"[{vp_name}] " if vp_name else ""
         self._running = False
         self._server = None
         self._clients: dict[str, asyncio.StreamWriter] = {}
@@ -249,10 +252,10 @@ class SimpleMQTTServer:
                     ssl_obj = writer.get_extra_info("ssl_object")
                     if ssl_obj:
                         logger.info(
-                            f"MQTT TLS connection from {addr} - cipher={ssl_obj.cipher()}, version={ssl_obj.version()}"
+                            f"{self._log_prefix}MQTT TLS connection from {addr} - cipher={ssl_obj.cipher()}, version={ssl_obj.version()}"
                         )
                     else:
-                        logger.info("MQTT connection from %s (no TLS?)", addr)
+                        logger.info("%sMQTT connection from %s (no TLS?)", self._log_prefix, addr)
                     await self._handle_client(reader, writer)
                 except ssl.SSLError as e:
                     logger.error("MQTT SSL error: %s", e)
@@ -351,7 +354,7 @@ class SimpleMQTTServer:
         """Handle an MQTT client connection."""
         addr = writer.get_extra_info("peername")
         client_id = f"{addr[0]}:{addr[1]}" if addr else "unknown"
-        logger.info("MQTT client connected: %s", client_id)
+        logger.info("%sMQTT client connected: %s", self._log_prefix, client_id)
 
         authenticated = False
 
@@ -471,7 +474,7 @@ class SimpleMQTTServer:
                 # Send CONNACK with success
                 writer.write(bytes([0x20, 0x02, 0x00, 0x00]))
                 await writer.drain()
-                logger.info("MQTT client authenticated successfully")
+                logger.info("%sMQTT client authenticated successfully", self._log_prefix)
 
                 # Send immediate status report after auth - slicer expects this
                 await self._send_status_report(writer)
@@ -480,7 +483,7 @@ class SimpleMQTTServer:
                 # Send CONNACK with auth failure
                 writer.write(bytes([0x20, 0x02, 0x00, 0x05]))  # Not authorized
                 await writer.drain()
-                logger.warning("MQTT auth failed for user '%s'", username)
+                logger.warning("%sMQTT auth failed for user '%s' (access code mismatch)", self._log_prefix, username)
                 return False
 
         except (IndexError, ValueError) as e:
@@ -507,7 +510,7 @@ class SimpleMQTTServer:
                 requested_qos = payload[idx]
                 idx += 1
 
-                logger.info("MQTT subscribe: %s QoS=%s", topic, requested_qos)
+                logger.info("%sMQTT subscribe: %s QoS=%s", self._log_prefix, topic, requested_qos)
                 granted_qos.append(min(requested_qos, 1))  # Grant up to QoS 1
 
             # Send SUBACK

+ 96 - 27
backend/app/services/virtual_printer/ssdp_server.py

@@ -35,6 +35,7 @@ class VirtualPrinterSSDPServer:
         model: str = "BL-P001",  # X1C model code for best compatibility
         advertise_ip: str = "",
         bind_ip: str = "",
+        extra_interfaces: list[str] | None = None,
     ):
         """Initialize the SSDP server.
 
@@ -44,6 +45,10 @@ class VirtualPrinterSSDPServer:
             model: Model code
             advertise_ip: Override IP to advertise instead of auto-detecting
             bind_ip: IP address to bind the SSDP socket to
+            extra_interfaces: Additional interface IPs to broadcast on (e.g. VPN).
+                NOTIFY and M-SEARCH responses are sent on these interfaces too,
+                but Location always points to the bind IP so the slicer connects
+                to the correct address for MQTT/FTP.
         """
         self.name = name
         self.serial = serial
@@ -51,6 +56,8 @@ class VirtualPrinterSSDPServer:
         self._bind_ip = bind_ip
         self._running = False
         self._socket: socket.socket | None = None
+        self._extra_sockets: list[socket.socket] = []
+        self._extra_interfaces = extra_interfaces or []
         self._local_ip: str | None = advertise_ip or bind_ip or None
 
     def _get_local_ip(self) -> str:
@@ -163,6 +170,30 @@ class VirtualPrinterSSDPServer:
             logger.info("SSDP server listening on port %s, advertising IP: %s", SSDP_PORT, local_ip)
             logger.info("Virtual printer: %s (%s) model=%s", self.name, self.serial, self.model)
 
+            # Create extra sockets for additional interfaces (VPN, etc.)
+            # If no explicit extra interfaces given and we're bound to a
+            # specific IP, add a wildcard socket to catch M-SEARCH from
+            # other subnets (VPN tunnels, secondary NICs, etc.)
+            extra_ips = list(self._extra_interfaces)
+            if not extra_ips and self._bind_ip:
+                extra_ips.append("0.0.0.0")  # nosec B104
+
+            for iface_ip in extra_ips:
+                try:
+                    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
+                    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+                    try:
+                        sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
+                    except (AttributeError, OSError):
+                        pass
+                    sock.setblocking(False)
+                    sock.bind((iface_ip, SSDP_PORT))
+                    sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+                    self._extra_sockets.append(sock)
+                    logger.info("SSDP server also listening on %s:%s", iface_ip, SSDP_PORT)
+                except OSError as e:
+                    logger.warning("SSDP server: failed to bind extra interface %s: %s", iface_ip, e)
+
             # Send initial NOTIFY
             await self._send_notify()
             logger.info("Sent initial SSDP NOTIFY announcement")
@@ -172,7 +203,7 @@ class VirtualPrinterSSDPServer:
             notify_interval = 30.0  # Send NOTIFY every 30 seconds
 
             while self._running:
-                # Try to receive M-SEARCH requests
+                # Try to receive M-SEARCH requests on primary socket
                 try:
                     data, addr = self._socket.recvfrom(4096)
                     message = data.decode("utf-8", errors="ignore")
@@ -183,6 +214,17 @@ class VirtualPrinterSSDPServer:
                     if self._running:
                         logger.debug("SSDP receive error: %s", e)
 
+                # Try to receive M-SEARCH requests on extra sockets
+                for sock in self._extra_sockets:
+                    try:
+                        data, addr = sock.recvfrom(4096)
+                        message = data.decode("utf-8", errors="ignore")
+                        await self._handle_message(message, addr, sock)
+                    except BlockingIOError:
+                        pass
+                    except OSError:
+                        pass
+
                 # Send periodic NOTIFY
                 now = asyncio.get_event_loop().time()
                 if now - last_notify >= notify_interval:
@@ -224,23 +266,35 @@ class VirtualPrinterSSDPServer:
                 pass  # Best-effort socket close; may already be released
             self._socket = None
 
+        for sock in self._extra_sockets:
+            try:
+                sock.close()
+            except OSError:
+                pass
+        self._extra_sockets = []
+
     async def _send_notify(self) -> None:
-        """Send SSDP NOTIFY message via broadcast."""
-        if not self._socket:
-            return
+        """Send SSDP NOTIFY message via broadcast on all sockets."""
+        msg = self._build_notify_message()
 
-        try:
-            msg = self._build_notify_message()
-            self._socket.sendto(msg, (SSDP_BROADCAST_ADDR, SSDP_PORT))
-            logger.debug(
-                "Sent SSDP NOTIFY for %s (Location=%s, USN=%s, bind=%s)",
-                self.name,
-                self._get_local_ip(),
-                self.serial,
-                self._bind_ip,
-            )
-        except OSError as e:
-            logger.debug("Failed to send NOTIFY for %s: %s", self.name, e)
+        if self._socket:
+            try:
+                self._socket.sendto(msg, (SSDP_BROADCAST_ADDR, SSDP_PORT))
+                logger.debug(
+                    "Sent SSDP NOTIFY for %s (Location=%s, USN=%s, bind=%s)",
+                    self.name,
+                    self._get_local_ip(),
+                    self.serial,
+                    self._bind_ip,
+                )
+            except OSError as e:
+                logger.debug("Failed to send NOTIFY for %s: %s", self.name, e)
+
+        for sock in self._extra_sockets:
+            try:
+                sock.sendto(msg, (SSDP_BROADCAST_ADDR, SSDP_PORT))
+            except OSError:
+                pass  # Best-effort broadcast on extra interfaces
 
     async def _send_byebye(self) -> None:
         """Send SSDP byebye message when shutting down."""
@@ -262,12 +316,15 @@ class VirtualPrinterSSDPServer:
         except OSError:
             pass  # Best-effort byebye send; network may be unavailable during shutdown
 
-    async def _handle_message(self, message: str, addr: tuple[str, int]) -> None:
+    async def _handle_message(
+        self, message: str, addr: tuple[str, int], reply_socket: socket.socket | None = None
+    ) -> None:
         """Handle incoming SSDP message.
 
         Args:
             message: The SSDP message content
             addr: Tuple of (ip_address, port) of sender
+            reply_socket: Socket to send the response on (defaults to primary)
         """
         # Check if this is an M-SEARCH request for Bambu printers
         if "M-SEARCH" not in message:
@@ -279,11 +336,12 @@ class VirtualPrinterSSDPServer:
 
         logger.debug("Received M-SEARCH from %s", addr[0])
 
-        # Send response
-        if self._socket:
+        # Send response on the socket that received the request
+        sock = reply_socket or self._socket
+        if sock:
             try:
                 response = self._build_response_message()
-                self._socket.sendto(response, addr)
+                sock.sendto(response, addr)
                 logger.info(
                     "Sent SSDP response to %s for '%s' (Location=%s, USN=%s)",
                     addr[0],
@@ -310,6 +368,7 @@ class SSDPProxy:
         local_interface_ip: str,
         remote_interface_ip: str,
         target_printer_ip: str,
+        name: str | None = None,
     ):
         """Initialize the SSDP proxy.
 
@@ -317,10 +376,12 @@ class SSDPProxy:
             local_interface_ip: IP of interface on printer's network (LAN A)
             remote_interface_ip: IP of interface on slicer's network (LAN B)
             target_printer_ip: IP of the real printer to proxy SSDP for
+            name: Optional VP name to advertise (replaces printer's real name)
         """
         self.local_interface_ip = local_interface_ip
         self.remote_interface_ip = remote_interface_ip
         self.target_printer_ip = target_printer_ip
+        self.proxy_name = name
         self._running = False
         self._local_socket: socket.socket | None = None
         self._remote_socket: socket.socket | None = None
@@ -365,13 +426,21 @@ class SSDPProxy:
                 text,
                 flags=re.IGNORECASE,
             )
-            # Append " - Proxy" to printer name so it's distinguishable
-            text = re.sub(
-                r"(DevName\.bambu\.com:\s*)(.+)",
-                r"\g<1>\g<2> - Proxy",
-                text,
-                flags=re.IGNORECASE,
-            )
+            # Replace printer name with configured VP name, or append " - Proxy"
+            if self.proxy_name:
+                text = re.sub(
+                    r"(DevName\.bambu\.com:\s*)[^\r\n]+",
+                    rf"\g<1>{self.proxy_name}",
+                    text,
+                    flags=re.IGNORECASE,
+                )
+            else:
+                text = re.sub(
+                    r"(DevName\.bambu\.com:\s*)([^\r\n]+)",
+                    r"\g<1>\g<2> - Proxy",
+                    text,
+                    flags=re.IGNORECASE,
+                )
             if text != original:
                 logger.debug("Rewrote SSDP for proxy:\n%s", text)
             else:

+ 614 - 84
backend/app/services/virtual_printer/tcp_proxy.py

@@ -1,15 +1,17 @@
-"""TLS proxy for slicer-to-printer communication.
+"""Proxy for slicer-to-printer communication.
 
-This module provides a TLS terminating proxy that forwards data between
-a slicer and a real Bambu printer, enabling remote printing over
-any network connection.
+This module provides both transparent TCP proxying and TLS-terminating
+proxying for forwarding data between a slicer and a real Bambu printer,
+enabling remote printing over any network connection.
 
-Unlike a transparent TCP proxy, this terminates TLS on both ends:
-- Slicer connects to Bambuddy using Bambuddy's certificate
-- Bambuddy connects to printer using printer's certificate
-- Data is decrypted, forwarded, and re-encrypted
+Most protocols (FTP, FileTransfer, Camera) use transparent TCP proxying —
+raw bytes are forwarded without decryption, preserving end-to-end TLS
+between slicer and printer. Only MQTT is TLS-terminated so Bambuddy can
+rewrite the printer's real IP with the proxy's bind IP in MQTT payloads.
 """
 
+# ruff: noqa: N801
+
 import asyncio
 import logging
 import random
@@ -22,6 +24,44 @@ from pathlib import Path
 logger = logging.getLogger(__name__)
 
 
+class _SessionReuseSSLContext:
+    """Proxy around SSLContext that injects a TLS session into wrap_bio().
+
+    vsFTPd (used by some Bambu printers like X1C) requires TLS session reuse
+    on FTP data channels — the data connection must reuse the TLS session from
+    the control channel. Without this, the printer rejects the data connection
+    with "522 SSL connection failed: session reuse required".
+
+    asyncio's open_connection() calls SSLContext.wrap_bio() internally but
+    doesn't expose a session parameter. This wrapper intercepts wrap_bio()
+    to inject the saved control-channel session, enabling session reuse.
+    """
+
+    def __init__(self, ctx: ssl.SSLContext, session: ssl.SSLSession) -> None:
+        object.__setattr__(self, "_ctx", ctx)
+        object.__setattr__(self, "_session", session)
+
+    def __getattr__(self, name: str) -> object:
+        return getattr(self._ctx, name)
+
+    def wrap_bio(
+        self,
+        incoming: ssl.MemoryBIO,
+        outgoing: ssl.MemoryBIO,
+        server_side: bool = False,
+        server_hostname: str | None = None,
+        **kwargs: object,
+    ) -> ssl.SSLObject:
+        return self._ctx.wrap_bio(
+            incoming,
+            outgoing,
+            server_side=server_side,
+            server_hostname=server_hostname,
+            session=self._session,
+            **kwargs,
+        )
+
+
 def detect_port_redirect(port: int) -> int | None:
     """Detect if iptables redirects a port to another port.
 
@@ -82,6 +122,7 @@ class TLSProxy:
         on_connect: Callable[[str], None] | None = None,
         on_disconnect: Callable[[str], None] | None = None,
         bind_address: str = "0.0.0.0",  # nosec B104
+        rewrite_ip: tuple[str, str] | None = None,
     ):
         """Initialize the TLS proxy.
 
@@ -95,6 +136,10 @@ class TLSProxy:
             on_connect: Optional callback when client connects (receives client_id)
             on_disconnect: Optional callback when client disconnects (receives client_id)
             bind_address: IP address to bind to (default: all interfaces)
+            rewrite_ip: Optional (old_ip, new_ip) tuple — replaces occurrences of
+                the printer's real IP with the proxy's bind IP in printer→client data.
+                This prevents the slicer from discovering the printer's real IP
+                in MQTT payloads (ip_addr, rtsp_url, etc.) and bypassing the proxy.
         """
         self.name = name
         self.listen_port = listen_port
@@ -106,12 +151,41 @@ class TLSProxy:
         self.on_disconnect = on_disconnect
         self.bind_address = bind_address
 
+        # IP rewriting for printer→client direction
+        if rewrite_ip:
+            self._rewrite_old = rewrite_ip[0].encode("utf-8")
+            self._rewrite_new = rewrite_ip[1].encode("utf-8")
+            # Also rewrite the integer IP in net.info[].ip fields.
+            # Bambu printers encode their IP as a little-endian uint32 integer
+            # in the JSON payload. BambuStudio reads this to set dev_ip.
+            self._rewrite_old_int = self._ip_to_le_int_bytes(rewrite_ip[0])
+            self._rewrite_new_int = self._ip_to_le_int_bytes(rewrite_ip[1])
+        else:
+            self._rewrite_old = None
+            self._rewrite_new = None
+            self._rewrite_old_int = None
+            self._rewrite_new_int = None
+
         self._server: asyncio.Server | None = None
         self._running = False
         self._active_connections: dict[str, tuple[asyncio.Task, asyncio.Task]] = {}
         self._server_ssl_context: ssl.SSLContext | None = None
         self._client_ssl_context: ssl.SSLContext | None = None
 
+    @staticmethod
+    def _ip_to_le_int_bytes(ip: str) -> bytes:
+        """Convert an IP address to its little-endian integer JSON representation.
+
+        E.g. "192.168.255.16" → b"285190336" (the integer as a decimal string,
+        as it appears in Bambu MQTT JSON payloads in the net.info[].ip field).
+        """
+        import struct as _struct
+
+        parts = ip.split(".")
+        packed = bytes(int(p) for p in parts)
+        le_int = _struct.unpack("<I", packed)[0]
+        return str(le_int).encode("utf-8")
+
     def _create_server_ssl_context(self) -> ssl.SSLContext:
         """Create SSL context for accepting client (slicer) connections."""
         ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
@@ -263,7 +337,7 @@ class TLSProxy:
             name=f"{self.name}_c2p_{client_id}",
         )
         printer_to_client = asyncio.create_task(
-            self._forward(printer_reader, client_writer, f"printer→{client_id}"),
+            self._forward(printer_reader, client_writer, f"printer→{client_id}", rewrite_ip=True),
             name=f"{self.name}_p2c_{client_id}",
         )
 
@@ -305,11 +379,157 @@ class TLSProxy:
                 except Exception:
                     pass  # Ignore disconnect callback errors; cleanup continues
 
+    @staticmethod
+    def _rewrite_mqtt_ip(
+        data: bytes,
+        old_ip: bytes,
+        new_ip: bytes,
+        buffer: bytearray,
+        extra_replacements: list[tuple[bytes, bytes]] | None = None,
+    ) -> tuple[bytes, bytearray]:
+        """Rewrite IP addresses inside MQTT packets, preserving packet framing.
+
+        MQTT packets have a variable-length header encoding the remaining
+        packet length.  A naive bytes.replace() would corrupt this framing
+        when old_ip and new_ip differ in length.
+
+        This method parses individual MQTT packets out of the data stream,
+        performs the replacement only on PUBLISH payloads, and re-encodes
+        the remaining-length field to match the new size.
+
+        Incomplete packets are buffered and returned for the next call.
+
+        Args:
+            extra_replacements: Additional (old, new) byte pairs to replace
+                (e.g. the integer IP representation in net.info[].ip).
+
+        Returns (output_data, remaining_buffer).
+        """
+        buffer.extend(data)
+
+        # Check if any replacement target exists in the buffer
+        has_target = old_ip in buffer
+        if not has_target and extra_replacements:
+            has_target = any(old in buffer for old, _new in extra_replacements)
+
+        if not has_target:
+            # Fast path: no IP in buffer, but we still need to check for
+            # incomplete packets at the end that might contain a partial IP.
+            # For safety, try to parse and emit only complete packets.
+            result = bytearray()
+            pos = 0
+            length = len(buffer)
+
+            while pos < length:
+                packet_start = pos
+                if pos + 1 >= length:
+                    break
+                pos += 1  # header byte
+
+                # Parse remaining length
+                remaining_length = 0
+                multiplier = 1
+                length_bytes = 0
+                while pos < length:
+                    encoded_byte = buffer[pos]
+                    pos += 1
+                    remaining_length += (encoded_byte & 0x7F) * multiplier
+                    multiplier *= 128
+                    length_bytes += 1
+                    if (encoded_byte & 0x80) == 0:
+                        break
+                    if length_bytes >= 4:
+                        break
+
+                if pos + remaining_length > length:
+                    # Incomplete — keep in buffer
+                    new_buffer = bytearray(buffer[packet_start:])
+                    return bytes(result), new_buffer
+
+                pos += remaining_length
+                result.extend(buffer[packet_start:pos])
+
+            # All complete
+            buffer.clear()
+            return bytes(result) if result else bytes(data), buffer
+
+        # Buffer contains old_ip — parse packets and rewrite
+        result = bytearray()
+        pos = 0
+        length = len(buffer)
+
+        while pos < length:
+            packet_start = pos
+
+            if pos >= length:
+                break
+            header_byte = buffer[pos]
+            pos += 1
+
+            # Remaining length: variable-length encoding (1-4 bytes)
+            remaining_length = 0
+            multiplier = 1
+            length_bytes = 0
+            while pos < length:
+                encoded_byte = buffer[pos]
+                pos += 1
+                remaining_length += (encoded_byte & 0x7F) * multiplier
+                multiplier *= 128
+                length_bytes += 1
+                if (encoded_byte & 0x80) == 0:
+                    break
+                if length_bytes >= 4:
+                    break
+
+            # Check if we have enough data for the full packet
+            if pos + remaining_length > length:
+                # Incomplete packet — keep in buffer for next call
+                new_buffer = bytearray(buffer[packet_start:])
+                return bytes(result), new_buffer
+
+            packet_type = (header_byte >> 4) & 0x0F
+            packet_body = buffer[pos : pos + remaining_length]
+            pos += remaining_length
+
+            # Only rewrite PUBLISH packets (type 3)
+            needs_rewrite = packet_type == 3 and (
+                old_ip in packet_body
+                or (extra_replacements and any(old in packet_body for old, _new in extra_replacements))
+            )
+            if needs_rewrite:
+                new_body = bytes(packet_body).replace(old_ip, new_ip)
+                if extra_replacements:
+                    for old_val, new_val in extra_replacements:
+                        new_body = new_body.replace(old_val, new_val)
+
+                # Re-encode: header byte + new remaining length + new body
+                result.append(header_byte)
+
+                # Encode remaining length (MQTT variable-length encoding)
+                new_remaining = len(new_body)
+                while True:
+                    encoded_byte = new_remaining % 128
+                    new_remaining //= 128
+                    if new_remaining > 0:
+                        encoded_byte |= 0x80
+                    result.append(encoded_byte)
+                    if new_remaining == 0:
+                        break
+
+                result.extend(new_body)
+            else:
+                # Pass through unchanged
+                result.extend(buffer[packet_start:pos])
+
+        buffer.clear()
+        return bytes(result), buffer
+
     async def _forward(
         self,
         reader: asyncio.StreamReader,
         writer: asyncio.StreamWriter,
         direction: str,
+        rewrite_ip: bool = False,
     ) -> None:
         """Forward data from reader to writer.
 
@@ -317,7 +537,12 @@ class TLSProxy:
             reader: Source stream (already TLS-decrypted)
             writer: Destination stream (will be TLS-encrypted by the stream)
             direction: Description for logging (e.g., "client→printer")
+            rewrite_ip: If True and rewrite_ip was configured, replace the
+                printer's real IP with the proxy's bind IP in the data.
         """
+        do_rewrite = rewrite_ip and self._rewrite_old is not None
+        rewrite_buffer = bytearray() if do_rewrite else None
+        rewrite_logged = False
         total_bytes = 0
         try:
             while self._running:
@@ -327,12 +552,40 @@ class TLSProxy:
                     # Connection closed
                     break
 
+                # Rewrite printer IP → proxy IP in MQTT PUBLISH payloads
+                # to prevent the slicer from bypassing the proxy.
+                if do_rewrite:
+                    extra = [(self._rewrite_old_int, self._rewrite_new_int)] if self._rewrite_old_int else None
+                    data, rewrite_buffer = self._rewrite_mqtt_ip(
+                        data,
+                        self._rewrite_old,
+                        self._rewrite_new,
+                        rewrite_buffer,
+                        extra_replacements=extra,
+                    )
+                    if not rewrite_logged and data:
+                        if self._rewrite_old in data:
+                            logger.warning(
+                                "%s proxy IP rewrite FAILED — %s still present after rewrite!",
+                                self.name,
+                                self._rewrite_old.decode(),
+                            )
+                        else:
+                            logger.info(
+                                "%s proxy IP rewrite active: %s → %s",
+                                self.name,
+                                self._rewrite_old.decode(),
+                                self._rewrite_new.decode(),
+                            )
+                        rewrite_logged = True
+                    if not data:
+                        continue  # All data buffered, waiting for more
+
                 # Forward to destination
                 writer.write(data)
                 await writer.drain()
 
                 total_bytes += len(data)
-                logger.debug("%s proxy %s: %s bytes", self.name, direction, len(data))
 
         except asyncio.CancelledError:
             pass  # Expected when the other forwarding direction closes first
@@ -628,11 +881,21 @@ class FTPTLSProxy(TLSProxy):
             await client_writer.wait_closed()
             return
 
+        # Capture the TLS session from the control channel for data channel
+        # reuse. vsFTPd (X1C) requires require_ssl_reuse — the data connection
+        # must present the same TLS session as the control channel.
+        ctrl_ssl_object = printer_writer.get_extra_info("ssl_object")
+        ctrl_tls_session = ctrl_ssl_object.session if ctrl_ssl_object else None
+        if ctrl_tls_session:
+            logger.debug("%s proxy: captured TLS session for data channel reuse", self.name)
+
         # Track data channel protection level per session.
         # PROT C = cleartext data, PROT P = TLS data.
         # Default to cleartext — many Bambu printers (A1, H2D) use PROT C.
         # If the slicer sends PROT P, we switch to TLS for data connections.
-        session_state: dict[str, str] = {"prot": "C"}
+        session_state: dict[str, str | ssl.SSLSession] = {"prot": "C"}
+        if ctrl_tls_session:
+            session_state["tls_session"] = ctrl_tls_session
 
         # Client→Printer: intercept EPSV and replace with PASV
         # EPSV responses only contain a port (no IP), so the slicer reuses
@@ -688,7 +951,7 @@ class FTPTLSProxy(TLSProxy):
         reader: asyncio.StreamReader,
         writer: asyncio.StreamWriter,
         direction: str,
-        session_state: dict[str, str],
+        session_state: dict[str, str | ssl.SSLSession],
     ) -> None:
         """Forward FTP client commands, replacing EPSV with PASV.
 
@@ -720,13 +983,8 @@ class FTPTLSProxy(TLSProxy):
 
                     cmd_upper = line.strip().upper()
 
-                    # Replace EPSV with PASV so response includes an IP
-                    if cmd_upper == b"EPSV":
-                        line = b"PASV"
-                        logger.info("FTP command rewrite: EPSV → PASV")
-
                     # Track PROT level for data channel encryption
-                    elif cmd_upper == b"PROT P":
+                    if cmd_upper == b"PROT P":
                         session_state["prot"] = "P"
                         logger.info("FTP data protection: PROT P (TLS)")
                     elif cmd_upper == b"PROT C":
@@ -765,7 +1023,7 @@ class FTPTLSProxy(TLSProxy):
         writer: asyncio.StreamWriter,
         direction: str,
         local_ip: str,
-        session_state: dict[str, str],
+        session_state: dict[str, str | ssl.SSLSession],
     ) -> None:
         """Forward FTP control channel responses, rewriting PASV/EPSV.
 
@@ -820,7 +1078,9 @@ class FTPTLSProxy(TLSProxy):
 
         logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
 
-    async def _maybe_rewrite_pasv(self, line: bytes, local_ip: str, session_state: dict[str, str]) -> bytes:
+    async def _maybe_rewrite_pasv(
+        self, line: bytes, local_ip: str, session_state: dict[str, str | ssl.SSLSession]
+    ) -> bytes:
         """Rewrite PASV/EPSV response to point to a local data proxy."""
         try:
             text = line.decode("utf-8")
@@ -869,7 +1129,9 @@ class FTPTLSProxy(TLSProxy):
 
         return line
 
-    async def _create_data_proxy(self, printer_ip: str, printer_port: int, session_state: dict[str, str]) -> int | None:
+    async def _create_data_proxy(
+        self, printer_ip: str, printer_port: int, session_state: dict[str, str | ssl.SSLSession]
+    ) -> int | None:
         """Create a one-shot proxy for an FTP data connection.
 
         Prefers the printer's original passive port so the port number stays
@@ -895,10 +1157,13 @@ class FTPTLSProxy(TLSProxy):
             "TLS" if use_tls else "cleartext",
         )
 
+        # Get control channel TLS session for data channel reuse
+        tls_session = session_state.get("tls_session") if use_tls else None
+
         # Try the printer's original port first — this ensures the port
         # matches even when bounce protection or iptables REDIRECT is in play.
         try:
-            await self._start_data_proxy_server(printer_port, printer_ip, printer_port, use_tls)
+            await self._start_data_proxy_server(printer_port, printer_ip, printer_port, use_tls, tls_session)
             logger.info("FTP data proxy: using printer's port %s", printer_port)
             return printer_port
         except OSError as e:
@@ -911,7 +1176,7 @@ class FTPTLSProxy(TLSProxy):
         for _attempt in range(10):
             port = random.randint(self.PASV_PORT_MIN, self.PASV_PORT_MAX)
             try:
-                await self._start_data_proxy_server(port, printer_ip, printer_port, use_tls)
+                await self._start_data_proxy_server(port, printer_ip, printer_port, use_tls, tls_session)
                 logger.info("FTP data proxy: using random port %s", port)
                 return port
             except OSError:
@@ -920,9 +1185,21 @@ class FTPTLSProxy(TLSProxy):
         logger.error("Failed to bind FTP data proxy port after 10 attempts")
         return None
 
-    async def _start_data_proxy_server(self, port: int, printer_ip: str, printer_port: int, use_tls: bool) -> None:
+    async def _start_data_proxy_server(
+        self,
+        port: int,
+        printer_ip: str,
+        printer_port: int,
+        use_tls: bool,
+        tls_session: ssl.SSLSession | None = None,
+    ) -> None:
         """Start a one-shot server for one FTP data connection.
 
+        When the slicer connects, immediately connects to the printer's data
+        port and buffers any slicer data until the printer connection is ready.
+        This handles zero-byte uploads (verify_job) where the slicer closes
+        the data channel before a naive proxy would finish its TLS handshake.
+
         The slicer-side listener is ALWAYS cleartext.  Even when the slicer
         sends PROT P on the control channel, Bambu Studio does not perform
         a TLS handshake on the data connection — it relies on the implicit
@@ -942,7 +1219,18 @@ class FTPTLSProxy(TLSProxy):
         # Slicer side: ALWAYS cleartext — Bambu Studio does not do TLS on
         # the data channel even after sending PROT P.
         # Printer side: TLS if PROT P, cleartext if PROT C.
-        client_ssl = self._client_ssl_context if use_tls else None
+        # For TLS data connections, wrap the SSL context to reuse the
+        # control channel's TLS session if available. vsFTPd (X1C) requires
+        # require_ssl_reuse — without this, data connections are rejected
+        # with "522 SSL connection failed: session reuse required".
+        if use_tls and tls_session:
+            client_ssl = _SessionReuseSSLContext(self._client_ssl_context, tls_session)
+            logger.debug("FTP data proxy: using TLS session reuse for port %s", port)
+        else:
+            client_ssl = self._client_ssl_context if use_tls else None
+
+        # Slicer side is ALWAYS cleartext — Bambu Studio does not do TLS on
+        # the data channel even after PROT P (confirmed for both H2D and X1C).
         printer_mode = "TLS" if use_tls else "cleartext"
 
         async def handle_data(
@@ -967,13 +1255,26 @@ class FTPTLSProxy(TLSProxy):
 
             printer_writer = None
             try:
+                # Buffer any slicer data while connecting to printer.
+                # This handles the race where the slicer sends data (or closes
+                # for zero-byte files) before the TLS handshake completes.
+                slicer_buffer = bytearray()
+                slicer_eof = False
+
+                async def buffer_slicer():
+                    nonlocal slicer_eof
+                    while True:
+                        chunk = await client_reader.read(65536)
+                        if not chunk:
+                            slicer_eof = True
+                            return
+                        slicer_buffer.extend(chunk)
+
+                buffer_task = asyncio.create_task(buffer_slicer())
+
                 # Connect to printer's data port
                 printer_reader, printer_writer = await asyncio.wait_for(
-                    asyncio.open_connection(
-                        printer_ip,
-                        printer_port,
-                        ssl=client_ssl,
-                    ),
+                    asyncio.open_connection(printer_ip, printer_port, ssl=client_ssl),
                     timeout=10.0,
                 )
                 logger.info(
@@ -984,21 +1285,92 @@ class FTPTLSProxy(TLSProxy):
                     printer_port,
                 )
 
-                # Bidirectional data forwarding
-                c2p = asyncio.create_task(self._forward(client_reader, printer_writer, "data_c2p"))
-                p2c = asyncio.create_task(self._forward(printer_reader, client_writer, "data_p2c"))
-
-                done, pending = await asyncio.wait([c2p, p2c], return_when=asyncio.FIRST_COMPLETED)
-                for task in pending:
-                    task.cancel()
-                    try:
-                        await task
-                    except asyncio.CancelledError:
-                        pass  # Expected when other data direction closes
-            except TimeoutError:
-                logger.error("FTP data proxy port %s: timeout connecting to printer", port)
-            except ssl.SSLError as e:
-                logger.error("FTP data proxy port %s: SSL error to printer: %s", port, e)
+                # Stop buffering
+                buffer_task.cancel()
+                try:
+                    await buffer_task
+                except asyncio.CancelledError:
+                    pass
+
+                # Flush buffered slicer data to printer
+                logger.info(
+                    "FTP data proxy port %s: buffer=%s bytes, slicer_eof=%s",
+                    port,
+                    len(slicer_buffer),
+                    slicer_eof,
+                )
+                if slicer_buffer:
+                    printer_writer.write(bytes(slicer_buffer))
+                    await printer_writer.drain()
+
+                # Forward remaining slicer data to printer, then close the
+                # printer side to signal upload complete.
+                #
+                # Bambu Studio does NOT close the FTP data channel after sending
+                # STOR data — it keeps the connection open and waits for the
+                # printer to close its side + send 226 on the control channel.
+                # A naive bidirectional proxy deadlocks here because the proxy
+                # waits for the slicer EOF that never comes.
+                #
+                # Fix: read slicer data with an idle timeout. Once data has been
+                # received and the slicer goes quiet, close the printer side so
+                # the printer can send 226. For RETR (download), the printer
+                # sends data and closes — the slicer reads until EOF — so this
+                # unidirectional approach works for both directions.
+                total_c2p = len(slicer_buffer)
+                if not slicer_eof:
+                    # Read remaining slicer data with idle detection.
+                    # Must be short — Bambu Studio expects 226 almost instantly
+                    # after sending data. Too long and the slicer times out.
+                    idle_timeout = 0.3
+                    while True:
+                        try:
+                            chunk = await asyncio.wait_for(client_reader.read(65536), timeout=idle_timeout)
+                        except TimeoutError:
+                            if total_c2p > 0:
+                                # Slicer sent data then went idle — upload done
+                                logger.debug(
+                                    "FTP data proxy port %s: slicer idle after %s bytes, closing printer side",
+                                    port,
+                                    total_c2p,
+                                )
+                                break
+                            continue  # No data yet, keep waiting
+                        if not chunk:
+                            break  # Slicer closed
+                        printer_writer.write(chunk)
+                        await printer_writer.drain()
+                        total_c2p += len(chunk)
+
+                logger.debug("FTP proxy data_c2p: total %s bytes", total_c2p)
+
+                # Close printer side to signal upload complete.
+                # For TLS, close() sends close_notify which the printer treats
+                # as end-of-data. The printer then sends 226 on the control
+                # channel. For RETR, this is a no-op since the printer closes
+                # first and we'd have exited the loop above via EOF.
+                try:
+                    printer_writer.close()
+                    await printer_writer.wait_closed()
+                except OSError:
+                    pass
+
+                # Wait for 226 response to propagate through the FTP control
+                # channel before closing the slicer's data channel.
+                #
+                # Without this delay, the data channel FIN arrives at the
+                # slicer before the 226 response on the control channel.
+                # BambuStudio reacts to the data channel FIN within <1ms
+                # by sending QUIT + closing the control channel — before
+                # 226 arrives (~2-3ms network RTT). This causes verify_job
+                # to be treated as failed and shows the login modal.
+                #
+                # In a direct connection, the printer sends 226 AND closes
+                # the data channel simultaneously, so the slicer gets both
+                # at once. The delay here emulates that timing.
+                if total_c2p > 0:
+                    await asyncio.sleep(0.5)
+
             except Exception as e:
                 logger.error("FTP data proxy port %s: error: %s", port, e)
             finally:
@@ -1016,8 +1388,7 @@ class FTPTLSProxy(TLSProxy):
             "0.0.0.0",  # nosec B104
             port,
             # No TLS on slicer side — Bambu Studio doesn't do TLS on data
-            # channel even after PROT P. The proxy terminates TLS only on
-            # the printer side (inside handle_data).
+            # channel even after PROT P (confirmed by connection hang test).
         )
         server_holder.append(server)
         self._data_servers.append(server)
@@ -1048,6 +1419,8 @@ class SlicerProxyManager:
     # Bambu printer ports
     PRINTER_FTP_PORT = 990
     PRINTER_MQTT_PORT = 8883
+    PRINTER_FILE_TRANSFER_PORT = 6000
+    PRINTER_RTSP_PORT = 322  # X1/H2/P2 series camera (A1/P1 use port 6000)
     PRINTER_BIND_PORTS = [3000, 3002]
 
     # Local listen ports - must match what Bambu Studio expects
@@ -1062,6 +1435,7 @@ class SlicerProxyManager:
         key_path: Path,
         on_activity: Callable[[str, str], None] | None = None,
         bind_address: str = "0.0.0.0",  # nosec B104
+        bind_identity: dict[str, str] | None = None,
     ):
         """Initialize the slicer proxy manager.
 
@@ -1071,25 +1445,46 @@ class SlicerProxyManager:
             key_path: Path to server private key
             on_activity: Optional callback for activity logging (name, message)
             bind_address: IP address to bind proxy listeners to
+            bind_identity: Optional dict with keys (serial, model, name, version)
+                for the bind/detect response. When provided, the proxy responds
+                to detect requests itself instead of forwarding to the printer.
+                This ensures the slicer sees the VP identity, not the real printer.
         """
         self.target_host = target_host
         self.cert_path = cert_path
         self.key_path = key_path
         self.on_activity = on_activity
         self.bind_address = bind_address
+        self.bind_identity = bind_identity
 
-        self._ftp_proxy: TLSProxy | None = None
+        self._ftp_proxy: TCPProxy | None = None
         self._mqtt_proxy: TLSProxy | None = None
+        self._file_transfer_proxy: TCPProxy | None = None
+        self._rtsp_proxy: TCPProxy | None = None
         self._bind_proxies: list[TCPProxy] = []
+        self._bind_server = None
+        self._probe_servers: list[asyncio.Server] = []
         self._tasks: list[asyncio.Task] = []
 
+    # FTP passive data port range — Bambu printers typically use ports in
+    # this range for EPSV/PASV data connections. We pre-listen on all of
+    # them so EPSV works transparently without decrypting FTP control.
+    FTP_DATA_PORT_MIN = 50000
+    FTP_DATA_PORT_MAX = 50100
+
     async def start(self) -> None:
-        """Start FTP and MQTT TLS proxies."""
-        logger.info("Starting slicer TLS proxy to %s", self.target_host)
+        """Start proxy services.
+
+        Uses transparent TCP proxying for most protocols (FTP, FileTransfer,
+        Camera) — raw bytes are forwarded without TLS termination, so the
+        slicer gets the printer's real TLS certificate end-to-end.
+
+        Only MQTT is TLS-terminated because we must decrypt the payload to
+        rewrite the printer's real IP with the proxy's bind IP.
+        """
+        logger.info("Starting slicer proxy to %s (transparent mode)", self.target_host)
 
-        # Detect iptables port redirect (e.g. if an external redirect exists).
-        # If active, connections get intercepted by iptables PREROUTING
-        # and sent to the redirect target — our socket never sees them.
+        # Detect iptables port redirect for FTP
         ftp_listen_port = self.LOCAL_FTP_PORT
         redirect_target = detect_port_redirect(self.LOCAL_FTP_PORT)
         if redirect_target:
@@ -1101,19 +1496,35 @@ class SlicerProxyManager:
             )
             ftp_listen_port = redirect_target
 
-        # Create FTP proxy with PASV/EPSV awareness for data connections
-        self._ftp_proxy = FTPTLSProxy(
+        # FTP control — raw TCP pass-through (end-to-end TLS with printer)
+        self._ftp_proxy = TCPProxy(
             name="FTP",
             listen_port=ftp_listen_port,
             target_host=self.target_host,
             target_port=self.PRINTER_FTP_PORT,
-            server_cert_path=self.cert_path,
-            server_key_path=self.key_path,
             on_connect=lambda cid: self._log_activity("FTP", f"connected: {cid}"),
             on_disconnect=lambda cid: self._log_activity("FTP", f"disconnected: {cid}"),
             bind_address=self.bind_address,
         )
 
+        # FTP data ports — pre-listen on the entire passive port range.
+        # Since FTP control is encrypted end-to-end, we can't read EPSV
+        # responses to know which port the printer chose. Instead, we
+        # listen on every port in the range and forward to the same port
+        # on the printer. The slicer connects to bind_ip:PORT (from EPSV)
+        # and we transparently relay to printer_ip:PORT.
+        self._ftp_data_proxies: list[TCPProxy] = []
+        for port in range(self.FTP_DATA_PORT_MIN, self.FTP_DATA_PORT_MAX + 1):
+            dp = TCPProxy(
+                name=f"FTP-Data-{port}",
+                listen_port=port,
+                target_host=self.target_host,
+                target_port=port,
+                bind_address=self.bind_address,
+            )
+            self._ftp_data_proxies.append(dp)
+
+        # MQTT — TLS-terminating proxy (must decrypt to rewrite IP addresses)
         self._mqtt_proxy = TLSProxy(
             name="MQTT",
             listen_port=self.LOCAL_MQTT_PORT,
@@ -1124,36 +1535,76 @@ class SlicerProxyManager:
             on_connect=lambda cid: self._log_activity("MQTT", f"connected: {cid}"),
             on_disconnect=lambda cid: self._log_activity("MQTT", f"disconnected: {cid}"),
             bind_address=self.bind_address,
+            rewrite_ip=(self.target_host, self.bind_address) if self.bind_address != "0.0.0.0" else None,  # nosec B104
         )
 
-        # Bind/auth proxy — port 3000 plain TCP, port 3002 TLS
-        for bind_port in self.PRINTER_BIND_PORTS:
-            if bind_port == 3002:
-                proxy = TLSProxy(
-                    name="Bind-TLS",
-                    listen_port=bind_port,
-                    target_host=self.target_host,
-                    target_port=bind_port,
-                    server_cert_path=self.cert_path,
-                    server_key_path=self.key_path,
-                    on_connect=lambda cid: self._log_activity("Bind", f"connected: {cid}"),
-                    on_disconnect=lambda cid: self._log_activity("Bind", f"disconnected: {cid}"),
-                    bind_address=self.bind_address,
-                )
-            else:
-                proxy = TCPProxy(
-                    name="Bind",
-                    listen_port=bind_port,
-                    target_host=self.target_host,
-                    target_port=bind_port,
-                    on_connect=lambda cid: self._log_activity("Bind", f"connected: {cid}"),
-                    on_disconnect=lambda cid: self._log_activity("Bind", f"disconnected: {cid}"),
-                    bind_address=self.bind_address,
-                )
-            self._bind_proxies.append(proxy)
+        # File transfer — raw TCP pass-through (port 6000)
+        self._file_transfer_proxy = TCPProxy(
+            name="FileTransfer",
+            listen_port=self.PRINTER_FILE_TRANSFER_PORT,
+            target_host=self.target_host,
+            target_port=self.PRINTER_FILE_TRANSFER_PORT,
+            on_connect=lambda cid: self._log_activity("FileTransfer", f"connected: {cid}"),
+            on_disconnect=lambda cid: self._log_activity("FileTransfer", f"disconnected: {cid}"),
+            bind_address=self.bind_address,
+        )
+
+        # RTSP camera — raw TCP pass-through (port 322)
+        self._rtsp_proxy = TCPProxy(
+            name="RTSP",
+            listen_port=self.PRINTER_RTSP_PORT,
+            target_host=self.target_host,
+            target_port=self.PRINTER_RTSP_PORT,
+            on_connect=lambda cid: self._log_activity("RTSP", f"connected: {cid}"),
+            on_disconnect=lambda cid: self._log_activity("RTSP", f"disconnected: {cid}"),
+            bind_address=self.bind_address,
+        )
+
+        # Bind/auth — respond with VP identity instead of proxying to printer.
+        # The detect response contains the printer name, serial, model, and
+        # bind status. Proxying it would leak the real printer's identity and
+        # cause the slicer to treat it as a different device.
+        if self.bind_identity:
+            from backend.app.services.virtual_printer.bind_server import BindServer
+
+            self._bind_server = BindServer(
+                serial=self.bind_identity["serial"],
+                model=self.bind_identity["model"],
+                name=self.bind_identity["name"],
+                version=self.bind_identity.get("version", "01.00.00.00"),
+                bind_address=self.bind_address,
+                cert_path=self.cert_path,
+                key_path=self.key_path,
+            )
+        else:
+            # Fallback: proxy bind requests to the real printer
+            for bind_port in self.PRINTER_BIND_PORTS:
+                if bind_port == 3002:
+                    proxy = TLSProxy(
+                        name="Bind-TLS",
+                        listen_port=bind_port,
+                        target_host=self.target_host,
+                        target_port=bind_port,
+                        server_cert_path=self.cert_path,
+                        server_key_path=self.key_path,
+                        on_connect=lambda cid: self._log_activity("Bind", f"connected: {cid}"),
+                        on_disconnect=lambda cid: self._log_activity("Bind", f"disconnected: {cid}"),
+                        bind_address=self.bind_address,
+                    )
+                else:
+                    proxy = TCPProxy(
+                        name="Bind",
+                        listen_port=bind_port,
+                        target_host=self.target_host,
+                        target_port=bind_port,
+                        on_connect=lambda cid: self._log_activity("Bind", f"connected: {cid}"),
+                        on_disconnect=lambda cid: self._log_activity("Bind", f"disconnected: {cid}"),
+                        bind_address=self.bind_address,
+                    )
+                self._bind_proxies.append(proxy)
 
         # Start as background tasks
-        async def run_with_logging(proxy: TLSProxy) -> None:
+        async def run_with_logging(proxy: TLSProxy | TCPProxy) -> None:
             try:
                 await proxy.start()
             except Exception as e:
@@ -1168,7 +1619,22 @@ class SlicerProxyManager:
                 run_with_logging(self._mqtt_proxy),
                 name="slicer_proxy_mqtt",
             ),
+            asyncio.create_task(
+                run_with_logging(self._file_transfer_proxy),
+                name="slicer_proxy_file_transfer",
+            ),
+            asyncio.create_task(
+                run_with_logging(self._rtsp_proxy),
+                name="slicer_proxy_rtsp",
+            ),
         ]
+        if self._bind_server:
+            self._tasks.append(
+                asyncio.create_task(
+                    run_with_logging(self._bind_server),
+                    name="slicer_proxy_bind_server",
+                )
+            )
         for bp in self._bind_proxies:
             self._tasks.append(
                 asyncio.create_task(
@@ -1176,8 +1642,37 @@ class SlicerProxyManager:
                     name=f"slicer_proxy_bind_{bp.listen_port}",
                 )
             )
+        # FTP data port proxies (50000-50100)
+        for dp in self._ftp_data_proxies:
+            self._tasks.append(
+                asyncio.create_task(
+                    run_with_logging(dp),
+                    name=f"slicer_proxy_ftp_data_{dp.listen_port}",
+                )
+            )
 
-        logger.info("Slicer TLS proxy started for %s", self.target_host)
+        # Diagnostic probe: listen on common un-proxied ports to detect
+        # if the slicer tries to reach a service we don't handle.
+        if self.bind_address and self.bind_address != "0.0.0.0":  # nosec B104
+            for probe_port in (21, 80, 443):
+                try:
+                    srv = await asyncio.start_server(
+                        lambda r, w, p=probe_port: self._probe_handler(r, w, p),
+                        self.bind_address,
+                        probe_port,
+                    )
+                    self._probe_servers.append(srv)
+                except OSError:
+                    pass  # Port in use or no permission — skip
+            if self._probe_servers:
+                probed = [s.sockets[0].getsockname()[1] for s in self._probe_servers if s.sockets]
+                logger.info("Proxy diagnostic: probing un-proxied ports %s on %s", probed, self.bind_address)
+
+        logger.info(
+            "Slicer proxy started for %s (transparent TCP + MQTT TLS, %d FTP data ports)",
+            self.target_host,
+            len(self._ftp_data_proxies),
+        )
 
         # Wait for tasks to complete (they run until cancelled)
         # This keeps the start() coroutine alive so the parent task doesn't complete
@@ -1199,10 +1694,30 @@ class SlicerProxyManager:
             await self._mqtt_proxy.stop()
             self._mqtt_proxy = None
 
+        if self._file_transfer_proxy:
+            await self._file_transfer_proxy.stop()
+            self._file_transfer_proxy = None
+
+        if self._rtsp_proxy:
+            await self._rtsp_proxy.stop()
+            self._rtsp_proxy = None
+
+        if self._bind_server:
+            await self._bind_server.stop()
+            self._bind_server = None
+
         for bp in self._bind_proxies:
             await bp.stop()
         self._bind_proxies = []
 
+        for dp in self._ftp_data_proxies:
+            await dp.stop()
+        self._ftp_data_proxies = []
+
+        for srv in self._probe_servers:
+            srv.close()
+        self._probe_servers = []
+
         # Cancel tasks
         for task in self._tasks:
             task.cancel()
@@ -1219,6 +1734,21 @@ class SlicerProxyManager:
         self._tasks = []
         logger.info("Slicer proxy stopped")
 
+    async def _probe_handler(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, port: int) -> None:
+        """Log unexpected connections on un-proxied ports for diagnostics."""
+        peername = writer.get_extra_info("peername")
+        client = f"{peername[0]}:{peername[1]}" if peername else "unknown"
+        logger.warning(
+            "PROBE: slicer connected to un-proxied port %d from %s — this port may need proxying",
+            port,
+            client,
+        )
+        writer.close()
+        try:
+            await writer.wait_closed()
+        except OSError:
+            pass
+
     def _log_activity(self, name: str, message: str) -> None:
         """Log activity via callback if configured."""
         if self.on_activity:

+ 1 - 0
backend/tests/conftest.py

@@ -88,6 +88,7 @@ async def test_engine():
         spool_catalog,
         spool_usage_history,
         user,
+        user_email_pref,
         virtual_printer,
     )
 

+ 144 - 0
backend/tests/integration/test_inventory_assign.py

@@ -324,3 +324,147 @@ class TestAssignSpoolTrayInfoIdx:
             call_kwargs = mock_client.ams_set_filament_setting.call_args
             # Slot's specific preset is reused when spool has no own preset
             assert call_kwargs.kwargs["tray_info_idx"] == "GFA05"
+
+
+class TestAssignSpoolPresetMapping:
+    """Tests that assign_spool saves the slot preset mapping for correct UI display."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_preset_mapping_saved_with_slicer_filament_name(
+        self, async_client: AsyncClient, printer_factory, spool_factory
+    ):
+        """Slot preset mapping uses slicer_filament_name (not material+subtype)."""
+
+        printer = await printer_factory(name="X1C")
+        spool = await spool_factory(
+            slicer_filament="GFA05",
+            slicer_filament_name="Bambu PLA Silk",
+            material="PLA",
+            subtype="Silk",
+            brand="Bambu",
+        )
+
+        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=[])
+
+        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": 0, "tray_id": 1},
+            )
+
+        assert response.status_code == 200
+
+        # Verify via the slot presets API
+        presets_resp = await async_client.get(f"/api/v1/printers/{printer.id}/slot-presets")
+        assert presets_resp.status_code == 200
+        presets = presets_resp.json()
+        # Key is str(ams_id * 4 + tray_id) — ams 0, tray 1 → "1"
+        assert "1" in presets
+        # Must use slicer_filament_name, NOT "PLA Silk" from material+subtype
+        assert presets["1"]["preset_name"] == "Bambu PLA Silk"
+        assert presets["1"]["preset_id"] == "GFSA05"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_preset_mapping_overwrites_old_mapping(
+        self, async_client: AsyncClient, printer_factory, spool_factory, db_session: AsyncSession
+    ):
+        """Assigning a new spool overwrites the old slot preset mapping."""
+        from backend.app.models.slot_preset import SlotPresetMapping
+
+        printer = await printer_factory(name="X1C")
+
+        # Pre-existing mapping (e.g. from previous manual configuration)
+        old_mapping = SlotPresetMapping(
+            printer_id=printer.id,
+            ams_id=0,
+            tray_id=2,
+            preset_id="GFSA01",
+            preset_name="Bambu PLA Matte",
+            preset_source="cloud",
+        )
+        db_session.add(old_mapping)
+        await db_session.commit()
+
+        # Assign a "Generic PLA Silk" spool to same slot
+        spool = await spool_factory(
+            slicer_filament="GFL96",
+            slicer_filament_name="Generic PLA Silk",
+            material="PLA",
+            subtype="Silk",
+        )
+
+        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=[])
+
+        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": 0, "tray_id": 2},
+            )
+
+        assert response.status_code == 200
+
+        # Verify via the slot presets API to avoid stale session cache
+        presets_resp = await async_client.get(f"/api/v1/printers/{printer.id}/slot-presets")
+        assert presets_resp.status_code == 200
+        presets = presets_resp.json()
+        # Key is str(ams_id * 4 + tray_id) — ams 0, tray 2 → "2"
+        assert "2" in presets
+        # Old "Bambu PLA Matte" must be overwritten
+        assert presets["2"]["preset_name"] == "Generic PLA Silk"
+        assert presets["2"]["preset_id"] == "GFSL96"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_preset_mapping_fallback_to_tray_sub_brands(
+        self, async_client: AsyncClient, printer_factory, spool_factory
+    ):
+        """When slicer_filament_name is null, falls back to tray_sub_brands."""
+        from backend.app.models.slot_preset import SlotPresetMapping
+
+        printer = await printer_factory(name="A1M")
+        spool = await spool_factory(
+            slicer_filament="GFL05",
+            slicer_filament_name=None,
+            material="PLA",
+            subtype="Matte",
+            brand="Overture",
+        )
+
+        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=[])
+
+        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": 0, "tray_id": 0},
+            )
+
+        assert response.status_code == 200
+
+        # Verify via the slot presets API
+        presets_resp = await async_client.get(f"/api/v1/printers/{printer.id}/slot-presets")
+        assert presets_resp.status_code == 200
+        presets = presets_resp.json()
+        # Key is str(ams_id * 4 + tray_id) — ams 0, tray 0 → "0"
+        assert "0" in presets
+        # Falls back to tray_sub_brands ("Overture PLA Matte")
+        assert presets["0"]["preset_name"] == "Overture PLA Matte"

+ 270 - 0
backend/tests/integration/test_spoolbuddy.py

@@ -741,3 +741,273 @@ class TestCalibrationEndpoints:
         data = resp.json()
         assert data["tare_offset"] == 11111
         assert data["calibration_factor"] == pytest.approx(0.0042)
+
+
+# ============================================================================
+# Display endpoints
+# ============================================================================
+
+
+class TestDisplayEndpoints:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_display_settings(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-disp", display_brightness=100, display_blank_timeout=0)
+
+        resp = await async_client.put(
+            f"{API}/devices/sb-disp/display",
+            json={"brightness": 75, "blank_timeout": 300},
+        )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["brightness"] == 75
+        assert data["blank_timeout"] == 300
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_display_persists_via_heartbeat(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-disp-hb")
+
+        await async_client.put(
+            f"{API}/devices/sb-disp-hb/display",
+            json={"brightness": 50, "blank_timeout": 600},
+        )
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            hb = await async_client.post(
+                f"{API}/devices/sb-disp-hb/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
+            )
+
+        assert hb.json()["display_brightness"] == 50
+        assert hb.json()["display_blank_timeout"] == 600
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_display_unknown_device_404(self, async_client: AsyncClient):
+        resp = await async_client.put(
+            f"{API}/devices/ghost/display",
+            json={"brightness": 50, "blank_timeout": 60},
+        )
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_display_validates_brightness(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-disp-val")
+
+        resp = await async_client.put(
+            f"{API}/devices/sb-disp-val/display",
+            json={"brightness": 150, "blank_timeout": 0},
+        )
+        assert resp.status_code == 422  # Validation error: brightness > 100
+
+
+# ============================================================================
+# Update endpoints
+# ============================================================================
+
+
+class TestUpdateEndpoints:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_trigger_update_queues_command(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-upd")
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(f"{API}/devices/sb-upd/update")
+
+        assert resp.status_code == 200
+        assert resp.json()["status"] == "ok"
+
+        # Verify heartbeat returns update command
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            hb = await async_client.post(
+                f"{API}/devices/sb-upd/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
+            )
+
+        # update command is NOT cleared by heartbeat (cleared by update-status)
+        assert hb.json()["pending_command"] == "update"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_trigger_update_offline_device_409(self, async_client: AsyncClient, device_factory):
+        await device_factory(
+            device_id="sb-upd-off",
+            last_seen=datetime.now(timezone.utc) - timedelta(seconds=120),
+        )
+
+        resp = await async_client.post(f"{API}/devices/sb-upd-off/update")
+        assert resp.status_code == 409
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_trigger_update_unknown_device_404(self, async_client: AsyncClient):
+        resp = await async_client.post(f"{API}/devices/ghost/update")
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_trigger_update_already_updating(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-upd-dup", update_status="updating")
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(f"{API}/devices/sb-upd-dup/update")
+
+        assert resp.status_code == 200
+        assert resp.json()["status"] == "already_updating"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_report_update_status_updating(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-upd-st", pending_command="update", update_status="pending")
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/devices/sb-upd-st/update-status",
+                json={"status": "updating", "message": "Fetching latest code..."},
+            )
+
+        assert resp.status_code == 200
+        mock_ws.broadcast.assert_called_once()
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_update"
+        assert msg["update_status"] == "updating"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_report_update_status_complete_clears_command(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-upd-done", pending_command="update", update_status="updating")
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            await async_client.post(
+                f"{API}/devices/sb-upd-done/update-status",
+                json={"status": "complete", "message": "Update complete, restarting..."},
+            )
+
+        # Heartbeat should have no pending command
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            hb = await async_client.post(
+                f"{API}/devices/sb-upd-done/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
+            )
+
+        assert hb.json()["pending_command"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_report_update_status_error(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-upd-err", pending_command="update", update_status="updating")
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/devices/sb-upd-err/update-status",
+                json={"status": "error", "message": "git fetch failed: network unreachable"},
+            )
+
+        assert resp.status_code == 200
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["update_status"] == "error"
+        assert "git fetch failed" in msg["update_message"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_report_update_status_unknown_device_404(self, async_client: AsyncClient):
+        resp = await async_client.post(
+            f"{API}/devices/ghost/update-status",
+            json={"status": "updating", "message": "test"},
+        )
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_device_response_includes_update_fields(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-upd-resp", update_status="complete", update_message="Done!")
+
+        resp = await async_client.get(f"{API}/devices")
+        assert resp.status_code == 200
+        device = next(d for d in resp.json() if d["device_id"] == "sb-upd-resp")
+        assert device["update_status"] == "complete"
+        assert device["update_message"] == "Done!"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_check_returns_version_info(self, async_client: AsyncClient, device_factory):
+        """GET /devices/{id}/update-check queries GitHub and returns version comparison."""
+        await device_factory(device_id="sb-uc", firmware_version="0.1.0")
+
+        mock_releases = [{"tag_name": "v0.2.0", "html_url": "https://github.com/test/releases/0.2.0"}]
+
+        mock_resp = MagicMock()
+        mock_resp.status_code = 200
+        mock_resp.json.return_value = mock_releases
+        mock_resp.raise_for_status = MagicMock()
+
+        mock_client = AsyncMock()
+        mock_client.get.return_value = mock_resp
+        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+        mock_client.__aexit__ = AsyncMock(return_value=False)
+
+        with patch("httpx.AsyncClient", return_value=mock_client):
+            resp = await async_client.get(f"{API}/devices/sb-uc/update-check")
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["current_version"] == "0.1.0"
+        assert data["latest_version"] == "0.2.0"
+        assert data["update_available"] is True
+        assert data["release_url"] == "https://github.com/test/releases/0.2.0"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_check_up_to_date(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-uc2", firmware_version="0.2.0")
+
+        mock_releases = [{"tag_name": "v0.2.0", "html_url": "https://github.com/test/releases/0.2.0"}]
+
+        mock_resp = MagicMock()
+        mock_resp.status_code = 200
+        mock_resp.json.return_value = mock_releases
+        mock_resp.raise_for_status = MagicMock()
+
+        mock_client = AsyncMock()
+        mock_client.get.return_value = mock_resp
+        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+        mock_client.__aexit__ = AsyncMock(return_value=False)
+
+        with patch("httpx.AsyncClient", return_value=mock_client):
+            resp = await async_client.get(f"{API}/devices/sb-uc2/update-check")
+
+        assert resp.status_code == 200
+        assert resp.json()["update_available"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_check_unknown_device_404(self, async_client: AsyncClient):
+        resp = await async_client.get(f"{API}/devices/ghost/update-check")
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_trigger_update_broadcasts_websocket(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-upd-ws")
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            await async_client.post(f"{API}/devices/sb-upd-ws/update")
+
+        mock_ws.broadcast.assert_called_once()
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_update"
+        assert msg["device_id"] == "sb-upd-ws"
+        assert msg["update_status"] == "pending"

+ 80 - 0
backend/tests/integration/test_user_notifications_api.py

@@ -0,0 +1,80 @@
+"""Integration tests for User Notifications API endpoints.
+
+Tests the full request/response cycle for /api/v1/user-notifications/ endpoints.
+"""
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestUserNotificationsAPI:
+    """Integration tests for /api/v1/user-notifications/ endpoints."""
+
+    # ========================================================================
+    # GET /preferences — no auth
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_preferences_returns_defaults_when_no_auth(self, async_client: AsyncClient):
+        """Without auth, GET should return all-enabled defaults."""
+        response = await async_client.get("/api/v1/user-notifications/preferences")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data["notify_print_start"] is True
+        assert data["notify_print_complete"] is True
+        assert data["notify_print_failed"] is True
+        assert data["notify_print_stopped"] is True
+
+    # ========================================================================
+    # PUT /preferences — no auth
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_preferences_fails_without_auth(self, async_client: AsyncClient):
+        """Without auth enabled, PUT should return 400 (no user context)."""
+        data = {
+            "notify_print_start": False,
+            "notify_print_complete": True,
+            "notify_print_failed": True,
+            "notify_print_stopped": False,
+        }
+
+        response = await async_client.put("/api/v1/user-notifications/preferences", json=data)
+
+        assert response.status_code == 400
+        assert "Authentication must be enabled" in response.json()["detail"]
+
+    # ========================================================================
+    # Schema validation
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_preferences_rejects_missing_fields(self, async_client: AsyncClient):
+        """PUT should reject requests missing required boolean fields."""
+        data = {
+            "notify_print_start": True,
+            # missing other fields
+        }
+
+        response = await async_client.put("/api/v1/user-notifications/preferences", json=data)
+
+        assert response.status_code == 422  # Pydantic validation error
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_preferences_rejects_invalid_type(self, async_client: AsyncClient):
+        """PUT should reject values that cannot be coerced to boolean."""
+        data = {
+            "notify_print_start": [1, 2, 3],
+            "notify_print_complete": True,
+            "notify_print_failed": True,
+            "notify_print_stopped": True,
+        }
+
+        response = await async_client.put("/api/v1/user-notifications/preferences", json=data)
+
+        assert response.status_code == 422

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

@@ -4,6 +4,8 @@ Tests for the BambuMQTTClient service.
 These tests focus on timelapse tracking during prints.
 """
 
+import json
+
 import pytest
 
 
@@ -604,6 +606,133 @@ class TestAMSDataMerging:
         assert ams_data[0]["tray"][0]["tray_type"] == "PLA", "A1 should still have PLA"
         assert ams_data[1]["tray"][0]["tray_type"] == "PLA", "B1 should still have PLA"
 
+    def test_shutdown_message_preserves_ams_data(self, mqtt_client):
+        """Printer shutdown (power_on_flag=False) must not wipe AMS slot data (#765).
+
+        When a printer shuts down it sends a final MQTT message with
+        tray_exist_bits='0' and power_on_flag=False. This all-zero value
+        previously caused every slot to be cleared, which then triggered
+        auto-unlink of all spool assignments on reconnect.
+        """
+        # Initial state: two AMS units with loaded spools
+        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},
+                        {"id": 1, "tray_type": "PETG", "tray_color": "67DB25FF", "remain": 70},
+                    ],
+                },
+            ],
+            "tray_exist_bits": "33",  # Slots 0,1 of each AMS (0b00110011)
+            "power_on_flag": True,
+        }
+        mqtt_client._handle_ams_data(initial_ams)
+
+        # Verify initial state
+        ams_data = mqtt_client.state.raw_data["ams"]
+        assert ams_data[0]["tray"][0]["tray_type"] == "PLA"
+        assert ams_data[1]["tray"][0]["tray_type"] == "PETG"
+
+        # Simulate printer shutdown — all-zero bits with power_on_flag=False
+        shutdown_ams = {
+            "ams_exist_bits": "0",
+            "tray_exist_bits": "0",
+            "power_on_flag": False,
+            "insert_flag": False,
+            "tray_now": "0",
+            "version": 0,
+        }
+        mqtt_client._handle_ams_data(shutdown_ams)
+
+        # AMS slot data MUST be preserved — shutdown should not clear it
+        ams_data = mqtt_client.state.raw_data["ams"]
+        assert ams_data[0]["tray"][0]["tray_type"] == "PLA", "Shutdown must not clear AMS 0 slot 0"
+        assert ams_data[0]["tray"][0]["tray_color"] == "FF0000FF", "Shutdown must not clear AMS 0 slot 0 color"
+        assert ams_data[0]["tray"][1]["tray_type"] == "PETG", "Shutdown must not clear AMS 0 slot 1"
+        assert ams_data[1]["tray"][0]["tray_type"] == "PETG", "Shutdown must not clear AMS 1 slot 0"
+        assert ams_data[1]["tray"][1]["tray_type"] == "PETG", "Shutdown must not clear AMS 1 slot 1"
+
+    def test_genuine_removal_still_clears_with_power_on(self, mqtt_client):
+        """Genuine spool removal (power_on_flag=True) must still clear slot data.
+
+        Ensures the #765 fix doesn't break normal spool removal detection.
+        """
+        # Initial state: AMS with loaded spool
+        initial_ams = {
+            "ams": [
+                {
+                    "id": 0,
+                    "tray": [
+                        {"id": 0, "tray_type": "PLA", "tray_color": "FF0000", "remain": 80},
+                        {"id": 1, "tray_type": "PETG", "tray_color": "00FF00", "remain": 60},
+                    ],
+                },
+            ],
+            "tray_exist_bits": "3",  # Both slots occupied (0b11)
+            "power_on_flag": True,
+        }
+        mqtt_client._handle_ams_data(initial_ams)
+
+        # Spool removed from slot 1 while printer is running
+        removal_ams = {
+            "ams": [
+                {
+                    "id": 0,
+                    "tray": [{"id": 0}, {"id": 1}],
+                },
+            ],
+            "tray_exist_bits": "1",  # Only slot 0 occupied (0b01)
+            "power_on_flag": True,
+        }
+        mqtt_client._handle_ams_data(removal_ams)
+
+        # Slot 0 preserved, slot 1 cleared
+        ams_data = mqtt_client.state.raw_data["ams"]
+        assert ams_data[0]["tray"][0]["tray_type"] == "PLA", "Slot 0 should be preserved"
+        assert ams_data[0]["tray"][1]["tray_type"] == "", "Slot 1 should be cleared on removal"
+        assert ams_data[0]["tray"][1]["tray_color"] == "", "Slot 1 color should be cleared"
+
+    def test_power_on_flag_defaults_true_when_absent(self, mqtt_client):
+        """When power_on_flag is not in the MQTT data, clearing must proceed normally.
+
+        Ensures backwards compatibility with firmware that doesn't send power_on_flag.
+        """
+        # Initial state
+        initial_ams = {
+            "ams": [
+                {
+                    "id": 0,
+                    "tray": [
+                        {"id": 0, "tray_type": "PLA", "tray_color": "FF0000", "remain": 80},
+                    ],
+                },
+            ],
+            "tray_exist_bits": "1",
+        }
+        mqtt_client._handle_ams_data(initial_ams)
+
+        # Update WITHOUT power_on_flag — should still clear when bit=0
+        update_ams = {
+            "ams": [{"id": 0, "tray": [{"id": 0}]}],
+            "tray_exist_bits": "0",
+            # No power_on_flag key at all
+        }
+        mqtt_client._handle_ams_data(update_ams)
+
+        ams_data = mqtt_client.state.raw_data["ams"]
+        assert ams_data[0]["tray"][0]["tray_type"] == "", (
+            "Without power_on_flag, clearing should proceed (defaults to True)"
+        )
+
 
 class TestNozzleRackData:
     """Tests for nozzle rack data parsing from H2 series device.nozzle.info."""
@@ -863,6 +992,13 @@ class TestNozzleRackData:
 class TestRequestTopicFailSafe:
     """Tests for graceful degradation when broker rejects request topic subscription."""
 
+    @pytest.fixture(autouse=True)
+    def clear_request_topic_cache(self):
+        """Clear class-level cache before each test to avoid cross-test pollution."""
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        BambuMQTTClient._request_topic_cache.clear()
+
     @pytest.fixture
     def mqtt_client(self):
         from backend.app.services.bambu_mqtt import BambuMQTTClient
@@ -970,6 +1106,79 @@ class TestRequestTopicFailSafe:
         assert len(subscribe_calls) == 1
         assert subscribe_calls[0] == mqtt_client.topic_subscribe
 
+    def test_cache_persists_across_instances(self):
+        """New client instance inherits request topic unsupported state from cache."""
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client1 = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST_CACHE",
+            access_code="12345678",
+        )
+        assert client1._request_topic_supported is True
+
+        # Simulate disconnect-after-subscribe disabling the topic
+        client1._request_topic_sub_time = __import__("time").time()
+        client1._request_topic_confirmed = False
+        client1._last_message_time = 0.0
+        client1._on_disconnect(None, None)
+        assert client1._request_topic_supported is False
+
+        # New instance for same serial should inherit the cached state
+        client2 = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST_CACHE",
+            access_code="12345678",
+        )
+        assert client2._request_topic_supported is False
+
+    def test_cache_does_not_affect_different_serial(self):
+        """Cache is per-serial — different printer is unaffected."""
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        BambuMQTTClient._request_topic_cache["SERIAL_A"] = False
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="SERIAL_B",
+            access_code="12345678",
+        )
+        assert client._request_topic_supported is True
+
+    def test_cache_updated_on_suback_success(self):
+        """Successful SUBACK caches positive confirmation."""
+        from paho.mqtt.reasoncodes import ReasonCode
+
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST_SUBACK",
+            access_code="12345678",
+        )
+        client._request_topic_sub_mid = 42
+        rc = ReasonCode(9, identifier=0)  # Success
+        client._on_subscribe(None, None, 42, [rc], None)
+
+        assert BambuMQTTClient._request_topic_cache["TEST_SUBACK"] is True
+
+    def test_cache_updated_on_suback_rejection(self):
+        """SUBACK rejection caches negative state."""
+        from paho.mqtt.reasoncodes import ReasonCode
+
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST_REJECT",
+            access_code="12345678",
+        )
+        client._request_topic_sub_mid = 42
+        rc = ReasonCode(9, identifier=0x80)  # Failure
+        client._on_subscribe(None, None, 42, [rc], None)
+
+        assert BambuMQTTClient._request_topic_cache["TEST_REJECT"] is False
+
 
 class TestRequestTopicAmsMapping:
     """Tests for capturing ams_mapping from the MQTT request topic."""
@@ -2376,3 +2585,74 @@ class TestDeveloperModeDetection:
                 }
             )
         assert mqtt_client.state.developer_mode is False
+
+
+class TestSendDryingCommand:
+    """Tests for send_drying_command MQTT payload construction."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        """Create a BambuMQTTClient with a mock MQTT client."""
+        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()
+        return client
+
+    def test_rotate_tray_false_by_default(self, mqtt_client):
+        """Verify rotate_tray defaults to False in the MQTT payload."""
+        mqtt_client.send_drying_command(ams_id=0, temp=55, duration=4, mode=1, filament="PLA")
+
+        call_args = mqtt_client._client.publish.call_args
+        payload = json.loads(call_args[0][1])
+        assert payload["print"]["rotate_tray"] is False
+
+    def test_rotate_tray_true_when_enabled(self, mqtt_client):
+        """Verify rotate_tray is True when explicitly enabled."""
+        mqtt_client.send_drying_command(ams_id=0, temp=55, duration=4, mode=1, filament="PLA", rotate_tray=True)
+
+        call_args = mqtt_client._client.publish.call_args
+        payload = json.loads(call_args[0][1])
+        assert payload["print"]["rotate_tray"] is True
+
+    def test_rotate_tray_false_on_stop(self, mqtt_client):
+        """Verify rotate_tray is False when stopping drying (mode=0)."""
+        mqtt_client.send_drying_command(ams_id=0, temp=0, duration=0, mode=0)
+
+        call_args = mqtt_client._client.publish.call_args
+        payload = json.loads(call_args[0][1])
+        assert payload["print"]["rotate_tray"] is False
+
+    def test_all_required_fields_present(self, mqtt_client):
+        """Verify all required MQTT fields are present in the drying command."""
+        mqtt_client.send_drying_command(ams_id=128, temp=75, duration=8, mode=1, filament="ABS", rotate_tray=True)
+
+        call_args = mqtt_client._client.publish.call_args
+        payload = json.loads(call_args[0][1])
+        cmd = payload["print"]
+        assert cmd["command"] == "ams_filament_drying"
+        assert cmd["ams_id"] == 128
+        assert cmd["temp"] == 75
+        assert cmd["duration"] == 8
+        assert cmd["mode"] == 1
+        assert cmd["rotate_tray"] is True
+        assert cmd["filament"] == "ABS"
+        assert cmd["cooling_temp"] == 20
+        assert cmd["humidity"] == 0
+        assert cmd["close_power_conflict"] is False
+        assert "sequence_id" in cmd
+
+    def test_publishes_with_qos_1(self, mqtt_client):
+        """Verify drying commands are published with QoS 1."""
+        mqtt_client.send_drying_command(ams_id=0, temp=55, duration=4)
+
+        call_args = mqtt_client._client.publish.call_args
+        # qos may be positional arg [2] or keyword
+        qos = call_args.kwargs.get("qos", call_args[0][2] if len(call_args[0]) > 2 else None)
+        assert qos == 1

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

@@ -588,6 +588,87 @@ class TestNotificationProviderTypes:
             assert "timestamp" not in payload
             assert "source" not in payload
 
+    @pytest.mark.asyncio
+    async def test_webhook_generic_format_includes_image(self, service):
+        """Verify generic webhook includes base64-encoded image when provided."""
+        config = {
+            "webhook_url": "http://test.local/webhook",
+            "field_title": "title",
+            "field_message": "message",
+        }
+
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+
+        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
+
+            image_bytes = b"\xff\xd8\xff\xe0fake-jpeg-data"
+            success, message = await service._send_webhook(config, "Test Title", "Test Message", image_data=image_bytes)
+
+            assert success is True
+            call_args = mock_client.post.call_args
+            payload = call_args.kwargs.get("json") or call_args[1].get("json")
+            assert "image" in payload
+
+            import base64
+
+            assert payload["image"] == base64.b64encode(image_bytes).decode("ascii")
+
+    @pytest.mark.asyncio
+    async def test_webhook_generic_format_no_image_when_none(self, service):
+        """Verify generic webhook omits image field when no image_data provided."""
+        config = {
+            "webhook_url": "http://test.local/webhook",
+            "field_title": "title",
+            "field_message": "message",
+        }
+
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+
+        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, message = await service._send_webhook(config, "Test Title", "Test Message")
+
+            assert success is True
+            call_args = mock_client.post.call_args
+            payload = call_args.kwargs.get("json") or call_args[1].get("json")
+            assert "image" not in payload
+
+    @pytest.mark.asyncio
+    async def test_webhook_slack_format_excludes_image(self, service):
+        """Verify Slack format does not include image even when provided."""
+        config = {
+            "webhook_url": "http://mattermost.local/hooks/abc123",
+            "payload_format": "slack",
+        }
+
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+
+        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, message = await service._send_webhook(
+                config, "Test Title", "Test Message", image_data=b"fake-image"
+            )
+
+            assert success is True
+            call_args = mock_client.post.call_args
+            payload = call_args.kwargs.get("json") or call_args[1].get("json")
+            assert "image" not in payload
+
 
 class TestHomeAssistantProvider:
     """Tests for Home Assistant notification provider."""

+ 8 - 4
backend/tests/unit/services/test_spoolman_service.py

@@ -298,11 +298,15 @@ class TestSpoolmanClient:
     async def test_clear_location_for_removed_spools_with_cached_spools(self, client):
         """Verify clear_location_for_removed_spools uses cached spools."""
         cached = [
-            {"id": 1, "location": "Printer1 - AMS A1", "extra": {"tag": '"TAG1"'}},
-            {"id": 2, "location": "Printer1 - AMS A2", "extra": {"tag": '"TAG2"'}},
-            {"id": 3, "location": "Printer1 - AMS A3", "extra": {"tag": '"TAG3"'}},
+            {"id": 1, "location": "Printer1 - AMS A1", "extra": {"tag": '"A1B2C3D4E5F60718293A4B5C6D7E8F90"'}},
+            {"id": 2, "location": "Printer1 - AMS A2", "extra": {"tag": '"B1C2D3E4F5061728394A5B6C7D8E9F01"'}},
+            {"id": 3, "location": "Printer1 - AMS A3", "extra": {"tag": '"C1D2E3F40516273849A5B6C7D8E9F012"'}},
         ]
-        current_tags = {"TAG1", "TAG2"}  # TAG3 was removed
+        # Tag 3 was cleared, so only tags 1 and 2 are current
+        current_tags = {
+            "A1B2C3D4E5F60718293A4B5C6D7E8F90",
+            "B1C2D3E4F5061728394A5B6C7D8E9F01",
+        }
 
         with (
             patch.object(client, "get_spools", AsyncMock()) as mock_get,

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

@@ -1089,8 +1089,13 @@ class TestSlicerProxyManager:
         assert proxy_manager.LOCAL_MQTT_PORT == 8883
         assert proxy_manager.PRINTER_FTP_PORT == 990
         assert proxy_manager.PRINTER_MQTT_PORT == 8883
+        assert proxy_manager.PRINTER_FILE_TRANSFER_PORT == 6000
+        assert proxy_manager.PRINTER_RTSP_PORT == 322
         # Bind ports: both 3000 and 3002 for slicer compatibility
         assert proxy_manager.PRINTER_BIND_PORTS == [3000, 3002]
+        # FTP data port range for transparent EPSV proxying
+        assert proxy_manager.FTP_DATA_PORT_MIN == 50000
+        assert proxy_manager.FTP_DATA_PORT_MAX == 50100
 
     def test_proxy_manager_stores_target_host(self, proxy_manager):
         """Verify proxy manager stores target host."""
@@ -1104,6 +1109,89 @@ class TestSlicerProxyManager:
         assert status["ftp_connections"] == 0
         assert status["mqtt_connections"] == 0
 
+    @pytest.mark.asyncio
+    async def test_proxy_start_creates_transparent_proxies(self, tmp_path):
+        """Verify start() uses TCPProxy for FTP/FileTransfer/RTSP and TLSProxy only for MQTT.
+
+        The transparent proxy architecture preserves end-to-end TLS between
+        slicer and printer for all protocols except MQTT, which must be
+        TLS-terminated to rewrite the printer's IP in MQTT payloads.
+        """
+        from unittest.mock import AsyncMock, patch
+
+        from backend.app.services.virtual_printer.tcp_proxy import (
+            SlicerProxyManager,
+            TCPProxy,
+            TLSProxy,
+        )
+
+        cert_path = tmp_path / "cert.pem"
+        key_path = tmp_path / "key.pem"
+        cert_path.write_text("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
+        key_path.write_text("-----BEGIN " + "PRIVATE KEY-----\ntest\n-----END " + "PRIVATE KEY-----")
+
+        mgr = SlicerProxyManager(
+            target_host="192.168.1.100",
+            cert_path=cert_path,
+            key_path=key_path,
+            bind_address="10.0.0.1",
+        )
+
+        # Mock asyncio.create_task and asyncio.gather to prevent actual server start
+        with (
+            patch("asyncio.create_task") as mock_create_task,
+            patch("asyncio.gather", new_callable=AsyncMock),
+            patch.object(SlicerProxyManager, "_log_activity"),
+        ):
+            mock_create_task.return_value = MagicMock()
+            # start() will create proxies then try to gather tasks — we just
+            # need to verify the proxy types after creation.
+            # Trigger start but let gather return immediately.
+            await mgr.start()
+
+        # FTP, FileTransfer, RTSP should be TCPProxy (transparent)
+        assert isinstance(mgr._ftp_proxy, TCPProxy), "FTP should be TCPProxy (transparent)"
+        assert isinstance(mgr._file_transfer_proxy, TCPProxy), "FileTransfer should be TCPProxy"
+        assert isinstance(mgr._rtsp_proxy, TCPProxy), "RTSP should be TCPProxy"
+
+        # MQTT should be TLSProxy (TLS-terminated for IP rewriting)
+        assert isinstance(mgr._mqtt_proxy, TLSProxy), "MQTT should be TLSProxy (TLS-terminated)"
+
+        # FTP data ports should be pre-created as TCPProxy instances
+        assert len(mgr._ftp_data_proxies) == 101  # 50000-50100 inclusive
+        for dp in mgr._ftp_data_proxies:
+            assert isinstance(dp, TCPProxy), "FTP data proxies should be TCPProxy"
+
+        # Verify FTP data proxies target the same port on the printer
+        first_dp = mgr._ftp_data_proxies[0]
+        assert first_dp.listen_port == 50000
+        assert first_dp.target_port == 50000
+        assert first_dp.target_host == "192.168.1.100"
+
+        last_dp = mgr._ftp_data_proxies[-1]
+        assert last_dp.listen_port == 50100
+        assert last_dp.target_port == 50100
+
+    def test_proxy_manager_mqtt_has_ip_rewriting(self, tmp_path):
+        """Verify MQTT proxy is configured with IP rewriting when bind_address is set."""
+        from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager
+
+        cert_path = tmp_path / "cert.pem"
+        key_path = tmp_path / "key.pem"
+        cert_path.write_text("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
+        key_path.write_text("-----BEGIN " + "PRIVATE KEY-----\ntest\n-----END " + "PRIVATE KEY-----")
+
+        mgr = SlicerProxyManager(
+            target_host="192.168.1.100",
+            cert_path=cert_path,
+            key_path=key_path,
+            bind_address="10.0.0.1",
+        )
+
+        # Before start, proxies are None — verify constructor stores rewrite config
+        assert mgr.bind_address == "10.0.0.1"
+        assert mgr.target_host == "192.168.1.100"
+
 
 class TestSSDPProxy:
     """Tests for SSDPProxy (cross-network SSDP relay)."""
@@ -1567,3 +1655,218 @@ class TestResolveModelCodes:
 
         assert _resolve_printer_model(None) is None
         assert _resolve_printer_model("UnknownModel") is None
+
+
+class TestMqttIpRewrite:
+    """Tests for TLSProxy._rewrite_mqtt_ip() MQTT packet IP rewriting."""
+
+    @staticmethod
+    def _build_mqtt_publish(topic: str, payload: bytes) -> bytes:
+        """Build a minimal MQTT PUBLISH packet."""
+        # PUBLISH fixed header: type 3, no flags
+        topic_bytes = topic.encode("utf-8")
+        # Variable header: topic length (2 bytes) + topic
+        var_header = len(topic_bytes).to_bytes(2, "big") + topic_bytes
+        body = var_header + payload
+
+        # Encode remaining length
+        remaining = len(body)
+        header = bytearray([0x30])  # PUBLISH, QoS 0
+        while True:
+            encoded_byte = remaining % 128
+            remaining //= 128
+            if remaining > 0:
+                encoded_byte |= 0x80
+            header.append(encoded_byte)
+            if remaining == 0:
+                break
+
+        return bytes(header) + body
+
+    @staticmethod
+    def _build_mqtt_pingreq() -> bytes:
+        """Build an MQTT PINGREQ packet (2 bytes, no payload)."""
+        return b"\xc0\x00"
+
+    def test_rewrite_ip_in_publish(self):
+        """IP string in PUBLISH payload is rewritten."""
+        from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
+
+        payload = b'{"rtsp_url":"rtsps://192.168.1.100:322/live"}'
+        packet = self._build_mqtt_publish("device/status", payload)
+
+        result, buf = TLSProxy._rewrite_mqtt_ip(packet, b"192.168.1.100", b"10.0.0.1", bytearray())
+
+        assert b"10.0.0.1" in result
+        assert b"192.168.1.100" not in result
+
+    def test_no_rewrite_when_ip_absent(self):
+        """Packets without the target IP are passed through unchanged."""
+        from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
+
+        payload = b'{"status":"idle"}'
+        packet = self._build_mqtt_publish("device/status", payload)
+
+        result, buf = TLSProxy._rewrite_mqtt_ip(packet, b"192.168.1.100", b"10.0.0.1", bytearray())
+
+        assert result == packet
+
+    def test_non_publish_packets_unchanged(self):
+        """Non-PUBLISH packets (e.g. PINGREQ) are never rewritten."""
+        from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
+
+        pingreq = self._build_mqtt_pingreq()
+        result, buf = TLSProxy._rewrite_mqtt_ip(pingreq, b"192.168.1.100", b"10.0.0.1", bytearray())
+
+        assert result == pingreq
+
+    def test_rewrite_preserves_packet_framing(self):
+        """Rewritten packet has valid MQTT remaining length."""
+        from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
+
+        # Use IPs of different lengths to test length re-encoding
+        old_ip = b"192.168.255.133"  # 15 bytes
+        new_ip = b"10.0.0.1"  # 8 bytes
+
+        payload = b'{"ip":"192.168.255.133"}'
+        packet = self._build_mqtt_publish("device/status", payload)
+
+        result, buf = TLSProxy._rewrite_mqtt_ip(packet, old_ip, new_ip, bytearray())
+
+        # Parse the result to verify framing
+        assert result[0] == 0x30  # PUBLISH header byte
+        # Decode remaining length
+        pos = 1
+        remaining = 0
+        multiplier = 1
+        while True:
+            b = result[pos]
+            pos += 1
+            remaining += (b & 0x7F) * multiplier
+            multiplier *= 128
+            if (b & 0x80) == 0:
+                break
+
+        # Remaining length should match actual data
+        assert pos + remaining == len(result)
+        assert new_ip in result
+
+    def test_incomplete_packet_buffered(self):
+        """Incomplete packet at end of chunk is buffered for next call."""
+        from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
+
+        payload = b'{"ip":"192.168.1.100"}'
+        packet = self._build_mqtt_publish("device/status", payload)
+
+        # Split packet in the middle
+        half = len(packet) // 2
+        chunk1 = packet[:half]
+        chunk2 = packet[half:]
+
+        result1, buf = TLSProxy._rewrite_mqtt_ip(chunk1, b"192.168.1.100", b"10.0.0.1", bytearray())
+        # First chunk should be buffered (incomplete packet)
+        assert len(buf) > 0
+
+        result2, buf = TLSProxy._rewrite_mqtt_ip(chunk2, b"192.168.1.100", b"10.0.0.1", buf)
+        # Second chunk completes the packet, IP should be rewritten
+        combined = result1 + result2
+        assert b"10.0.0.1" in combined
+        assert b"192.168.1.100" not in combined
+
+    def test_multiple_packets_in_one_chunk(self):
+        """Multiple MQTT packets in a single chunk are all processed."""
+        from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
+
+        payload1 = b'{"ip":"192.168.1.100"}'
+        payload2 = b'{"other":"data"}'
+        packet1 = self._build_mqtt_publish("topic1", payload1)
+        packet2 = self._build_mqtt_publish("topic2", payload2)
+
+        combined = packet1 + packet2
+        result, buf = TLSProxy._rewrite_mqtt_ip(combined, b"192.168.1.100", b"10.0.0.1", bytearray())
+
+        assert b"10.0.0.1" in result
+        assert b"192.168.1.100" not in result
+        # Second packet should still be present
+        assert b"other" in result
+
+    def test_extra_replacements(self):
+        """Extra replacement pairs (e.g. integer IP) are also applied."""
+        from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
+
+        payload = b'{"net":{"info":[{"ip":2248124608}]}}'
+        packet = self._build_mqtt_publish("device/status", payload)
+
+        result, buf = TLSProxy._rewrite_mqtt_ip(
+            packet,
+            b"NOMATCH",
+            b"NOREPLACE",
+            bytearray(),
+            extra_replacements=[(b"2248124608", b"285190336")],
+        )
+
+        assert b"285190336" in result
+        assert b"2248124608" not in result
+
+
+class TestIpToLeIntBytes:
+    """Tests for TLSProxy._ip_to_le_int_bytes() integer IP conversion."""
+
+    def test_converts_ip_to_le_int(self):
+        from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
+
+        assert TLSProxy._ip_to_le_int_bytes("192.168.255.133") == b"2248124608"
+        assert TLSProxy._ip_to_le_int_bytes("192.168.255.16") == b"285190336"
+        assert TLSProxy._ip_to_le_int_bytes("10.0.0.1") == b"16777226"
+
+    def test_roundtrip(self):
+        """Verify the integer converts back to the correct IP."""
+        import struct
+
+        from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
+
+        for ip in ["192.168.1.1", "10.0.0.1", "172.16.0.100", "192.168.255.133"]:
+            le_int = int(TLSProxy._ip_to_le_int_bytes(ip))
+            parts = ip.split(".")
+            expected = struct.unpack("<I", bytes(int(p) for p in parts))[0]
+            assert le_int == expected
+
+
+class TestSSDPProxyName:
+    """Tests for SSDPProxy VP name rewriting."""
+
+    @pytest.fixture
+    def ssdp_proxy_with_name(self):
+        from backend.app.services.virtual_printer.ssdp_server import SSDPProxy
+
+        return SSDPProxy(
+            local_interface_ip="192.168.1.100",
+            remote_interface_ip="10.0.0.100",
+            target_printer_ip="192.168.1.50",
+            name="H2D-1 Proxy",
+        )
+
+    @pytest.fixture
+    def ssdp_proxy_without_name(self):
+        from backend.app.services.virtual_printer.ssdp_server import SSDPProxy
+
+        return SSDPProxy(
+            local_interface_ip="192.168.1.100",
+            remote_interface_ip="10.0.0.100",
+            target_printer_ip="192.168.1.50",
+        )
+
+    def test_rewrite_uses_configured_name(self, ssdp_proxy_with_name):
+        """When name is set, DevName is replaced entirely."""
+        packet = b"NOTIFY * HTTP/1.1\r\nLocation: 192.168.1.50\r\nDevName.bambu.com: RealPrinter\r\nDevBind.bambu.com: cloud\r\n\r\n"
+        rewritten = ssdp_proxy_with_name._rewrite_ssdp(packet)
+
+        assert b"DevName.bambu.com: H2D-1 Proxy" in rewritten
+        assert b"RealPrinter" not in rewritten
+
+    def test_rewrite_appends_proxy_without_name(self, ssdp_proxy_without_name):
+        """When no name is set, ' - Proxy' is appended to the real name."""
+        packet = b"NOTIFY * HTTP/1.1\r\nLocation: 192.168.1.50\r\nDevName.bambu.com: RealPrinter\r\nDevBind.bambu.com: cloud\r\n\r\n"
+        rewritten = ssdp_proxy_without_name._rewrite_ssdp(packet)
+
+        assert b"DevName.bambu.com: RealPrinter - Proxy" in rewritten

+ 130 - 26
backend/tests/unit/test_bug_report.py

@@ -212,17 +212,16 @@ class TestBugReportService:
         assert "GitHub API error" in result["message"]
 
 
-class TestCollectDebugLogs:
-    """Tests for _collect_debug_logs()."""
+class TestStartLogging:
+    """Tests for the start-logging endpoint handler."""
 
     @pytest.mark.asyncio
     @pytest.mark.unit
     async def test_enables_debug_when_not_already_enabled(self):
-        """Debug logging is enabled, then restored after collection."""
-        from backend.app.api.routes.bug_report import _collect_debug_logs
+        """Debug logging is enabled and printers are pushed."""
+        from backend.app.api.routes.bug_report import start_logging
 
         apply_calls = []
-
         mock_db = AsyncMock()
 
         with (
@@ -234,25 +233,26 @@ class TestCollectDebugLogs:
                 side_effect=lambda v: apply_calls.append(v),
             ),
             patch("backend.app.api.routes.bug_report.printer_manager") as mock_pm,
-            patch("backend.app.api.routes.bug_report._get_recent_sanitized_logs", return_value="DEBUG log line"),
-            patch("backend.app.api.routes.bug_report.asyncio.sleep", new_callable=AsyncMock),
-            patch("backend.app.api.routes.bug_report.LOG_COLLECTION_SECONDS", 0),
         ):
-            mock_pm._clients = {}
+            mock_pm._clients = {"printer1": MagicMock()}
             mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
             mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
 
-            result = await _collect_debug_logs()
+            result = await start_logging()
 
-        assert result == "DEBUG log line"
-        assert apply_calls == [True, False]  # enabled then restored
-        assert mock_set.call_count == 2
+        assert result.started is True
+        assert result.was_debug is False
+        assert apply_calls == [True]
+        mock_set.assert_called_once()
+        mock_pm.request_status_update.assert_called_once_with("printer1")
 
     @pytest.mark.asyncio
     @pytest.mark.unit
     async def test_skips_enable_when_already_debug(self):
         """Debug logging not toggled when already enabled."""
-        from backend.app.api.routes.bug_report import _collect_debug_logs
+        mock_db = AsyncMock()
+
+        from backend.app.api.routes.bug_report import start_logging
 
         with (
             patch("backend.app.api.routes.bug_report.async_session") as mock_session,
@@ -260,18 +260,15 @@ class TestCollectDebugLogs:
             patch("backend.app.api.routes.bug_report._set_debug_setting", new_callable=AsyncMock) as mock_set,
             patch("backend.app.api.routes.bug_report._apply_log_level") as mock_apply,
             patch("backend.app.api.routes.bug_report.printer_manager") as mock_pm,
-            patch("backend.app.api.routes.bug_report._get_recent_sanitized_logs", return_value="logs"),
-            patch("backend.app.api.routes.bug_report.asyncio.sleep", new_callable=AsyncMock),
-            patch("backend.app.api.routes.bug_report.LOG_COLLECTION_SECONDS", 0),
         ):
             mock_pm._clients = {}
-            mock_db = AsyncMock()
             mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
             mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
 
-            result = await _collect_debug_logs()
+            result = await start_logging()
 
-        assert result == "logs"
+        assert result.started is True
+        assert result.was_debug is True
         mock_apply.assert_not_called()
         mock_set.assert_not_called()
 
@@ -279,7 +276,9 @@ class TestCollectDebugLogs:
     @pytest.mark.unit
     async def test_pushes_all_connected_printers(self):
         """Sends status update request to all connected printers."""
-        from backend.app.api.routes.bug_report import _collect_debug_logs
+        mock_db = AsyncMock()
+
+        from backend.app.api.routes.bug_report import start_logging
 
         with (
             patch("backend.app.api.routes.bug_report.async_session") as mock_session,
@@ -287,20 +286,125 @@ class TestCollectDebugLogs:
             patch("backend.app.api.routes.bug_report._set_debug_setting", new_callable=AsyncMock),
             patch("backend.app.api.routes.bug_report._apply_log_level"),
             patch("backend.app.api.routes.bug_report.printer_manager") as mock_pm,
-            patch("backend.app.api.routes.bug_report._get_recent_sanitized_logs", return_value=""),
-            patch("backend.app.api.routes.bug_report.asyncio.sleep", new_callable=AsyncMock),
-            patch("backend.app.api.routes.bug_report.LOG_COLLECTION_SECONDS", 0),
         ):
             mock_pm._clients = {"printer1": MagicMock(), "printer2": MagicMock()}
-            mock_db = AsyncMock()
             mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
             mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
 
-            await _collect_debug_logs()
+            await start_logging()
 
         assert mock_pm.request_status_update.call_count == 2
 
 
+class TestStopLogging:
+    """Tests for the stop-logging endpoint handler."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_collects_logs_and_restores_level(self):
+        """Collects logs and restores log level when was_debug=False."""
+        from backend.app.api.routes.bug_report import stop_logging
+
+        apply_calls = []
+        mock_db = AsyncMock()
+
+        with (
+            patch("backend.app.api.routes.bug_report.async_session") as mock_session,
+            patch("backend.app.api.routes.bug_report._set_debug_setting", new_callable=AsyncMock) as mock_set,
+            patch(
+                "backend.app.api.routes.bug_report._apply_log_level",
+                side_effect=lambda v: apply_calls.append(v),
+            ),
+            patch("backend.app.api.routes.bug_report._get_recent_sanitized_logs", return_value="DEBUG log line"),
+        ):
+            mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            result = await stop_logging(was_debug=False)
+
+        assert result.logs == "DEBUG log line"
+        assert apply_calls == [False]
+        mock_set.assert_called_once()
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_skips_restore_when_was_debug(self):
+        """Does not restore log level when was_debug=True."""
+        from backend.app.api.routes.bug_report import stop_logging
+
+        with (
+            patch("backend.app.api.routes.bug_report.async_session") as mock_session,
+            patch("backend.app.api.routes.bug_report._set_debug_setting", new_callable=AsyncMock) as mock_set,
+            patch("backend.app.api.routes.bug_report._apply_log_level") as mock_apply,
+            patch("backend.app.api.routes.bug_report._get_recent_sanitized_logs", return_value="logs"),
+        ):
+            mock_db = AsyncMock()
+            mock_session.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            result = await stop_logging(was_debug=True)
+
+        assert result.logs == "logs"
+        mock_apply.assert_not_called()
+        mock_set.assert_not_called()
+
+
+class TestSubmitBugReportRoute:
+    """Tests for the submit_bug_report route handler."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_uses_provided_debug_logs(self):
+        """When debug_logs is provided, it is used as recent_logs."""
+        from backend.app.api.routes.bug_report import BugReportRequest, submit_bug_report
+
+        report = BugReportRequest(
+            description="Test bug",
+            debug_logs="pre-collected debug logs",
+        )
+
+        with (
+            patch("backend.app.api.routes.bug_report._collect_support_info", return_value={"version": "1.0"}),
+            patch("backend.app.api.routes.bug_report.submit_report", new_callable=AsyncMock) as mock_submit,
+        ):
+            mock_submit.return_value = {
+                "success": True,
+                "message": "Created",
+                "issue_url": "https://github.com/maziggy/bambuddy/issues/1",
+                "issue_number": 1,
+            }
+
+            result = await submit_bug_report(report)
+
+        assert result.success is True
+        call_kwargs = mock_submit.call_args[1]
+        assert call_kwargs["support_info"]["recent_logs"] == "pre-collected debug logs"
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_no_logs_when_debug_logs_not_provided(self):
+        """When debug_logs is None, recent_logs is not added."""
+        from backend.app.api.routes.bug_report import BugReportRequest, submit_bug_report
+
+        report = BugReportRequest(description="Test bug")
+
+        with (
+            patch("backend.app.api.routes.bug_report._collect_support_info", return_value={"version": "1.0"}),
+            patch("backend.app.api.routes.bug_report.submit_report", new_callable=AsyncMock) as mock_submit,
+        ):
+            mock_submit.return_value = {
+                "success": True,
+                "message": "Created",
+                "issue_url": None,
+                "issue_number": None,
+            }
+
+            await submit_bug_report(report)
+
+        call_kwargs = mock_submit.call_args[1]
+        assert "recent_logs" not in call_kwargs["support_info"]
+
+
 class TestRateLimit:
     """Tests for rate limiting in bug report service."""
 

+ 2 - 4
backend/tests/unit/test_maintenance_rod_filtering.py

@@ -11,16 +11,14 @@ class TestShouldApplyToPrinter:
     # Carbon rod tasks should only apply to X1/P1 models
     @pytest.mark.parametrize("model", ["X1C", "X1", "X1E", "P1P", "P1S"])
     def test_carbon_rod_tasks_apply_to_carbon_models(self, model: str):
-        assert _should_apply_to_printer("Lubricate Carbon Rods", model) is True
         assert _should_apply_to_printer("Clean Carbon Rods", model) is True
 
     def test_carbon_rod_tasks_do_not_apply_to_p2s(self):
         """P2S has steel rods, not carbon rods (#640)."""
-        assert _should_apply_to_printer("Lubricate Carbon Rods", "P2S") is False
         assert _should_apply_to_printer("Clean Carbon Rods", "P2S") is False
 
     def test_carbon_rod_tasks_do_not_apply_to_a1(self):
-        assert _should_apply_to_printer("Lubricate Carbon Rods", "A1") is False
+        assert _should_apply_to_printer("Clean Carbon Rods", "A1") is False
 
     # Steel rod tasks should only apply to P2S
     def test_steel_rod_tasks_apply_to_p2s(self):
@@ -51,6 +49,6 @@ class TestShouldApplyToPrinter:
 
     # Unknown models default to carbon (legacy behavior)
     def test_unknown_model_defaults_to_carbon(self):
-        assert _should_apply_to_printer("Lubricate Carbon Rods", "UNKNOWN") is True
+        assert _should_apply_to_printer("Clean Carbon Rods", "UNKNOWN") is True
         assert _should_apply_to_printer("Lubricate Steel Rods", "UNKNOWN") is False
         assert _should_apply_to_printer("Lubricate Linear Rails", "UNKNOWN") is False

+ 77 - 0
backend/tests/unit/test_print_speed.py

@@ -0,0 +1,77 @@
+"""Unit tests for the print speed control endpoint.
+
+Tests POST /api/v1/printers/{printer_id}/print-speed?mode=N
+where mode is 1=silent, 2=standard, 3=sport, 4=ludicrous.
+"""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestPrintSpeedAPI:
+    """Tests for the print speed control endpoint."""
+
+    @pytest.mark.asyncio
+    async def test_print_speed_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent printer."""
+        response = await async_client.post("/api/v1/printers/99999/print-speed?mode=2")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    async def test_print_speed_not_connected(self, async_client: AsyncClient, printer_factory):
+        """Verify error when printer is not connected."""
+        printer = await printer_factory(name="Disconnected Printer")
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = None
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/print-speed?mode=2")
+
+            assert response.status_code == 400
+            assert "not connected" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    async def test_print_speed_failure(self, async_client: AsyncClient, printer_factory):
+        """Verify 500 when client fails to set speed."""
+        printer = await printer_factory(name="Test Printer")
+
+        mock_client = MagicMock()
+        mock_client.set_print_speed.return_value = False
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/print-speed?mode=2")
+
+            assert response.status_code == 500
+            assert "failed" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        "mode, expected_name",
+        [
+            (1, "Silent"),
+            (2, "Standard"),
+            (3, "Sport"),
+            (4, "Ludicrous"),
+        ],
+    )
+    async def test_print_speed_success(self, async_client: AsyncClient, printer_factory, mode, expected_name):
+        """Verify successful speed change for each mode (1-4)."""
+        printer = await printer_factory(name="Test Printer")
+
+        mock_client = MagicMock()
+        mock_client.set_print_speed.return_value = True
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/print-speed?mode={mode}")
+
+            assert response.status_code == 200
+            result = response.json()
+            assert result["success"] is True
+            assert expected_name in result["message"]
+            mock_client.set_print_speed.assert_called_once_with(mode)

+ 31 - 0
backend/tests/unit/test_scheduler_busy_only.py

@@ -0,0 +1,31 @@
+"""Tests for _is_busy_only() in the print scheduler."""
+
+from backend.app.services.print_scheduler import PrintScheduler
+
+
+class TestIsBusyOnly:
+    """Test the _is_busy_only static method."""
+
+    def test_single_busy(self):
+        assert PrintScheduler._is_busy_only("Busy: Printer1") is True
+
+    def test_multiple_busy(self):
+        assert PrintScheduler._is_busy_only("Busy: Printer1, Printer2") is True
+
+    def test_busy_and_offline(self):
+        assert PrintScheduler._is_busy_only("Busy: Printer1 | Offline: Printer2") is False
+
+    def test_busy_and_filament(self):
+        assert PrintScheduler._is_busy_only("Busy: Printer1 | Waiting for filament: Printer2 (needs PLA)") is False
+
+    def test_offline_only(self):
+        assert PrintScheduler._is_busy_only("Offline: Printer1") is False
+
+    def test_filament_only(self):
+        assert PrintScheduler._is_busy_only("Waiting for filament: Printer1 (needs PLA)") is False
+
+    def test_no_matching_color(self):
+        assert PrintScheduler._is_busy_only("No matching material/color. Waiting on PLA (Blue)") is False
+
+    def test_no_available_printers(self):
+        assert PrintScheduler._is_busy_only("No available P1S printers configured") is False

+ 123 - 0
backend/tests/unit/test_user_notifications.py

@@ -0,0 +1,123 @@
+"""Tests for user email notification preferences and permissions."""
+
+from backend.app.core.permissions import (
+    ALL_PERMISSIONS,
+    DEFAULT_GROUPS,
+    PERMISSION_CATEGORIES,
+    Permission,
+)
+from backend.app.schemas.user_notifications import (
+    UserEmailPreferenceResponse,
+    UserEmailPreferenceUpdate,
+)
+
+
+class TestNotificationsUserEmailPermission:
+    """Test the NOTIFICATIONS_USER_EMAIL permission integration."""
+
+    def test_permission_exists(self):
+        """notifications:user_email permission should exist in the enum."""
+        assert hasattr(Permission, "NOTIFICATIONS_USER_EMAIL")
+        assert Permission.NOTIFICATIONS_USER_EMAIL == "notifications:user_email"
+
+    def test_permission_in_all_permissions(self):
+        """notifications:user_email should be in ALL_PERMISSIONS list."""
+        assert "notifications:user_email" in ALL_PERMISSIONS
+
+    def test_permission_in_notifications_category(self):
+        """notifications:user_email should be in the Notifications permission category."""
+        notifications_perms = PERMISSION_CATEGORIES["Notifications"]
+        assert Permission.NOTIFICATIONS_USER_EMAIL in notifications_perms
+
+    def test_administrators_have_permission(self):
+        """Administrators should have notifications:user_email via ALL_PERMISSIONS."""
+        admins = DEFAULT_GROUPS["Administrators"]
+        assert "notifications:user_email" in admins["permissions"]
+
+    def test_operators_have_permission(self):
+        """Operators should have notifications:user_email for managing their own preferences."""
+        operators = DEFAULT_GROUPS["Operators"]
+        assert "notifications:user_email" in operators["permissions"]
+
+    def test_viewers_do_not_have_permission(self):
+        """Viewers (read-only) should not have notifications:user_email."""
+        viewers = DEFAULT_GROUPS["Viewers"]
+        assert "notifications:user_email" not in viewers["permissions"]
+
+    def test_permission_separate_from_notifications_read(self):
+        """user_email and read should be distinct permissions."""
+        assert Permission.NOTIFICATIONS_USER_EMAIL != Permission.NOTIFICATIONS_READ
+        assert Permission.NOTIFICATIONS_USER_EMAIL.value != Permission.NOTIFICATIONS_READ.value
+
+
+class TestUserEmailPreferenceSchemas:
+    """Test the user email preference Pydantic schemas."""
+
+    def test_response_schema_defaults(self):
+        """Response schema should accept all four boolean fields."""
+        resp = UserEmailPreferenceResponse(
+            notify_print_start=True,
+            notify_print_complete=True,
+            notify_print_failed=True,
+            notify_print_stopped=True,
+        )
+        assert resp.notify_print_start is True
+        assert resp.notify_print_complete is True
+        assert resp.notify_print_failed is True
+        assert resp.notify_print_stopped is True
+
+    def test_response_schema_all_disabled(self):
+        """Response schema should handle all-disabled preferences."""
+        resp = UserEmailPreferenceResponse(
+            notify_print_start=False,
+            notify_print_complete=False,
+            notify_print_failed=False,
+            notify_print_stopped=False,
+        )
+        assert resp.notify_print_start is False
+        assert resp.notify_print_complete is False
+        assert resp.notify_print_failed is False
+        assert resp.notify_print_stopped is False
+
+    def test_update_schema_accepts_mixed(self):
+        """Update schema should accept a mix of enabled/disabled."""
+        update = UserEmailPreferenceUpdate(
+            notify_print_start=True,
+            notify_print_complete=False,
+            notify_print_failed=True,
+            notify_print_stopped=False,
+        )
+        assert update.notify_print_start is True
+        assert update.notify_print_complete is False
+        assert update.notify_print_failed is True
+        assert update.notify_print_stopped is False
+
+    def test_response_schema_from_attributes(self):
+        """Response schema should support from_attributes (ORM mode)."""
+        assert UserEmailPreferenceResponse.model_config.get("from_attributes") is True
+
+
+class TestNotificationTemplateTypes:
+    """Test that user print notification template types are registered."""
+
+    def test_user_print_template_types_exist(self):
+        """All four user print email template types should be in EVENT_NAMES."""
+        from backend.app.api.routes.notification_templates import EVENT_NAMES
+
+        expected_types = [
+            "user_print_start",
+            "user_print_complete",
+            "user_print_failed",
+            "user_print_stopped",
+        ]
+        for event_type in expected_types:
+            assert event_type in EVENT_NAMES, f"{event_type} not in EVENT_NAMES"
+
+    def test_user_print_template_display_names(self):
+        """User print template display names should be descriptive."""
+        from backend.app.api.routes.notification_templates import EVENT_NAMES
+
+        assert EVENT_NAMES["user_print_start"] == "User Print Started Email"
+        assert EVENT_NAMES["user_print_complete"] == "User Print Completed Email"
+        assert EVENT_NAMES["user_print_failed"] == "User Print Failed Email"
+        assert EVENT_NAMES["user_print_stopped"] == "User Print Stopped Email"

+ 4 - 2
docker-compose.yml

@@ -10,8 +10,8 @@ services:
     # Override with: PUID=$(id -u) PGID=$(id -g) docker compose up -d
     user: "${PUID:-1000}:${PGID:-1000}"
     #
-    # Proxy mode: allow binding to port 990 (FTP) as non-root user.
-    # Without this, the FTP proxy silently fails and sending prints won't work.
+    # Proxy mode: allow binding to privileged ports (322, 990) as non-root user.
+    # Without this, the FTP and RTSP proxies silently fail.
     cap_add:
       - NET_BIND_SERVICE
     #
@@ -27,6 +27,8 @@ services:
     #  - "3002:3002"                  # Virtual printer bind/detect
     #  - "8883:8883"                  # Virtual printer MQTT
     #  - "990:990"                    # Virtual printer FTP control
+    #  - "6000:6000"                  # Virtual printer file transfer tunnel
+    #  - "322:322"                    # Virtual printer RTSP camera (X1/H2/P2)
     #  - "50000-50100:50000-50100"    # Virtual printer FTP passive data
     volumes:
       - bambuddy_data:/app/data

+ 41 - 38
docker-publish-daily-beta.sh

@@ -12,12 +12,11 @@
 #   ./docker-publish-daily-beta.sh --skip-release   # Build+push without GitHub release
 #
 # Reads APP_VERSION from backend/app/core/config.py (must be a beta version like 0.2.2b1).
-# Appends a daily date suffix to create a unique tag (e.g., 0.2.2b3-daily.20260311),
-# avoiding collisions with release tags. Builds and pushes a multi-arch Docker image.
-# Optionally creates/updates a GitHub prerelease.
+# Builds and pushes a multi-arch Docker image tagged as 'daily'. Each push overwrites the
+# previous 'daily' image. A GitHub prerelease is created with a date-stamped tag for history.
 #
-# Beta versions are never tagged as 'latest'. Users update by pulling the daily tag
-# (e.g., docker pull ghcr.io/maziggy/bambuddy:0.2.2b3-daily.20260311) or using Watchtower.
+# Users can stay up to date by pulling the 'daily' tag or using Watchtower:
+#   docker pull ghcr.io/maziggy/bambuddy:daily
 #
 # Prerequisites:
 #   1. Log in to ghcr.io:
@@ -109,12 +108,13 @@ if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+b[0-9]+$ ]]; then
     exit 1
 fi
 
-# Append date suffix to differentiate daily builds from releases
+# Date-stamped tag for GitHub releases only (not used as Docker tag)
 DAILY_DATE=$(date +%Y%m%d)
 DAILY_TAG="${VERSION}-daily.${DAILY_DATE}"
 
 echo -e "${GREEN}  APP_VERSION: ${VERSION}${NC}"
-echo -e "${GREEN}  Daily tag:   ${DAILY_TAG}${NC}"
+echo -e "${GREEN}  Docker tag:  daily${NC}"
+echo -e "${GREEN}  Release tag: v${DAILY_TAG}${NC}"
 
 # ============================================================
 # Step 2: Build & push Docker images
@@ -126,8 +126,8 @@ CPU_COUNT=$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)
 
 echo -e "${GREEN}================================================${NC}"
 echo -e "${GREEN}  Daily beta build${NC}"
-echo -e "${GREEN}  Version: ${VERSION}${NC}"
-echo -e "${GREEN}  Tag:     ${DAILY_TAG}${NC}"
+echo -e "${GREEN}  Version:   ${VERSION}${NC}"
+echo -e "${GREEN}  Docker tag: daily${NC}"
 echo -e "${GREEN}  Platforms: ${PLATFORMS}${NC}"
 echo -e "${GREEN}  CPU cores: ${CPU_COUNT}${NC}"
 if [ "$PARALLEL" = true ]; then
@@ -187,16 +187,16 @@ if ! docker buildx inspect --bootstrap | grep -q "linux/arm64"; then
     docker run --privileged --rm tonistiigi/binfmt --install all
 fi
 
-# Beta versions never get 'latest' tag
-echo -e "${YELLOW}Beta version — skipping 'latest' tag${NC}"
+# Beta versions get 'daily' tag (never 'latest')
+echo -e "${YELLOW}Beta version — tagging as 'daily' (not 'latest')${NC}"
 
 # Build tags for all target registries
 TAGS=""
 if [ "$PUSH_GHCR" = true ]; then
-    TAGS="$TAGS -t ${GHCR_IMAGE}:${DAILY_TAG}"
+    TAGS="$TAGS -t ${GHCR_IMAGE}:daily"
 fi
 if [ "$PUSH_DOCKERHUB" = true ]; then
-    TAGS="$TAGS -t ${DOCKERHUB_IMAGE}:${DAILY_TAG}"
+    TAGS="$TAGS -t ${DOCKERHUB_IMAGE}:daily"
 fi
 
 # Common build args (no cache to ensure clean builds)
@@ -210,12 +210,12 @@ if [ "$PARALLEL" = true ]; then
     ARCH_TAGS_AMD64=""
     ARCH_TAGS_ARM64=""
     if [ "$PUSH_GHCR" = true ]; then
-        ARCH_TAGS_AMD64="$ARCH_TAGS_AMD64 -t ${GHCR_IMAGE}:${DAILY_TAG}-amd64"
-        ARCH_TAGS_ARM64="$ARCH_TAGS_ARM64 -t ${GHCR_IMAGE}:${DAILY_TAG}-arm64"
+        ARCH_TAGS_AMD64="$ARCH_TAGS_AMD64 -t ${GHCR_IMAGE}:daily-amd64"
+        ARCH_TAGS_ARM64="$ARCH_TAGS_ARM64 -t ${GHCR_IMAGE}:daily-arm64"
     fi
     if [ "$PUSH_DOCKERHUB" = true ]; then
-        ARCH_TAGS_AMD64="$ARCH_TAGS_AMD64 -t ${DOCKERHUB_IMAGE}:${DAILY_TAG}-amd64"
-        ARCH_TAGS_ARM64="$ARCH_TAGS_ARM64 -t ${DOCKERHUB_IMAGE}:${DAILY_TAG}-arm64"
+        ARCH_TAGS_AMD64="$ARCH_TAGS_AMD64 -t ${DOCKERHUB_IMAGE}:daily-amd64"
+        ARCH_TAGS_ARM64="$ARCH_TAGS_ARM64 -t ${DOCKERHUB_IMAGE}:daily-arm64"
     fi
 
     # Build amd64 in background
@@ -255,16 +255,16 @@ if [ "$PARALLEL" = true ]; then
     if [ "$PUSH_GHCR" = true ]; then
         echo -e "${BLUE}  Creating GHCR manifest...${NC}"
         docker buildx imagetools create \
-            -t "${GHCR_IMAGE}:${DAILY_TAG}" \
-            "${GHCR_IMAGE}:${DAILY_TAG}-amd64" \
-            "${GHCR_IMAGE}:${DAILY_TAG}-arm64"
+            -t "${GHCR_IMAGE}:daily" \
+            "${GHCR_IMAGE}:daily-amd64" \
+            "${GHCR_IMAGE}:daily-arm64"
     fi
     if [ "$PUSH_DOCKERHUB" = true ]; then
         echo -e "${BLUE}  Creating Docker Hub manifest...${NC}"
         docker buildx imagetools create \
-            -t "${DOCKERHUB_IMAGE}:${DAILY_TAG}" \
-            "${DOCKERHUB_IMAGE}:${DAILY_TAG}-amd64" \
-            "${DOCKERHUB_IMAGE}:${DAILY_TAG}-arm64"
+            -t "${DOCKERHUB_IMAGE}:daily" \
+            "${DOCKERHUB_IMAGE}:daily-amd64" \
+            "${DOCKERHUB_IMAGE}:daily-arm64"
     fi
 else
     # Sequential build (default): Build both platforms in one command
@@ -296,15 +296,15 @@ else
     # Build pull commands for the release body
     PULL_COMMANDS=""
     if [ "$PUSH_GHCR" = true ]; then
-        PULL_COMMANDS="docker pull ghcr.io/maziggy/bambuddy:${DAILY_TAG}"
+        PULL_COMMANDS="docker pull ghcr.io/maziggy/bambuddy:daily"
     fi
     if [ "$PUSH_DOCKERHUB" = true ]; then
         if [ -n "$PULL_COMMANDS" ]; then
             PULL_COMMANDS="${PULL_COMMANDS}
 # or
-docker pull maziggy/bambuddy:${DAILY_TAG}"
+docker pull maziggy/bambuddy:daily"
         else
-            PULL_COMMANDS="docker pull maziggy/bambuddy:${DAILY_TAG}"
+            PULL_COMMANDS="docker pull maziggy/bambuddy:daily"
         fi
     fi
 
@@ -327,11 +327,14 @@ ${CHANGELOG_NOTES}
 EOF
     )
 
-    # Delete existing release so the new one gets today's date
-    # (gh release edit only updates title/notes, not the creation timestamp)
-    if gh release view "v${DAILY_TAG}" >/dev/null 2>&1; then
-        echo "  Deleting old release v${DAILY_TAG} (will recreate with today's date)..."
-        gh release delete "v${DAILY_TAG}" --yes --cleanup-tag
+    # Delete ALL old daily releases — only the latest daily build should exist
+    echo "  Cleaning up old daily releases..."
+    OLD_DAILY_RELEASES=$(gh release list --limit 100 --json tagName --jq '.[] | select(.tagName | test("-daily\\.")) | .tagName' 2>/dev/null || true)
+    if [ -n "$OLD_DAILY_RELEASES" ]; then
+        while IFS= read -r old_tag; do
+            echo "  Deleting old daily release: ${old_tag}..."
+            gh release delete "$old_tag" --yes --cleanup-tag 2>/dev/null || true
+        done <<< "$OLD_DAILY_RELEASES"
     fi
 
     # Create/move tag to current HEAD and push
@@ -354,11 +357,11 @@ echo -e "${BLUE}[4/4] Verifying...${NC}"
 
 if [ "$PUSH_GHCR" = true ]; then
     echo -e "${BLUE}GHCR manifest:${NC}"
-    docker buildx imagetools inspect "${GHCR_IMAGE}:${DAILY_TAG}"
+    docker buildx imagetools inspect "${GHCR_IMAGE}:daily"
 fi
 if [ "$PUSH_DOCKERHUB" = true ]; then
     echo -e "${BLUE}Docker Hub manifest:${NC}"
-    docker buildx imagetools inspect "${DOCKERHUB_IMAGE}:${DAILY_TAG}"
+    docker buildx imagetools inspect "${DOCKERHUB_IMAGE}:daily"
 fi
 
 if [ "$SKIP_RELEASE" != true ]; then
@@ -376,10 +379,10 @@ echo -e "${GREEN}  Daily beta build complete!${NC}"
 echo -e "${GREEN}  Version: ${VERSION}${NC}"
 echo -e "${GREEN}================================================${NC}"
 if [ "$PUSH_GHCR" = true ]; then
-    echo "  GHCR:       ${GHCR_IMAGE}:${DAILY_TAG}"
+    echo "  GHCR:       ${GHCR_IMAGE}:daily"
 fi
 if [ "$PUSH_DOCKERHUB" = true ]; then
-    echo "  Docker Hub: ${DOCKERHUB_IMAGE}:${DAILY_TAG}"
+    echo "  Docker Hub: ${DOCKERHUB_IMAGE}:daily"
 fi
 if [ "$SKIP_RELEASE" != true ]; then
     echo "  Release:    https://github.com/${IMAGE_NAME}/releases/tag/v${DAILY_TAG}"
@@ -391,9 +394,9 @@ echo "  - linux/arm64 (Raspberry Pi 4/5, Apple Silicon)"
 echo ""
 echo -e "${GREEN}Users can now run:${NC}"
 if [ "$PUSH_GHCR" = true ]; then
-    echo "  docker pull ${GHCR_IMAGE}:${DAILY_TAG}"
+    echo "  docker pull ${GHCR_IMAGE}:daily"
 fi
 if [ "$PUSH_DOCKERHUB" = true ]; then
-    echo "  docker pull ${DOCKERHUB_IMAGE}:${DAILY_TAG}"
-    echo "  docker pull ${IMAGE_NAME}:${DAILY_TAG}  # shorthand"
+    echo "  docker pull ${DOCKERHUB_IMAGE}:daily"
+    echo "  docker pull ${IMAGE_NAME}:daily  # shorthand"
 fi

BIN
docs/images/proxy-mode-diagram.png


+ 15 - 3
docs/migration-vp-ftp-port.md

@@ -1,11 +1,11 @@
-# Migration: Virtual Printer FTP Port Change (9990 -> 990)
+# Migration: Virtual Printer Port Changes
 
-## What Changed
+## FTP Port Change (9990 → 990)
 
 The Virtual Printer FTP server now binds **directly to port 990** instead of port 9990.
 Previously, an iptables `REDIRECT` rule was required to forward port 990 to 9990.
 
-## Why
+### Why
 
 The iptables `REDIRECT` target rewrites the destination IP to the **primary address
 of the incoming network interface**. When running multiple virtual printers on
@@ -16,6 +16,18 @@ when VPs have different access codes.
 By binding directly to port 990, iptables is no longer involved and each VP's
 FTP server correctly receives only its own traffic.
 
+## New Proxy Mode Ports (6000, 322)
+
+Proxy mode now requires two additional ports:
+
+| Port | Protocol | Purpose |
+|------|----------|---------|
+| 6000 | TCP | File transfer tunnel (transparent proxy, end-to-end TLS) |
+| 322 | TCP | RTSP camera streaming (transparent proxy, end-to-end TLS) |
+
+These ports are proxied automatically — no iptables rules needed. If you have
+a firewall, ensure these ports are open between the slicer and Bambuddy.
+
 ## Migration Steps
 
 ### Linux (Native / systemd)

+ 3 - 3
frontend/package-lock.json

@@ -4777,9 +4777,9 @@
       }
     },
     "node_modules/flatted": {
-      "version": "3.4.1",
-      "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
-      "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
+      "version": "3.4.2",
+      "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
+      "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
       "dev": true
     },
     "node_modules/foreground-child": {

+ 2 - 0
frontend/src/App.tsx

@@ -19,6 +19,7 @@ import InventoryPage from './pages/InventoryPage';
 import { SystemInfoPage } from './pages/SystemInfoPage';
 import { LoginPage } from './pages/LoginPage';
 import { SetupPage } from './pages/SetupPage';
+import { NotificationsPage } from './pages/NotificationsPage';
 import { useWebSocket } from './hooks/useWebSocket';
 import { ThemeProvider } from './contexts/ThemeContext';
 import { ToastProvider } from './contexts/ToastContext';
@@ -146,6 +147,7 @@ function App() {
                   <Route path="users" element={<Navigate to="/settings?tab=users" replace />} />
                   <Route path="groups" element={<Navigate to="/settings?tab=users" replace />} />
                   <Route path="system" element={<SystemInfoPage />} />
+                  <Route path="notifications" element={<NotificationsPage />} />
                   <Route path="external/:id" element={<ExternalLinkPage />} />
                 </Route>
               </Routes>

+ 47 - 14
frontend/src/__tests__/components/BugReportBubble.test.tsx

@@ -23,6 +23,17 @@ function getSubmitButton() {
   );
 }
 
+function setupLoggingEndpoints() {
+  server.use(
+    http.post('*/bug-report/start-logging', () => {
+      return HttpResponse.json({ started: true, was_debug: false });
+    }),
+    http.post('*/bug-report/stop-logging', () => {
+      return HttpResponse.json({ logs: 'test debug logs' });
+    })
+  );
+}
+
 describe('BugReportBubble', () => {
   it('renders the floating bug button', () => {
     render(<BugReportBubble />);
@@ -79,16 +90,9 @@ describe('BugReportBubble', () => {
     expect(getSubmitButton()).not.toBeDisabled();
   });
 
-  it('shows collecting state with countdown after submit', async () => {
+  it('shows logging state with step indicators after start', async () => {
     const user = userEvent.setup();
-
-    // Delay the API response so we can see collecting state
-    server.use(
-      http.post('*/bug-report/submit', async () => {
-        await new Promise((resolve) => setTimeout(resolve, 60000));
-        return HttpResponse.json({ success: true, message: 'ok', issue_url: null, issue_number: null });
-      })
-    );
+    setupLoggingEndpoints();
 
     render(<BugReportBubble />);
     await user.click(screen.getByRole('button'));
@@ -98,16 +102,23 @@ describe('BugReportBubble', () => {
     const submitBtn = getSubmitButton();
     if (submitBtn) await user.click(submitBtn);
 
-    // Should show collecting state
+    // Should show step indicators and elapsed timer
+    await waitFor(() => {
+      const reproduceText = screen.queryByText(/reproduce|Reproduce|reproduzieren|reproduire|riproduci|再現|reproduza|重现/i);
+      expect(reproduceText).toBeInTheDocument();
+    });
+
+    // Should show elapsed timer (00:00 format)
     await waitFor(() => {
-      const collectingText = screen.queryByText(/collecting|Collecting|収集|Sammeln|Collecte|Raccolta|Coletando|收集/i);
-      expect(collectingText).toBeInTheDocument();
+      const timer = screen.queryByText(/00:0/);
+      expect(timer).toBeInTheDocument();
     });
   });
 
   it('shows success state after successful submission', async () => {
     const user = userEvent.setup();
 
+    setupLoggingEndpoints();
     server.use(
       http.post('*/bug-report/submit', () => {
         return HttpResponse.json({
@@ -127,17 +138,29 @@ describe('BugReportBubble', () => {
     const submitBtn = getSubmitButton();
     if (submitBtn) await user.click(submitBtn);
 
+    // Wait for logging state, then click stop
+    await waitFor(() => {
+      expect(screen.queryByText(/reproduce|Reproduce|reproduzieren|reproduire|riproduci|再現|reproduza|重现/i)).toBeInTheDocument();
+    });
+
+    // Find and click the Stop & Submit button
+    const stopBtn = screen.getAllByRole('button').find(
+      (b) => b.className.includes('bg-red-500') && !b.className.includes('rounded-full')
+    );
+    if (stopBtn) await user.click(stopBtn);
+
     await waitFor(
       () => {
         expect(screen.getByText(/#42/)).toBeInTheDocument();
       },
-      { timeout: 35000 }
+      { timeout: 10000 }
     );
   });
 
   it('shows error state after failed submission', async () => {
     const user = userEvent.setup();
 
+    setupLoggingEndpoints();
     server.use(
       http.post('*/bug-report/submit', () => {
         return HttpResponse.json({
@@ -157,11 +180,21 @@ describe('BugReportBubble', () => {
     const submitBtn = getSubmitButton();
     if (submitBtn) await user.click(submitBtn);
 
+    // Wait for logging state, then click stop
+    await waitFor(() => {
+      expect(screen.queryByText(/reproduce|Reproduce|reproduzieren|reproduire|riproduci|再現|reproduza|重现/i)).toBeInTheDocument();
+    });
+
+    const stopBtn = screen.getAllByRole('button').find(
+      (b) => b.className.includes('bg-red-500') && !b.className.includes('rounded-full')
+    );
+    if (stopBtn) await user.click(stopBtn);
+
     await waitFor(
       () => {
         expect(screen.getByText(/Relay not available/)).toBeInTheDocument();
       },
-      { timeout: 35000 }
+      { timeout: 10000 }
     );
   });
 

+ 20 - 1
frontend/src/__tests__/components/LinkSpoolModal.test.tsx

@@ -117,7 +117,7 @@ describe('LinkSpoolModal', () => {
   });
 
   describe('linking', () => {
-    it('calls linkSpool on spool click', async () => {
+    it('uses trayUuid when linking if present (Bambu spool path)', async () => {
       render(<LinkSpoolModal {...defaultProps} />);
 
       await waitFor(() => {
@@ -126,6 +126,25 @@ describe('LinkSpoolModal', () => {
 
       fireEvent.click(screen.getByText(/Generic PLA Red/).closest('button')!);
 
+      await waitFor(() => {
+        expect(api.linkSpool).toHaveBeenCalledWith(1, {
+          spoolTag: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4',
+          printerId: 1,
+          amsId: 0,
+          trayId: 0,
+        });
+      });
+    });
+
+    it('falls back to tagUid when trayUuid is missing (generic spool path)', async () => {
+      render(<LinkSpoolModal {...defaultProps} trayUuid="" />);
+
+      await waitFor(() => {
+        expect(screen.getByText(/Generic PLA Red/)).toBeInTheDocument();
+      });
+
+      fireEvent.click(screen.getByText(/Generic PLA Red/).closest('button')!);
+
       await waitFor(() => {
         expect(api.linkSpool).toHaveBeenCalledWith(1, {
           spoolTag: 'ABCD1234',

+ 61 - 15
frontend/src/__tests__/components/PrintModal.test.tsx

@@ -713,7 +713,7 @@ describe('PrintModal', () => {
     });
   });
 
-  describe('queue all plates', () => {
+  describe('multi-plate selection', () => {
     const multiPlateResponse = {
       is_multi_plate: true,
       plates: [
@@ -731,7 +731,7 @@ describe('PrintModal', () => {
       );
     });
 
-    it('shows "Queue All" button only in add-to-queue mode', async () => {
+    it('shows "Select All" button only in add-to-queue mode', async () => {
       render(
         <PrintModal
           mode="add-to-queue"
@@ -742,11 +742,11 @@ describe('PrintModal', () => {
       );
 
       await waitFor(() => {
-        expect(screen.getByText('Queue All 3 Plates')).toBeInTheDocument();
+        expect(screen.getByText('Select All 3 Plates')).toBeInTheDocument();
       });
     });
 
-    it('does not show "Queue All" button in reprint mode', async () => {
+    it('does not show "Select All" button in reprint mode', async () => {
       render(
         <PrintModal
           mode="reprint"
@@ -760,10 +760,10 @@ describe('PrintModal', () => {
       await waitFor(() => {
         expect(screen.getByText('Plate 1')).toBeInTheDocument();
       });
-      expect(screen.queryByText('Queue All 3 Plates')).not.toBeInTheDocument();
+      expect(screen.queryByText('Select All 3 Plates')).not.toBeInTheDocument();
     });
 
-    it('highlights all plates when "Queue All" is clicked', async () => {
+    it('selects all plates when "Select All" is clicked', async () => {
       const user = userEvent.setup();
       render(
         <PrintModal
@@ -775,19 +775,20 @@ describe('PrintModal', () => {
       );
 
       await waitFor(() => {
-        expect(screen.getByText('Queue All 3 Plates')).toBeInTheDocument();
+        expect(screen.getByText('Select All 3 Plates')).toBeInTheDocument();
       });
 
-      await user.click(screen.getByText('Queue All 3 Plates'));
+      await user.click(screen.getByText('Select All 3 Plates'));
 
-      // All plates should show check marks
+      // All plates should be highlighted (green border)
       await waitFor(() => {
-        const checks = document.querySelectorAll('.text-bambu-green.flex-shrink-0');
-        expect(checks.length).toBe(3);
+        const plateButtons = document.querySelectorAll('button[type="button"].border-bambu-green');
+        // 3 plate buttons + the "Deselect All" toggle button = 4 green-bordered buttons
+        expect(plateButtons.length).toBeGreaterThanOrEqual(3);
       });
     });
 
-    it('creates one queue item per plate when submitting with queue-all', async () => {
+    it('allows selecting a subset of plates to queue', async () => {
       const queueRequests: unknown[] = [];
       server.use(
         http.post('/api/v1/queue/', async ({ request }) => {
@@ -810,15 +811,60 @@ describe('PrintModal', () => {
 
       // Wait for plates and select a printer
       await waitFor(() => {
-        expect(screen.getByText('Queue All 3 Plates')).toBeInTheDocument();
+        expect(screen.getByText('Select All 3 Plates')).toBeInTheDocument();
         expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
       });
 
       // Select printer
       await user.click(screen.getByText('X1 Carbon'));
 
-      // Click queue all
-      await user.click(screen.getByText('Queue All 3 Plates'));
+      // Plate 1 is auto-selected. Click Plate 3 to add it (multi-select in add-to-queue mode)
+      await user.click(screen.getByText('Plate 3'));
+
+      // Submit — should queue plates 1 and 3
+      const submitButton = document.querySelector('button[type="submit"]') as HTMLElement;
+      await user.click(submitButton);
+
+      await waitFor(() => {
+        expect(queueRequests.length).toBe(2);
+      });
+
+      expect((queueRequests[0] as { plate_id: number }).plate_id).toBe(1);
+      expect((queueRequests[1] as { plate_id: number }).plate_id).toBe(3);
+    });
+
+    it('creates one queue item per plate when submitting with select-all', async () => {
+      const queueRequests: unknown[] = [];
+      server.use(
+        http.post('/api/v1/queue/', async ({ request }) => {
+          const body = await request.json();
+          queueRequests.push(body);
+          return HttpResponse.json({ id: queueRequests.length, status: 'pending' });
+        }),
+      );
+
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="add-to-queue"
+          archiveId={1}
+          archiveName="MultiPlate.3mf"
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      // Wait for plates and select a printer
+      await waitFor(() => {
+        expect(screen.getByText('Select All 3 Plates')).toBeInTheDocument();
+        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+      });
+
+      // Select printer
+      await user.click(screen.getByText('X1 Carbon'));
+
+      // Click select all
+      await user.click(screen.getByText('Select All 3 Plates'));
 
       // Find the submit button (type="submit") — distinct from the toggle button (type="button")
       const submitButton = document.querySelector('button[type="submit"]') as HTMLElement;

+ 131 - 0
frontend/src/__tests__/components/spoolbuddy/AmsUnitCard.test.tsx

@@ -0,0 +1,131 @@
+/**
+ * Tests for AmsUnitCard component:
+ * - Renders slot circles for a 4-slot AMS
+ * - Shows slot labels (1, 2, 3, 4)
+ * - Shows fill level bars
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import React from 'react';
+import { AmsUnitCard } from '../../../components/spoolbuddy/AmsUnitCard';
+import type { AMSUnit, AMSTray } from '../../../api/client';
+
+vi.mock('../../../utils/amsHelpers', () => ({
+  getFillBarColor: (fill: number) => {
+    if (fill > 50) return '#00ae42';
+    if (fill >= 15) return '#f59e0b';
+    return '#ef4444';
+  },
+}));
+
+function makeTray(overrides: Partial<AMSTray> = {}): AMSTray {
+  return {
+    id: 0,
+    tray_color: 'FF0000FF',
+    tray_type: 'PLA',
+    tray_sub_brands: null,
+    tray_id_name: null,
+    tray_info_idx: null,
+    remain: 80,
+    k: null,
+    cali_idx: null,
+    tag_uid: null,
+    tray_uuid: null,
+    nozzle_temp_min: null,
+    nozzle_temp_max: null,
+    drying_temp: null,
+    drying_time: null,
+    ...overrides,
+  };
+}
+
+function makeUnit(overrides: Partial<AMSUnit> = {}): AMSUnit {
+  return {
+    id: 0,
+    humidity: 30,
+    temp: 25,
+    is_ams_ht: false,
+    tray: [
+      makeTray({ id: 0, tray_color: 'FF0000FF', tray_type: 'PLA', remain: 80 }),
+      makeTray({ id: 1, tray_color: '00FF00FF', tray_type: 'PETG', remain: 50 }),
+      makeTray({ id: 2, tray_color: '0000FFFF', tray_type: 'ABS', remain: 10 }),
+      makeTray({ id: 3, tray_color: null, tray_type: '', remain: -1 }),
+    ],
+    serial_number: 'AMS001',
+    sw_ver: '1.0.0',
+    dry_time: 0,
+    dry_status: 0,
+    dry_sub_status: 0,
+    ...overrides,
+  };
+}
+
+describe('AmsUnitCard', () => {
+  it('renders 4 slot positions for a regular AMS', () => {
+    const { container } = render(
+      <AmsUnitCard unit={makeUnit()} activeSlot={null} />
+    );
+    // 4 slot numbers should be visible (1, 2, 3, 4)
+    expect(screen.getByText('1')).toBeDefined();
+    expect(screen.getByText('2')).toBeDefined();
+    expect(screen.getByText('3')).toBeDefined();
+    expect(screen.getByText('4')).toBeDefined();
+    // grid-cols-4 class should be present
+    const grid = container.querySelector('.grid-cols-4');
+    expect(grid).not.toBeNull();
+  });
+
+  it('renders AMS name in header', () => {
+    render(<AmsUnitCard unit={makeUnit({ id: 0 })} activeSlot={null} />);
+    expect(screen.getByText('AMS A')).toBeDefined();
+  });
+
+  it('shows material types for populated slots', () => {
+    render(<AmsUnitCard unit={makeUnit()} activeSlot={null} />);
+    expect(screen.getByText('PLA')).toBeDefined();
+    expect(screen.getByText('PETG')).toBeDefined();
+    expect(screen.getByText('ABS')).toBeDefined();
+  });
+
+  it('shows "Empty" for empty slot', () => {
+    render(<AmsUnitCard unit={makeUnit()} activeSlot={null} />);
+    expect(screen.getByText('Empty')).toBeDefined();
+  });
+
+  it('renders fill level bars for slots with filament', () => {
+    const { container } = render(
+      <AmsUnitCard unit={makeUnit()} activeSlot={null} />
+    );
+    // Look for fill bar elements (they have style width set to fill%)
+    const fillBars = container.querySelectorAll('.h-full.rounded-full.transition-all');
+    // 3 populated slots should have fill bars (slot 4 is empty)
+    expect(fillBars.length).toBe(3);
+  });
+
+  it('renders only 1 slot for AMS-HT', () => {
+    const htUnit = makeUnit({
+      is_ams_ht: true,
+      tray: [makeTray({ id: 0, tray_type: 'PLA', remain: 90 })],
+    });
+    const { container } = render(
+      <AmsUnitCard unit={htUnit} activeSlot={null} />
+    );
+    const grid = container.querySelector('.grid-cols-1');
+    expect(grid).not.toBeNull();
+    expect(screen.getByText('1')).toBeDefined();
+  });
+
+  it('shows humidity and temperature indicators', () => {
+    render(<AmsUnitCard unit={makeUnit({ humidity: 45, temp: 30 })} activeSlot={null} />);
+    expect(screen.getByText('45%')).toBeDefined();
+  });
+
+  it('highlights active slot with ring', () => {
+    const { container } = render(
+      <AmsUnitCard unit={makeUnit()} activeSlot={1} />
+    );
+    const activeSlot = container.querySelector('.ring-2.ring-bambu-green');
+    expect(activeSlot).not.toBeNull();
+  });
+});

+ 60 - 0
frontend/src/__tests__/components/spoolbuddy/SpoolBuddyBottomNav.test.tsx

@@ -0,0 +1,60 @@
+/**
+ * Tests for SpoolBuddyBottomNav component:
+ * - Renders 4 nav items (Dashboard, AMS, Write, Settings)
+ * - NavLinks have correct paths
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import React from 'react';
+import { MemoryRouter } from 'react-router-dom';
+import { SpoolBuddyBottomNav } from '../../../components/spoolbuddy/SpoolBuddyBottomNav';
+
+vi.mock('react-i18next', () => ({
+  useTranslation: () => ({
+    t: (_key: string, fallback: string) => fallback,
+    i18n: { language: 'en', changeLanguage: vi.fn() },
+  }),
+}));
+
+function renderNav() {
+  return render(
+    <MemoryRouter initialEntries={['/spoolbuddy']}>
+      <SpoolBuddyBottomNav />
+    </MemoryRouter>
+  );
+}
+
+describe('SpoolBuddyBottomNav', () => {
+  it('renders 4 nav items', () => {
+    renderNav();
+    expect(screen.getByText('Dashboard')).toBeDefined();
+    expect(screen.getByText('AMS')).toBeDefined();
+    expect(screen.getByText('Write')).toBeDefined();
+    expect(screen.getByText('Settings')).toBeDefined();
+  });
+
+  it('has correct link for Dashboard', () => {
+    renderNav();
+    const link = screen.getByText('Dashboard').closest('a');
+    expect(link!.getAttribute('href')).toBe('/spoolbuddy');
+  });
+
+  it('has correct link for AMS', () => {
+    renderNav();
+    const link = screen.getByText('AMS').closest('a');
+    expect(link!.getAttribute('href')).toBe('/spoolbuddy/ams');
+  });
+
+  it('has correct link for Write', () => {
+    renderNav();
+    const link = screen.getByText('Write').closest('a');
+    expect(link!.getAttribute('href')).toBe('/spoolbuddy/write-tag');
+  });
+
+  it('has correct link for Settings', () => {
+    renderNav();
+    const link = screen.getByText('Settings').closest('a');
+    expect(link!.getAttribute('href')).toBe('/spoolbuddy/settings');
+  });
+});

+ 89 - 0
frontend/src/__tests__/components/spoolbuddy/SpoolBuddyLayout.test.tsx

@@ -0,0 +1,89 @@
+/**
+ * Tests for SpoolBuddyLayout component:
+ * - Renders without crashing
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render } from '@testing-library/react';
+import React from 'react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { SpoolBuddyLayout } from '../../../components/spoolbuddy/SpoolBuddyLayout';
+
+vi.mock('react-i18next', () => ({
+  useTranslation: () => ({
+    t: (_key: string, fallback: string) => fallback,
+    i18n: { language: 'en', changeLanguage: vi.fn() },
+  }),
+}));
+
+vi.mock('../../../api/client', () => ({
+  api: {
+    getPrinters: vi.fn().mockResolvedValue([]),
+    getPrinterStatus: vi.fn().mockResolvedValue({ connected: false }),
+    getSettings: vi.fn().mockResolvedValue({ time_format: 'system', language: 'en' }),
+  },
+  spoolbuddyApi: {
+    getDevices: vi.fn().mockResolvedValue([]),
+  },
+}));
+
+vi.mock('../../../utils/date', () => ({
+  formatTimeOnly: () => '12:00',
+}));
+
+vi.mock('lucide-react', () => ({
+  WifiOff: (props: Record<string, unknown>) => <span data-testid="wifi-off" {...props} />,
+}));
+
+vi.mock('../../../components/VirtualKeyboard', () => ({
+  VirtualKeyboard: () => <div data-testid="virtual-keyboard" />,
+}));
+
+function renderLayout() {
+  const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
+  return render(
+    <QueryClientProvider client={qc}>
+      <MemoryRouter initialEntries={['/spoolbuddy']}>
+        <Routes>
+          <Route path="spoolbuddy" element={<SpoolBuddyLayout />}>
+            <Route index element={<div data-testid="child-page">Child</div>} />
+          </Route>
+        </Routes>
+      </MemoryRouter>
+    </QueryClientProvider>
+  );
+}
+
+describe('SpoolBuddyLayout', () => {
+  it('renders without crashing', () => {
+    const { container } = renderLayout();
+    expect(container.firstChild).not.toBeNull();
+  });
+
+  it('renders the top bar with logo', () => {
+    renderLayout();
+    const img = document.querySelector('img[alt="SpoolBuddy"]');
+    expect(img).not.toBeNull();
+  });
+
+  it('renders the bottom nav', () => {
+    renderLayout();
+    const nav = document.querySelector('nav');
+    expect(nav).not.toBeNull();
+  });
+
+  it('renders the status bar', () => {
+    renderLayout();
+    // Status bar shows "System Ready" by default (device offline triggers warning later via useEffect)
+    // Just check the status bar container exists
+    const statusBar = document.querySelector('.shrink-0.h-9');
+    expect(statusBar).not.toBeNull();
+  });
+
+  it('renders child outlet content', () => {
+    renderLayout();
+    const child = document.querySelector('[data-testid="child-page"]');
+    expect(child).not.toBeNull();
+  });
+});

+ 63 - 0
frontend/src/__tests__/components/spoolbuddy/SpoolBuddyStatusBar.test.tsx

@@ -0,0 +1,63 @@
+/**
+ * Tests for SpoolBuddyStatusBar component:
+ * - Shows "System Ready" with green when no alert
+ * - Shows warning message with amber styling
+ * - Shows error message with red styling
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import React from 'react';
+import { SpoolBuddyStatusBar } from '../../../components/spoolbuddy/SpoolBuddyStatusBar';
+
+vi.mock('react-i18next', () => ({
+  useTranslation: () => ({
+    t: (_key: string, fallback: string) => fallback,
+    i18n: { language: 'en', changeLanguage: vi.fn() },
+  }),
+}));
+
+describe('SpoolBuddyStatusBar', () => {
+  it('shows "System Ready" when no alert', () => {
+    render(<SpoolBuddyStatusBar />);
+    expect(screen.getByText('System Ready')).toBeDefined();
+  });
+
+  it('uses green status LED when no alert', () => {
+    const { container } = render(<SpoolBuddyStatusBar />);
+    const led = container.querySelector('.rounded-full.animate-pulse');
+    expect(led!.className).toContain('bg-bambu-green');
+  });
+
+  it('shows warning message with amber styling', () => {
+    const { container } = render(
+      <SpoolBuddyStatusBar alert={{ type: 'warning', message: 'Low filament' }} />
+    );
+    expect(screen.getByText('Low filament')).toBeDefined();
+    const led = container.querySelector('.rounded-full.animate-pulse');
+    expect(led!.className).toContain('bg-amber-500');
+    // Border should also be amber
+    const bar = container.firstElementChild as HTMLElement;
+    expect(bar.className).toContain('border-amber-500');
+  });
+
+  it('shows error message with red styling', () => {
+    const { container } = render(
+      <SpoolBuddyStatusBar alert={{ type: 'error', message: 'Connection lost' }} />
+    );
+    expect(screen.getByText('Connection lost')).toBeDefined();
+    const led = container.querySelector('.rounded-full.animate-pulse');
+    expect(led!.className).toContain('bg-red-500');
+    const bar = container.firstElementChild as HTMLElement;
+    expect(bar.className).toContain('border-red-500');
+  });
+
+  it('shows info alert with green styling', () => {
+    const { container } = render(
+      <SpoolBuddyStatusBar alert={{ type: 'info', message: 'Update available' }} />
+    );
+    expect(screen.getByText('Update available')).toBeDefined();
+    const led = container.querySelector('.rounded-full.animate-pulse');
+    expect(led!.className).toContain('bg-bambu-green');
+  });
+});

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

@@ -0,0 +1,80 @@
+/**
+ * Tests for SpoolBuddyTopBar component:
+ * - Renders the logo image
+ * - Renders the printer selector
+ * - Shows backend status indicator
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import React from 'react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { SpoolBuddyTopBar } from '../../../components/spoolbuddy/SpoolBuddyTopBar';
+
+vi.mock('react-i18next', () => ({
+  useTranslation: () => ({
+    t: (_key: string, fallback: string) => fallback,
+    i18n: { language: 'en', changeLanguage: vi.fn() },
+  }),
+}));
+
+vi.mock('../../../api/client', () => ({
+  api: {
+    getPrinters: vi.fn().mockResolvedValue([]),
+    getPrinterStatus: vi.fn().mockResolvedValue({ connected: false }),
+    getSettings: vi.fn().mockResolvedValue({ time_format: 'system' }),
+  },
+}));
+
+vi.mock('../../../utils/date', () => ({
+  formatTimeOnly: () => '12:00',
+}));
+
+vi.mock('lucide-react', () => ({
+  WifiOff: (props: Record<string, unknown>) => <span data-testid="wifi-off" {...props} />,
+}));
+
+function renderTopBar(deviceOnline = false) {
+  const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
+  return render(
+    <QueryClientProvider client={qc}>
+      <SpoolBuddyTopBar
+        selectedPrinterId={null}
+        onPrinterChange={vi.fn()}
+        deviceOnline={deviceOnline}
+      />
+    </QueryClientProvider>
+  );
+}
+
+describe('SpoolBuddyTopBar', () => {
+  it('renders the logo image', () => {
+    renderTopBar();
+    const img = screen.getByAltText('SpoolBuddy');
+    expect(img).toBeDefined();
+    expect(img.getAttribute('src')).toBe('/img/spoolbuddy_logo_dark_small.png');
+  });
+
+  it('renders the printer selector', () => {
+    renderTopBar();
+    // Select element with "No printers online" fallback
+    const select = screen.getByRole('combobox');
+    expect(select).toBeDefined();
+  });
+
+  it('shows offline status when device is offline', () => {
+    renderTopBar(false);
+    expect(screen.getByText('Offline')).toBeDefined();
+    expect(screen.getByTestId('wifi-off')).toBeDefined();
+  });
+
+  it('shows backend status when device is online', () => {
+    renderTopBar(true);
+    expect(screen.getByText('Backend')).toBeDefined();
+  });
+
+  it('shows clock time', () => {
+    renderTopBar();
+    expect(screen.getByText('12:00')).toBeDefined();
+  });
+});

+ 56 - 0
frontend/src/__tests__/components/spoolbuddy/SpoolIcon.test.tsx

@@ -0,0 +1,56 @@
+/**
+ * Tests for SpoolIcon component:
+ * - Renders SVG when not empty (with correct color)
+ * - Renders dashed circle when isEmpty=true
+ * - Respects size prop
+ */
+
+import { describe, it, expect } from 'vitest';
+import { render } from '@testing-library/react';
+import React from 'react';
+import { SpoolIcon } from '../../../components/spoolbuddy/SpoolIcon';
+
+describe('SpoolIcon', () => {
+  it('renders SVG when not empty', () => {
+    const { container } = render(<SpoolIcon color="#FF0000" isEmpty={false} />);
+    const svg = container.querySelector('svg');
+    expect(svg).not.toBeNull();
+  });
+
+  it('renders SVG with correct color in fill', () => {
+    const { container } = render(<SpoolIcon color="#00AE42" isEmpty={false} />);
+    const circles = container.querySelectorAll('circle');
+    // First circle has the color as fill
+    expect(circles[0].getAttribute('fill')).toBe('#00AE42');
+  });
+
+  it('renders dashed circle when isEmpty=true', () => {
+    const { container } = render(<SpoolIcon color="#FF0000" isEmpty={true} />);
+    // No SVG, should be a div with border-dashed
+    const svg = container.querySelector('svg');
+    expect(svg).toBeNull();
+    const div = container.firstElementChild as HTMLElement;
+    expect(div.className).toContain('border-dashed');
+  });
+
+  it('uses default size of 32', () => {
+    const { container } = render(<SpoolIcon color="#FF0000" isEmpty={false} />);
+    const svg = container.querySelector('svg');
+    expect(svg!.getAttribute('width')).toBe('32');
+    expect(svg!.getAttribute('height')).toBe('32');
+  });
+
+  it('respects custom size prop', () => {
+    const { container } = render(<SpoolIcon color="#FF0000" isEmpty={false} size={64} />);
+    const svg = container.querySelector('svg');
+    expect(svg!.getAttribute('width')).toBe('64');
+    expect(svg!.getAttribute('height')).toBe('64');
+  });
+
+  it('respects custom size prop for empty spool', () => {
+    const { container } = render(<SpoolIcon color="#FF0000" isEmpty={true} size={48} />);
+    const div = container.firstElementChild as HTMLElement;
+    expect(div.style.width).toBe('48px');
+    expect(div.style.height).toBe('48px');
+  });
+});

+ 336 - 0
frontend/src/__tests__/hooks/useSpoolBuddyState.test.ts

@@ -0,0 +1,336 @@
+/**
+ * Tests for useSpoolBuddyState hook:
+ * - Reducer handles all action types correctly
+ * - Computed properties (remainingWeight, netWeight) work
+ * - Window events dispatch state updates
+ */
+
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { useSpoolBuddyState } from '../../hooks/useSpoolBuddyState';
+
+function dispatchCustomEvent(name: string, detail: Record<string, unknown>) {
+  window.dispatchEvent(new CustomEvent(name, { detail }));
+}
+
+describe('useSpoolBuddyState', () => {
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it('starts with initial state', () => {
+    const { result } = renderHook(() => useSpoolBuddyState());
+    expect(result.current.weight).toBeNull();
+    expect(result.current.weightStable).toBe(false);
+    expect(result.current.rawAdc).toBeNull();
+    expect(result.current.matchedSpool).toBeNull();
+    expect(result.current.unknownTagUid).toBeNull();
+    expect(result.current.deviceOnline).toBe(false);
+    expect(result.current.deviceId).toBeNull();
+    expect(result.current.remainingWeight).toBeNull();
+    expect(result.current.netWeight).toBeNull();
+  });
+
+  it('WEIGHT_UPDATE sets weight, stable, rawAdc, deviceOnline=true', () => {
+    const { result } = renderHook(() => useSpoolBuddyState());
+
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-weight', {
+        weight_grams: 250.5,
+        stable: true,
+        raw_adc: 12345,
+        device_id: 'dev-1',
+      });
+    });
+
+    expect(result.current.weight).toBe(250.5);
+    expect(result.current.weightStable).toBe(true);
+    expect(result.current.rawAdc).toBe(12345);
+    expect(result.current.deviceOnline).toBe(true);
+    expect(result.current.deviceId).toBe('dev-1');
+  });
+
+  it('WEIGHT_UPDATE handles nested data format', () => {
+    const { result } = renderHook(() => useSpoolBuddyState());
+
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-weight', {
+        data: {
+          weight_grams: 100,
+          stable: false,
+          raw_adc: 9999,
+          device_id: 'dev-2',
+        },
+      });
+    });
+
+    expect(result.current.weight).toBe(100);
+    expect(result.current.weightStable).toBe(false);
+    expect(result.current.rawAdc).toBe(9999);
+    expect(result.current.deviceId).toBe('dev-2');
+  });
+
+  it('TAG_MATCHED sets matchedSpool and clears unknownTagUid', () => {
+    const { result } = renderHook(() => useSpoolBuddyState());
+
+    // First set an unknown tag
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-unknown-tag', {
+        tag_uid: 'AA:BB:CC',
+        device_id: 'dev-1',
+      });
+    });
+    expect(result.current.unknownTagUid).toBe('AA:BB:CC');
+
+    // Now match a spool
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-tag-matched', {
+        tag_uid: 'AA:BB:CC',
+        device_id: 'dev-1',
+        spool: {
+          id: 42,
+          material: 'PLA',
+          subtype: 'Silk',
+          color_name: 'Red',
+          rgba: 'FF0000FF',
+          brand: 'Bambu',
+          label_weight: 1000,
+          core_weight: 250,
+          weight_used: 100,
+        },
+      });
+    });
+
+    expect(result.current.matchedSpool).not.toBeNull();
+    expect(result.current.matchedSpool!.id).toBe(42);
+    expect(result.current.matchedSpool!.material).toBe('PLA');
+    expect(result.current.matchedSpool!.subtype).toBe('Silk');
+    expect(result.current.matchedSpool!.color_name).toBe('Red');
+    expect(result.current.matchedSpool!.brand).toBe('Bambu');
+    expect(result.current.matchedSpool!.label_weight).toBe(1000);
+    expect(result.current.matchedSpool!.core_weight).toBe(250);
+    expect(result.current.matchedSpool!.weight_used).toBe(100);
+    expect(result.current.unknownTagUid).toBeNull();
+  });
+
+  it('UNKNOWN_TAG sets unknownTagUid and clears matchedSpool', () => {
+    const { result } = renderHook(() => useSpoolBuddyState());
+
+    // First match a spool
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-tag-matched', {
+        tag_uid: 'AA:BB:CC',
+        device_id: 'dev-1',
+        spool: {
+          id: 1,
+          material: 'PLA',
+          label_weight: 1000,
+          core_weight: 250,
+          weight_used: 0,
+        },
+      });
+    });
+    expect(result.current.matchedSpool).not.toBeNull();
+
+    // Now detect unknown tag
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-unknown-tag', {
+        tag_uid: 'DD:EE:FF',
+        device_id: 'dev-1',
+      });
+    });
+
+    expect(result.current.unknownTagUid).toBe('DD:EE:FF');
+    expect(result.current.matchedSpool).toBeNull();
+  });
+
+  it('TAG_REMOVED clears both matchedSpool and unknownTagUid', () => {
+    const { result } = renderHook(() => useSpoolBuddyState());
+
+    // Set a matched spool
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-tag-matched', {
+        tag_uid: 'AA:BB:CC',
+        device_id: 'dev-1',
+        spool: {
+          id: 1,
+          material: 'PLA',
+          label_weight: 1000,
+          core_weight: 250,
+          weight_used: 0,
+        },
+      });
+    });
+    expect(result.current.matchedSpool).not.toBeNull();
+
+    // Remove tag
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-tag-removed', { device_id: 'dev-1' });
+    });
+
+    expect(result.current.matchedSpool).toBeNull();
+    expect(result.current.unknownTagUid).toBeNull();
+  });
+
+  it('DEVICE_ONLINE sets deviceOnline=true', () => {
+    const { result } = renderHook(() => useSpoolBuddyState());
+    expect(result.current.deviceOnline).toBe(false);
+
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-online', { device_id: 'dev-1' });
+    });
+
+    expect(result.current.deviceOnline).toBe(true);
+    expect(result.current.deviceId).toBe('dev-1');
+  });
+
+  it('DEVICE_OFFLINE sets deviceOnline=false and clears weight/rawAdc', () => {
+    const { result } = renderHook(() => useSpoolBuddyState());
+
+    // First get some weight data
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-weight', {
+        weight_grams: 500,
+        stable: true,
+        raw_adc: 54321,
+        device_id: 'dev-1',
+      });
+    });
+    expect(result.current.weight).toBe(500);
+    expect(result.current.rawAdc).toBe(54321);
+    expect(result.current.deviceOnline).toBe(true);
+
+    // Go offline
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-offline', { device_id: 'dev-1' });
+    });
+
+    expect(result.current.deviceOnline).toBe(false);
+    expect(result.current.weight).toBeNull();
+    expect(result.current.weightStable).toBe(false);
+    expect(result.current.rawAdc).toBeNull();
+  });
+
+  it('computes remainingWeight from matchedSpool', () => {
+    const { result } = renderHook(() => useSpoolBuddyState());
+
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-tag-matched', {
+        tag_uid: 'AA:BB:CC',
+        device_id: 'dev-1',
+        spool: {
+          id: 1,
+          material: 'PLA',
+          label_weight: 1000,
+          core_weight: 250,
+          weight_used: 300,
+        },
+      });
+    });
+
+    // remainingWeight = label_weight - weight_used = 1000 - 300 = 700
+    expect(result.current.remainingWeight).toBe(700);
+  });
+
+  it('remainingWeight is clamped to 0 when weight_used exceeds label_weight', () => {
+    const { result } = renderHook(() => useSpoolBuddyState());
+
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-tag-matched', {
+        tag_uid: 'AA:BB:CC',
+        device_id: 'dev-1',
+        spool: {
+          id: 1,
+          material: 'PLA',
+          label_weight: 1000,
+          core_weight: 250,
+          weight_used: 1200,
+        },
+      });
+    });
+
+    expect(result.current.remainingWeight).toBe(0);
+  });
+
+  it('computes netWeight from weight and matchedSpool core_weight', () => {
+    const { result } = renderHook(() => useSpoolBuddyState());
+
+    // Set weight first
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-weight', {
+        weight_grams: 800,
+        stable: true,
+        raw_adc: 11111,
+        device_id: 'dev-1',
+      });
+    });
+
+    // Match a spool
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-tag-matched', {
+        tag_uid: 'AA:BB:CC',
+        device_id: 'dev-1',
+        spool: {
+          id: 1,
+          material: 'PLA',
+          label_weight: 1000,
+          core_weight: 250,
+          weight_used: 0,
+        },
+      });
+    });
+
+    // netWeight = weight - core_weight = 800 - 250 = 550
+    expect(result.current.netWeight).toBe(550);
+  });
+
+  it('netWeight is null when weight is null', () => {
+    const { result } = renderHook(() => useSpoolBuddyState());
+
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-tag-matched', {
+        tag_uid: 'AA:BB:CC',
+        device_id: 'dev-1',
+        spool: {
+          id: 1,
+          material: 'PLA',
+          label_weight: 1000,
+          core_weight: 250,
+          weight_used: 0,
+        },
+      });
+    });
+
+    expect(result.current.netWeight).toBeNull();
+  });
+
+  it('netWeight is null when no matchedSpool', () => {
+    const { result } = renderHook(() => useSpoolBuddyState());
+
+    act(() => {
+      dispatchCustomEvent('spoolbuddy-weight', {
+        weight_grams: 800,
+        stable: true,
+        raw_adc: 11111,
+        device_id: 'dev-1',
+      });
+    });
+
+    expect(result.current.netWeight).toBeNull();
+  });
+
+  it('cleans up event listeners on unmount', () => {
+    const removeSpy = vi.spyOn(window, 'removeEventListener');
+    const { unmount } = renderHook(() => useSpoolBuddyState());
+
+    unmount();
+
+    const removedEvents = removeSpy.mock.calls.map((c) => c[0]);
+    expect(removedEvents).toContain('spoolbuddy-weight');
+    expect(removedEvents).toContain('spoolbuddy-tag-matched');
+    expect(removedEvents).toContain('spoolbuddy-unknown-tag');
+    expect(removedEvents).toContain('spoolbuddy-tag-removed');
+    expect(removedEvents).toContain('spoolbuddy-online');
+    expect(removedEvents).toContain('spoolbuddy-offline');
+  });
+});

+ 136 - 0
frontend/src/__tests__/pages/NotificationsPage.test.tsx

@@ -0,0 +1,136 @@
+/**
+ * Tests for the NotificationsPage component.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { NotificationsPage } from '../../pages/NotificationsPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const mockPreferences = {
+  notify_print_start: true,
+  notify_print_complete: true,
+  notify_print_failed: true,
+  notify_print_stopped: true,
+};
+
+const mockAdvancedAuthEnabled = {
+  advanced_auth_enabled: true,
+  smtp_configured: true,
+};
+
+const mockSettingsWithNotifications = {
+  auto_archive: true,
+  user_notifications_enabled: true,
+};
+
+describe('NotificationsPage', () => {
+  beforeEach(() => {
+    server.use(
+      http.get('/api/v1/auth/advanced-auth/status', () => {
+        return HttpResponse.json(mockAdvancedAuthEnabled);
+      }),
+      http.get('/api/v1/user-notifications/preferences', () => {
+        return HttpResponse.json(mockPreferences);
+      }),
+      http.put('/api/v1/user-notifications/preferences', async ({ request }) => {
+        const body = await request.json();
+        return HttpResponse.json(body);
+      }),
+      http.get('/api/v1/settings/', () => {
+        return HttpResponse.json(mockSettingsWithNotifications);
+      }),
+      http.get('*/api/v1/auth/status', () => {
+        return HttpResponse.json({ auth_enabled: false, requires_setup: false });
+      }),
+      http.get('/api/v1/auth/me', () => {
+        return HttpResponse.json({
+          id: 1,
+          username: 'testuser',
+          email: 'test@example.com',
+          role: 'admin',
+          is_active: true,
+          is_admin: true,
+          groups: [{ id: 1, name: 'Administrators' }],
+          permissions: [],
+          created_at: '2024-01-01T00:00:00Z',
+        });
+      })
+    );
+  });
+
+  describe('rendering', () => {
+    it('renders the page heading', async () => {
+      render(<NotificationsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Notifications')).toBeInTheDocument();
+      });
+    });
+
+    it('renders all four notification toggle options', async () => {
+      render(<NotificationsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Print Job Starts')).toBeInTheDocument();
+        expect(screen.getByText('Print Job Finishes')).toBeInTheDocument();
+        expect(screen.getByText('Print Errors')).toBeInTheDocument();
+        expect(screen.getByText('Print Job Stops')).toBeInTheDocument();
+      });
+    });
+
+    it('renders four toggle switches', async () => {
+      render(<NotificationsPage />);
+
+      await waitFor(() => {
+        const switches = screen.getAllByRole('switch');
+        expect(switches).toHaveLength(4);
+      });
+    });
+
+    it('renders save button', async () => {
+      render(<NotificationsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
+      });
+    });
+
+    it('shows loading spinner initially', () => {
+      render(<NotificationsPage />);
+      expect(document.querySelector('.animate-spin')).toBeInTheDocument();
+    });
+  });
+
+  describe('toggle interaction', () => {
+    it('toggles switch state when clicked', async () => {
+      const user = userEvent.setup();
+      render(<NotificationsPage />);
+
+      await waitFor(() => {
+        expect(screen.getAllByRole('switch')).toHaveLength(4);
+      });
+
+      const switches = screen.getAllByRole('switch');
+      // All should start checked (matching mock preferences)
+      expect(switches[0]).toHaveAttribute('aria-checked', 'true');
+
+      await user.click(switches[0]); // Toggle print start off
+
+      expect(switches[0]).toHaveAttribute('aria-checked', 'false');
+    });
+  });
+
+  describe('redirect behavior', () => {
+    it('does not redirect when advanced auth is enabled and notifications are enabled', async () => {
+      render(<NotificationsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Notifications')).toBeInTheDocument();
+      });
+    });
+  });
+});

+ 25 - 0
frontend/src/__tests__/pages/PrintersPageDrying.test.ts

@@ -194,3 +194,28 @@ describe('temperature clamping', () => {
     });
   });
 });
+
+describe('rotate tray option', () => {
+  it('defaults to false', () => {
+    // Mirrors the initial state: useState(false)
+    const defaultRotateTray = false;
+    expect(defaultRotateTray).toBe(false);
+  });
+
+  it('is included in the API URL when true', () => {
+    // Mirrors the API call construction from client.ts
+    const buildUrl = (rotateTray: boolean) =>
+      `/printers/1/drying/start?ams_id=0&temp=55&duration=4&filament=PLA&rotate_tray=${rotateTray}`;
+    expect(buildUrl(true)).toContain('rotate_tray=true');
+    expect(buildUrl(false)).toContain('rotate_tray=false');
+  });
+
+  it('resets to false when opening popover for a new AMS unit', () => {
+    // Mirrors the popover open logic: setDryingRotateTray(false) is called
+    // each time the popover opens for any AMS unit
+    let rotateTray = true; // user enabled it for previous AMS
+    // Simulates opening popover for a different AMS
+    rotateTray = false; // setDryingRotateTray(false)
+    expect(rotateTray).toBe(false);
+  });
+});

+ 321 - 0
frontend/src/__tests__/pages/PrintersPageSpeed.test.tsx

@@ -0,0 +1,321 @@
+/**
+ * Tests for the print speed control feature on the PrintersPage.
+ *
+ * Verifies that the speed badge renders, the dropdown menu opens on click,
+ * speed options are displayed, and selecting an option calls the API.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { PrintersPage } from '../../pages/PrintersPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const mockPrinters = [
+  {
+    id: 1,
+    name: 'X1 Carbon',
+    ip_address: '192.168.1.100',
+    serial_number: '00M09A350100001',
+    access_code: '12345678',
+    model: 'X1C',
+    enabled: true,
+    nozzle_diameter: 0.4,
+    nozzle_type: 'hardened_steel',
+    location: 'Workshop',
+    auto_archive: true,
+    created_at: '2024-01-01T00:00:00Z',
+    updated_at: '2024-01-01T00:00:00Z',
+  },
+];
+
+const mockPrintingStatus = {
+  connected: true,
+  state: 'RUNNING',
+  progress: 42,
+  layer_num: 10,
+  total_layers: 100,
+  temperatures: {
+    nozzle: 220,
+    bed: 60,
+    chamber: 35,
+  },
+  remaining_time: 3600,
+  filename: 'test_print.3mf',
+  wifi_signal: -50,
+  vt_tray: [],
+  speed_level: 2,
+};
+
+const mockIdleStatus = {
+  connected: true,
+  state: 'IDLE',
+  progress: 0,
+  layer_num: 0,
+  total_layers: 0,
+  temperatures: {
+    nozzle: 25,
+    bed: 25,
+    chamber: 25,
+  },
+  remaining_time: 0,
+  filename: null,
+  wifi_signal: -50,
+  vt_tray: [],
+  speed_level: 2,
+};
+
+describe('PrintersPage - Print Speed Control', () => {
+  beforeEach(() => {
+    server.use(
+      http.get('/api/v1/printers/', () => {
+        return HttpResponse.json(mockPrinters);
+      }),
+      http.get('/api/v1/queue/', () => {
+        return HttpResponse.json([]);
+      })
+    );
+  });
+
+  describe('speed badge rendering', () => {
+    it('shows speed badge with current speed percentage when printing', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json(mockPrintingStatus);
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        // speed_level 2 = Standard = 100%
+        expect(screen.getByText('100%')).toBeInTheDocument();
+      });
+    });
+
+    it('shows speed badge with 50% for silent mode', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json({ ...mockPrintingStatus, speed_level: 1 });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('50%')).toBeInTheDocument();
+      });
+    });
+
+    it('shows speed badge with 124% for sport mode', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json({ ...mockPrintingStatus, speed_level: 3 });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('124%')).toBeInTheDocument();
+      });
+    });
+
+    it('shows speed badge with 166% for ludicrous mode', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json({ ...mockPrintingStatus, speed_level: 4 });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('166%')).toBeInTheDocument();
+      });
+    });
+
+    it('disables speed badge button when printer is idle', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json(mockIdleStatus);
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('100%')).toBeInTheDocument();
+      });
+
+      // The button containing the speed percentage should be disabled
+      const speedBadge = screen.getByText('100%').closest('button');
+      expect(speedBadge).toBeDisabled();
+    });
+  });
+
+  describe('speed dropdown menu', () => {
+    it('opens speed menu on click when printing', async () => {
+      const user = userEvent.setup();
+
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json(mockPrintingStatus);
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('100%')).toBeInTheDocument();
+      });
+
+      const speedBadge = screen.getByText('100%').closest('button')!;
+      await user.click(speedBadge);
+
+      await waitFor(() => {
+        expect(screen.getByText('Silent (50%)')).toBeInTheDocument();
+        expect(screen.getByText('Standard (100%)')).toBeInTheDocument();
+        expect(screen.getByText('Sport (124%)')).toBeInTheDocument();
+        expect(screen.getByText('Ludicrous (166%)')).toBeInTheDocument();
+      });
+    });
+
+    it('displays all four speed options in the dropdown', async () => {
+      const user = userEvent.setup();
+
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json(mockPrintingStatus);
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('100%')).toBeInTheDocument();
+      });
+
+      const speedBadge = screen.getByText('100%').closest('button')!;
+      await user.click(speedBadge);
+
+      await waitFor(() => {
+        const options = [
+          screen.getByText('Silent (50%)'),
+          screen.getByText('Standard (100%)'),
+          screen.getByText('Sport (124%)'),
+          screen.getByText('Ludicrous (166%)'),
+        ];
+        expect(options).toHaveLength(4);
+        options.forEach((opt) => expect(opt).toBeInTheDocument());
+      });
+    });
+
+    it('calls the API when a speed option is selected', async () => {
+      const user = userEvent.setup();
+      let capturedMode: number | null = null;
+
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json(mockPrintingStatus);
+        }),
+        http.post('/api/v1/printers/:id/print-speed', async ({ request }) => {
+          const url = new URL(request.url);
+          capturedMode = Number(url.searchParams.get('mode'));
+          return HttpResponse.json({ success: true, message: 'Speed set' });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('100%')).toBeInTheDocument();
+      });
+
+      // Open the speed menu
+      const speedBadge = screen.getByText('100%').closest('button')!;
+      await user.click(speedBadge);
+
+      await waitFor(() => {
+        expect(screen.getByText('Sport (124%)')).toBeInTheDocument();
+      });
+
+      // Select "Sport" speed
+      await user.click(screen.getByText('Sport (124%)'));
+
+      await waitFor(() => {
+        expect(capturedMode).toBe(3);
+      });
+    });
+
+    it('closes the dropdown after selecting a speed option', async () => {
+      const user = userEvent.setup();
+
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json(mockPrintingStatus);
+        }),
+        http.post('/api/v1/printers/:id/print-speed', () => {
+          return HttpResponse.json({ success: true, message: 'Speed set' });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('100%')).toBeInTheDocument();
+      });
+
+      const speedBadge = screen.getByText('100%').closest('button')!;
+      await user.click(speedBadge);
+
+      await waitFor(() => {
+        expect(screen.getByText('Silent (50%)')).toBeInTheDocument();
+      });
+
+      // Select an option
+      await user.click(screen.getByText('Silent (50%)'));
+
+      // Menu should close - speed labels should no longer be visible
+      await waitFor(() => {
+        expect(screen.queryByText('Silent (50%)')).not.toBeInTheDocument();
+      });
+    });
+
+    it('optimistically updates the speed display when selecting a new speed', async () => {
+      const user = userEvent.setup();
+
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json(mockPrintingStatus); // speed_level: 2 (100%)
+        }),
+        http.post('/api/v1/printers/:id/print-speed', () => {
+          return HttpResponse.json({ success: true, message: 'Speed set' });
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('100%')).toBeInTheDocument();
+      });
+
+      // Open the speed menu and select Ludicrous
+      const speedBadge = screen.getByText('100%').closest('button')!;
+      await user.click(speedBadge);
+
+      await waitFor(() => {
+        expect(screen.getByText('Ludicrous (166%)')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Ludicrous (166%)'));
+
+      // The badge should optimistically update to show 166%
+      await waitFor(() => {
+        expect(screen.getByText('166%')).toBeInTheDocument();
+      });
+    });
+  });
+});

+ 6 - 3
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -86,7 +86,7 @@ describe('SettingsPage', () => {
         // Use getAllByText since "General" appears both as tab and section heading
         expect(screen.getAllByText('General').length).toBeGreaterThan(0);
         expect(screen.getByText('Smart Plugs')).toBeInTheDocument();
-        expect(screen.getByText('Notifications')).toBeInTheDocument();
+        expect(screen.getAllByText('Notifications').length).toBeGreaterThan(0);
         expect(screen.getAllByText('Filament').length).toBeGreaterThan(0);
         expect(screen.getByText('Network')).toBeInTheDocument();
         expect(screen.getByText('API Keys')).toBeInTheDocument();
@@ -193,10 +193,13 @@ describe('SettingsPage', () => {
       render(<SettingsPage />);
 
       await waitFor(() => {
-        expect(screen.getByText('Notifications')).toBeInTheDocument();
+        expect(screen.getAllByText('Notifications').length).toBeGreaterThan(0);
       });
 
-      await user.click(screen.getByText('Notifications'));
+      // Click the tab button (not the mobile dropdown option)
+      const notificationButtons = screen.getAllByText('Notifications');
+      const tabButton = notificationButtons.find(el => el.tagName === 'BUTTON') || notificationButtons[0];
+      await user.click(tabButton);
 
       await waitFor(() => {
         expect(screen.getByText('Add Provider')).toBeInTheDocument();

+ 147 - 0
frontend/src/__tests__/pages/SpoolBuddyCalibrationPage.test.tsx

@@ -0,0 +1,147 @@
+/**
+ * Tests for SpoolBuddyCalibrationPage:
+ * - Renders "Scale Calibration" heading
+ * - Shows current weight display
+ * - Shows Tare and Calibrate buttons
+ * - Shows "No SpoolBuddy device found" when no device
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import React from 'react';
+import { render } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { MemoryRouter, Route, Routes, Outlet } from 'react-router-dom';
+import { SpoolBuddyCalibrationPage } from '../../pages/spoolbuddy/SpoolBuddyCalibrationPage';
+
+const mockDevice = {
+  id: 1,
+  device_id: 'sb-test-001',
+  hostname: 'spoolbuddy-pi',
+  ip_address: '192.168.1.100',
+  firmware_version: '1.2.3',
+  has_nfc: true,
+  has_scale: true,
+  tare_offset: 0,
+  calibration_factor: 1.0,
+  nfc_reader_type: 'PN532',
+  nfc_connection: 'I2C',
+  display_brightness: 80,
+  display_blank_timeout: 300,
+  has_backlight: true,
+  last_calibrated_at: null,
+  last_seen: '2026-03-22T12:00:00Z',
+  pending_command: null,
+  nfc_ok: true,
+  scale_ok: true,
+  uptime_s: 3600,
+  update_status: null,
+  update_message: null,
+  online: true,
+};
+
+let mockDevices = [mockDevice];
+
+vi.mock('../../api/client', () => ({
+  spoolbuddyApi: {
+    getDevices: vi.fn(() => Promise.resolve(mockDevices)),
+    tare: vi.fn().mockResolvedValue({ status: 'ok' }),
+    setCalibrationFactor: vi.fn().mockResolvedValue({ status: 'ok' }),
+  },
+}));
+
+vi.mock('react-i18next', () => ({
+  useTranslation: () => ({
+    t: (_key: string, fallback: string) => fallback,
+    i18n: { language: 'en', changeLanguage: vi.fn() },
+  }),
+}));
+
+function makeOutletContext(overrides: Record<string, unknown> = {}) {
+  return {
+    selectedPrinterId: null,
+    setSelectedPrinterId: vi.fn(),
+    sbState: {
+      weight: 250.5,
+      weightStable: true,
+      rawAdc: 12345,
+      matchedSpool: null,
+      unknownTagUid: null,
+      deviceOnline: true,
+      deviceId: 'sb-test-001',
+      remainingWeight: null,
+      netWeight: null,
+      ...(overrides.sbState as Record<string, unknown> || {}),
+    },
+    setAlert: vi.fn(),
+    displayBrightness: 100,
+    setDisplayBrightness: vi.fn(),
+    displayBlankTimeout: 0,
+    setDisplayBlankTimeout: vi.fn(),
+  };
+}
+
+function renderPage(contextOverrides: Record<string, unknown> = {}) {
+  const ctx = makeOutletContext(contextOverrides);
+  function Wrapper() {
+    return <Outlet context={ctx} />;
+  }
+  const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
+  return render(
+    <QueryClientProvider client={qc}>
+      <MemoryRouter initialEntries={['/spoolbuddy/calibration']}>
+        <Routes>
+          <Route element={<Wrapper />}>
+            <Route path="spoolbuddy/calibration" element={<SpoolBuddyCalibrationPage />} />
+          </Route>
+        </Routes>
+      </MemoryRouter>
+    </QueryClientProvider>
+  );
+}
+
+describe('SpoolBuddyCalibrationPage', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mockDevices = [mockDevice];
+  });
+
+  it('renders "Scale Calibration" heading', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('Scale Calibration')).toBeDefined();
+    });
+  });
+
+  it('shows current weight display when device available', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('Current weight')).toBeDefined();
+      expect(screen.getByText('250.5 g')).toBeDefined();
+    });
+  });
+
+  it('shows Tare and Calibrate buttons when device available', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('Tare')).toBeDefined();
+      expect(screen.getByText('Calibrate')).toBeDefined();
+    });
+  });
+
+  it('shows "No SpoolBuddy device found" when no device', async () => {
+    mockDevices = [];
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('No SpoolBuddy device found')).toBeDefined();
+    });
+  });
+
+  it('shows back button that navigates to settings', () => {
+    renderPage();
+    // Find the back button (contains a chevron SVG)
+    const buttons = screen.getAllByRole('button');
+    // First button is the back button
+    expect(buttons.length).toBeGreaterThan(0);
+  });
+});

+ 137 - 0
frontend/src/__tests__/pages/SpoolBuddyDashboard.test.tsx

@@ -0,0 +1,137 @@
+/**
+ * Tests for SpoolBuddyDashboard:
+ * - Shows stats bar (Spools, Materials, Brands)
+ * - Shows "Ready to scan" idle state when no tag detected
+ * - Shows device status section
+ * - Shows "Device Offline" state when device offline
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import React from 'react';
+import { render } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { MemoryRouter, Route, Routes, Outlet } from 'react-router-dom';
+import { SpoolBuddyDashboard } from '../../pages/spoolbuddy/SpoolBuddyDashboard';
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getSpools: vi.fn().mockResolvedValue([
+      { id: 1, material: 'PLA', brand: 'Bambu', tag_uid: 'AA:BB', archived_at: null, color_name: 'Red', rgba: 'FF0000FF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 100 },
+      { id: 2, material: 'PETG', brand: 'Bambu', tag_uid: 'CC:DD', archived_at: null, color_name: 'Blue', rgba: '0000FFFF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 200 },
+      { id: 3, material: 'ABS', brand: 'Polymaker', tag_uid: null, archived_at: null, color_name: 'White', rgba: 'FFFFFFFF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 0 },
+    ]),
+    getPrinters: vi.fn().mockResolvedValue([]),
+    getPrinterStatus: vi.fn().mockResolvedValue({ connected: false }),
+    linkTagToSpool: vi.fn().mockResolvedValue({}),
+    createSpool: vi.fn().mockResolvedValue({ id: 4 }),
+  },
+  spoolbuddyApi: {
+    getDevices: vi.fn().mockResolvedValue([]),
+  },
+}));
+
+vi.mock('react-i18next', () => ({
+  useTranslation: () => ({
+    t: (_key: string, fallback: string) => fallback,
+    i18n: { language: 'en', changeLanguage: vi.fn() },
+  }),
+}));
+
+const mockOutletContext = {
+  selectedPrinterId: null,
+  setSelectedPrinterId: vi.fn(),
+  sbState: {
+    weight: null,
+    weightStable: false,
+    rawAdc: null,
+    matchedSpool: null,
+    unknownTagUid: null,
+    deviceOnline: true,
+    deviceId: 'dev-1',
+    remainingWeight: null,
+    netWeight: null,
+  },
+  setAlert: vi.fn(),
+  displayBrightness: 100,
+  setDisplayBrightness: vi.fn(),
+  displayBlankTimeout: 0,
+  setDisplayBlankTimeout: vi.fn(),
+};
+
+function renderPage(overrides: Partial<typeof mockOutletContext['sbState']> = {}) {
+  const ctx = {
+    ...mockOutletContext,
+    sbState: { ...mockOutletContext.sbState, ...overrides },
+  };
+  function Wrapper() {
+    return <Outlet context={ctx} />;
+  }
+  const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
+  return render(
+    <QueryClientProvider client={qc}>
+      <MemoryRouter initialEntries={['/spoolbuddy']}>
+        <Routes>
+          <Route element={<Wrapper />}>
+            <Route path="spoolbuddy" element={<SpoolBuddyDashboard />} />
+          </Route>
+        </Routes>
+      </MemoryRouter>
+    </QueryClientProvider>
+  );
+}
+
+describe('SpoolBuddyDashboard', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it('shows stats bar with spool count, materials, and brands', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('Spools')).toBeDefined();
+      expect(screen.getByText('Materials')).toBeDefined();
+      expect(screen.getByText('Brands')).toBeDefined();
+      // Check that the stats numbers are rendered (3 spools, 3 materials, 2 brands)
+      const statNumbers = screen.getAllByText(/^[0-9]+$/);
+      expect(statNumbers.length).toBeGreaterThanOrEqual(3);
+    });
+  });
+
+  it('shows "Ready to scan" idle state when device online with no tag', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('Ready to scan')).toBeDefined();
+      expect(screen.getByText('Place a spool on the scale to identify it')).toBeDefined();
+    });
+  });
+
+  it('shows device status section', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('Device')).toBeDefined();
+    });
+  });
+
+  it('shows "Online" when device is online', async () => {
+    renderPage({ deviceOnline: true });
+    await waitFor(() => {
+      expect(screen.getByText('Online')).toBeDefined();
+    });
+  });
+
+  it('shows "Device Offline" state when device offline', async () => {
+    renderPage({ deviceOnline: false });
+    await waitFor(() => {
+      expect(screen.getByText('Device Offline')).toBeDefined();
+      expect(screen.getByText('Connect the SpoolBuddy display to scan spools')).toBeDefined();
+    });
+  });
+
+  it('shows current spool section heading', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('Current Spool')).toBeDefined();
+    });
+  });
+});

+ 173 - 0
frontend/src/__tests__/pages/SpoolBuddySettingsPage.test.tsx

@@ -0,0 +1,173 @@
+/**
+ * Tests for SpoolBuddySettingsPage:
+ * - Renders 4 tabs (Device, Display, Scale, Updates)
+ * - Device tab shows hostname, IP, NFC status
+ * - Updates tab shows "Check for Updates" button
+ * - Tab switching works
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor, fireEvent } from '@testing-library/react';
+import React from 'react';
+import { render } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { MemoryRouter, Route, Routes, Outlet } from 'react-router-dom';
+import { SpoolBuddySettingsPage } from '../../pages/spoolbuddy/SpoolBuddySettingsPage';
+
+vi.mock('../../api/client', () => ({
+  spoolbuddyApi: {
+    getDevices: vi.fn().mockResolvedValue([{
+      id: 1,
+      device_id: 'sb-test-001',
+      hostname: 'spoolbuddy-pi',
+      ip_address: '192.168.1.100',
+      firmware_version: '1.2.3',
+      has_nfc: true,
+      has_scale: true,
+      tare_offset: 0,
+      calibration_factor: 1.0,
+      nfc_reader_type: 'PN532',
+      nfc_connection: 'I2C',
+      display_brightness: 80,
+      display_blank_timeout: 300,
+      has_backlight: true,
+      last_calibrated_at: null,
+      last_seen: '2026-03-22T12:00:00Z',
+      pending_command: null,
+      nfc_ok: true,
+      scale_ok: true,
+      uptime_s: 3600,
+      update_status: null,
+      update_message: null,
+      online: true,
+    }]),
+    updateDisplay: vi.fn().mockResolvedValue({ status: 'ok' }),
+    tare: vi.fn().mockResolvedValue({ status: 'ok' }),
+    setCalibrationFactor: vi.fn().mockResolvedValue({ status: 'ok' }),
+    checkDaemonUpdate: vi.fn().mockResolvedValue({
+      current_version: '1.2.3',
+      latest_version: '1.2.3',
+      update_available: false,
+    }),
+    triggerUpdate: vi.fn().mockResolvedValue({ status: 'ok', message: '' }),
+  },
+}));
+
+vi.mock('react-i18next', () => ({
+  useTranslation: () => ({
+    t: (_key: string, fallback: string) => fallback,
+    i18n: { language: 'en', changeLanguage: vi.fn() },
+  }),
+}));
+
+const mockOutletContext = {
+  selectedPrinterId: null,
+  setSelectedPrinterId: vi.fn(),
+  sbState: {
+    weight: 250.0,
+    weightStable: true,
+    rawAdc: 12345,
+    matchedSpool: null,
+    unknownTagUid: null,
+    deviceOnline: true,
+    deviceId: 'sb-test-001',
+    remainingWeight: null,
+    netWeight: null,
+  },
+  setAlert: vi.fn(),
+  displayBrightness: 80,
+  setDisplayBrightness: vi.fn(),
+  displayBlankTimeout: 300,
+  setDisplayBlankTimeout: vi.fn(),
+};
+
+function OutletWrapper() {
+  return <Outlet context={mockOutletContext} />;
+}
+
+function renderPage() {
+  const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
+  return render(
+    <QueryClientProvider client={qc}>
+      <MemoryRouter initialEntries={['/spoolbuddy/settings']}>
+        <Routes>
+          <Route element={<OutletWrapper />}>
+            <Route path="spoolbuddy/settings" element={<SpoolBuddySettingsPage />} />
+          </Route>
+        </Routes>
+      </MemoryRouter>
+    </QueryClientProvider>
+  );
+}
+
+describe('SpoolBuddySettingsPage', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it('renders 4 tabs', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('Device')).toBeDefined();
+      expect(screen.getByText('Display')).toBeDefined();
+      expect(screen.getByText('Scale')).toBeDefined();
+      expect(screen.getByText('Updates')).toBeDefined();
+    });
+  });
+
+  it('device tab shows hostname and IP', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('spoolbuddy-pi')).toBeDefined();
+      expect(screen.getByText('192.168.1.100')).toBeDefined();
+    });
+  });
+
+  it('device tab shows NFC reader type', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('PN532')).toBeDefined();
+    });
+  });
+
+  it('device tab shows NFC status as Ready', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('Ready')).toBeDefined();
+    });
+  });
+
+  it('switching to Updates tab shows Check for Updates button', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('Updates')).toBeDefined();
+    });
+    fireEvent.click(screen.getByText('Updates'));
+    await waitFor(() => {
+      expect(screen.getByText('Check for Updates')).toBeDefined();
+    });
+  });
+
+  it('switching to Display tab shows Brightness', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('Display')).toBeDefined();
+    });
+    fireEvent.click(screen.getByText('Display'));
+    await waitFor(() => {
+      expect(screen.getByText('Brightness')).toBeDefined();
+    });
+  });
+
+  it('switching to Scale tab shows Tare and Calibrate buttons', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('Scale')).toBeDefined();
+    });
+    fireEvent.click(screen.getByText('Scale'));
+    await waitFor(() => {
+      expect(screen.getByText('Tare')).toBeDefined();
+      expect(screen.getByText('Calibrate')).toBeDefined();
+    });
+  });
+});

+ 59 - 6
frontend/src/api/client.ts

@@ -99,6 +99,7 @@ export interface Printer {
   external_camera_url: string | null;
   external_camera_type: string | null;  // "mjpeg", "rtsp", "snapshot"
   external_camera_enabled: boolean;
+  camera_rotation: number;  // 0, 90, 180, 270 degrees
   plate_detection_enabled: boolean;  // Check plate before print
   plate_detection_roi?: PlateDetectionROI;  // ROI for plate detection
   created_at: string;
@@ -279,6 +280,7 @@ export interface PrinterCreate {
   external_camera_url?: string | null;
   external_camera_type?: string | null;
   external_camera_enabled?: boolean;
+  camera_rotation?: number;
   plate_detection_enabled?: boolean;
   plate_detection_roi?: PlateDetectionROI;
 }
@@ -349,6 +351,8 @@ export interface Archive {
   f3d_path: string | null;
   duplicates: ArchiveDuplicate[] | null;
   duplicate_count: number;
+  duplicate_sequence: number;  // 0 = original, 1+ = nth duplicate
+  original_archive_id: number | null;  // ID of the first/original archive
   object_count: number | null;
   print_name: string | null;
   print_time_seconds: number | null;
@@ -549,6 +553,7 @@ export interface ProjectStats {
   remaining_parts: number | null;  // Remaining parts
   bom_total_items: number;
   bom_completed_items: number;
+  bom_cost: number;
 }
 
 export interface ProjectChildPreview {
@@ -607,6 +612,7 @@ export interface ProjectListItem {
   status: string;
   target_count: number | null;  // Target number of plates/print jobs
   target_parts_count: number | null;  // Target number of parts/objects
+  budget: number | null;
   created_at: string;
   archive_count: number;  // Number of print jobs (plates)
   total_items: number;  // Sum of quantities (total items printed, including failed)
@@ -627,7 +633,7 @@ export interface ProjectCreate {
   tags?: string;
   due_date?: string;
   priority?: string;
-  budget?: number;
+  budget?: number | null;
   parent_id?: number;
 }
 
@@ -642,7 +648,7 @@ export interface ProjectUpdate {
   tags?: string;
   due_date?: string;
   priority?: string;
-  budget?: number;
+  budget?: number | null;
   parent_id?: number;
 }
 
@@ -728,7 +734,7 @@ export interface ProjectImport {
   tags?: string;
   due_date?: string;
   priority?: string;
-  budget?: number;
+  budget?: number | null;
   bom_items?: BOMItemExport[];
   linked_folders?: LinkedFolderExport[];
 }
@@ -810,6 +816,8 @@ export interface AppSettings {
   // Date/time format settings
   date_format: 'system' | 'us' | 'eu' | 'iso';
   time_format: 'system' | '12h' | '24h';
+  // Filament tracking
+  disable_filament_warnings: boolean;  // Disable filament warnings (print insufficiency and assignment mismatch)
   // Default printer
   default_printer_id: number | null;
   // Dark mode theme settings
@@ -856,6 +864,10 @@ export interface AppSettings {
   bed_cooled_threshold: number;
   // Inventory low stock threshold
   low_stock_threshold: number;
+  // User email notifications toggle
+  user_notifications_enabled: boolean;
+  // Default sidebar order (admin-set for all users)
+  default_sidebar_order: string;
 }
 
 export type AppSettingsUpdate = Partial<AppSettings>;
@@ -2047,7 +2059,7 @@ export type Permission =
   | 'camera:view'
   | 'maintenance:read' | 'maintenance:create' | 'maintenance:update' | 'maintenance:delete'
   | 'kprofiles:read' | 'kprofiles:create' | 'kprofiles:update' | 'kprofiles:delete'
-  | 'notifications:read' | 'notifications:create' | 'notifications:update' | 'notifications:delete'
+  | 'notifications:read' | 'notifications:create' | 'notifications:update' | 'notifications:delete' | 'notifications:user_email'
   | 'notification_templates:read' | 'notification_templates:update'
   | 'external_links:read' | 'external_links:create' | 'external_links:update' | 'external_links:delete'
   | 'discovery:scan'
@@ -2111,6 +2123,14 @@ export interface PermissionsListResponse {
   all_permissions: Permission[];
 }
 
+// User email notification preferences
+export interface UserEmailPreferences {
+  notify_print_start: boolean;
+  notify_print_complete: boolean;
+  notify_print_failed: boolean;
+  notify_print_stopped: boolean;
+}
+
 // Auth types
 export interface LoginRequest {
   username: string;
@@ -2290,6 +2310,15 @@ export const api = {
       body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
     }),
 
+  // User Email Notifications
+  getUserEmailPreferences: () =>
+    request<UserEmailPreferences>('/user-notifications/preferences'),
+  updateUserEmailPreferences: (data: UserEmailPreferences) =>
+    request<UserEmailPreferences>('/user-notifications/preferences', {
+      method: 'PUT',
+      body: JSON.stringify(data),
+    }),
+
   // Groups
   getPermissions: () => request<PermissionsListResponse>('/groups/permissions'),
   getGroups: () => request<Group[]>('/groups/'),
@@ -2384,6 +2413,12 @@ export const api = {
   getCurrentPrintUser: (printerId: number) =>
     request<{ user_id?: number; username?: string }>(`/printers/${printerId}/current-print-user`),
 
+  // Print Speed Control
+  setPrintSpeed: (printerId: number, mode: number) =>
+    request<{ success: boolean; message: string }>(`/printers/${printerId}/print-speed?mode=${mode}`, {
+      method: 'POST',
+    }),
+
   // Chamber Light Control
   setChamberLight: (printerId: number, on: boolean) =>
     request<{ success: boolean; message: string }>(`/printers/${printerId}/chamber-light?on=${on}`, {
@@ -2391,9 +2426,9 @@ export const api = {
     }),
 
   // AMS Drying Control
-  startDrying: (printerId: number, amsId: number, temp: number, duration: number, filament: string = '') =>
+  startDrying: (printerId: number, amsId: number, temp: number, duration: number, filament: string = '', rotateTray: boolean = false) =>
     request<{ status: string; ams_id: number; temp: number; duration: number }>(
-      `/printers/${printerId}/drying/start?ams_id=${amsId}&temp=${temp}&duration=${duration}&filament=${encodeURIComponent(filament)}`,
+      `/printers/${printerId}/drying/start?ams_id=${amsId}&temp=${temp}&duration=${duration}&filament=${encodeURIComponent(filament)}&rotate_tray=${rotateTray}`,
       { method: 'POST' }
     ),
   stopDrying: (printerId: number, amsId: number) =>
@@ -3126,6 +3161,7 @@ export const api = {
 
   // Settings
   getSettings: () => request<AppSettings>('/settings/'),
+  getDefaultSidebarOrder: () => request<{ default_sidebar_order: string }>('/settings/default-sidebar-order'),
   updateSettings: (data: AppSettingsUpdate) =>
     request<AppSettings>('/settings/', {
       method: 'PUT',
@@ -4947,6 +4983,8 @@ export interface SpoolBuddyDevice {
   nfc_ok: boolean;
   scale_ok: boolean;
   uptime_s: number;
+  update_status: string | null;
+  update_message: string | null;
   online: boolean;
 }
 
@@ -4992,6 +5030,12 @@ export const spoolbuddyApi = {
   checkDaemonUpdate: (deviceId: string, includeBeta?: boolean) =>
     request<DaemonUpdateCheck>(`/spoolbuddy/devices/${deviceId}/update-check?include_beta=${includeBeta ?? false}`),
 
+  triggerUpdate: (deviceId: string) =>
+    request<{ status: string; message: string }>(`/spoolbuddy/devices/${deviceId}/update`, {
+      method: 'POST',
+      body: '{}',
+    }),
+
   writeTag: (deviceId: string, spoolId: number) =>
     request<{ status: string }>('/spoolbuddy/nfc/write-tag', {
       method: 'POST',
@@ -5010,6 +5054,7 @@ export interface BugReportRequest {
   email?: string;
   screenshot_base64?: string;
   include_support_info?: boolean;
+  debug_logs?: string;
 }
 
 export interface BugReportResponse {
@@ -5025,4 +5070,12 @@ export const bugReportApi = {
       method: 'POST',
       body: JSON.stringify(data),
     }),
+  startLogging: () =>
+    request<{ started: boolean; was_debug: boolean }>('/bug-report/start-logging', {
+      method: 'POST',
+    }),
+  stopLogging: (wasDebug: boolean) =>
+    request<{ logs: string }>(`/bug-report/stop-logging?was_debug=${wasDebug}`, {
+      method: 'POST',
+    }),
 };

+ 3 - 1
frontend/src/components/AddNotificationModal.tsx

@@ -213,7 +213,9 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
           { key: 'field_message', label: 'Message Field Name', placeholder: 'message', type: 'text', required: false, showIf: (cfg: Record<string, string>) => cfg.payload_format !== 'slack' },
         ];
       case 'homeassistant':
-        return [];
+        return [
+          { key: 'service', label: 'Home Assistant Service', placeholder: 'notify.mobile_app_myphone', type: 'text', required: false },
+        ];
       default:
         return [];
     }

+ 188 - 28
frontend/src/components/AssignSpoolModal.tsx

@@ -1,10 +1,11 @@
 import { useState } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
-import { X, Loader2, Package, Check, Search } from 'lucide-react';
+import { X, Loader2, Package, Search } from 'lucide-react';
 import { api } from '../api/client';
 import type { InventorySpool, SpoolAssignment } from '../api/client';
 import { Button } from './Button';
+import { ConfirmModal } from './ConfirmModal';
 import { useToast } from '../contexts/ToastContext';
 
 interface AssignSpoolModalProps {
@@ -15,6 +16,8 @@ interface AssignSpoolModalProps {
   trayId: number;
   trayInfo?: {
     type: string;
+    material?: string;
+    profile?: string;
     color: string;
     location: string;
   };
@@ -26,6 +29,15 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
   const { showToast } = useToast();
   const [selectedSpoolId, setSelectedSpoolId] = useState<number | null>(null);
   const [searchFilter, setSearchFilter] = useState('');
+  const [pendingAssignId, setPendingAssignId] = useState<number | null>(null);
+  const [showMismatchConfirm, setShowMismatchConfirm] = useState(false);
+  const [mismatchDetails, setMismatchDetails] = useState<{
+    type: 'material' | 'partial' | 'profile' | 'material_profile' | 'partial_profile';
+    spoolMaterial: string;
+    trayMaterial: string;
+    spoolProfile?: string;
+    trayProfile?: string;
+  } | null>(null);
 
   const { data: spools, isLoading } = useQuery({
     queryKey: ['inventory-spools'],
@@ -39,6 +51,12 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
     enabled: isOpen,
   });
 
+  const { data: settings } = useQuery({
+    queryKey: ['settings'],
+    queryFn: () => api.getSettings(),
+    enabled: isOpen,
+  });
+
   const assignMutation = useMutation({
     mutationFn: (spoolId: number) =>
       api.assignSpool({ spool_id: spoolId, printer_id: printerId, ams_id: amsId, tray_id: trayId }),
@@ -53,6 +71,9 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
       });
       queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
       showToast(t('inventory.assignSuccess'), 'success');
+      setShowMismatchConfirm(false);
+      setPendingAssignId(null);
+      setMismatchDetails(null);
       onClose();
     },
     onError: (error: Error) => {
@@ -60,6 +81,38 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
     },
   });
 
+  // --- Material/profile mismatch logic ---
+  const normalizeValue = (value: string | undefined | null) =>
+    (value ?? '').trim().toUpperCase();
+
+  const checkMaterialMatch = (
+    spoolMaterial: string | undefined | null,
+    trayMaterial: string | undefined | null
+  ): 'exact' | 'partial' | 'none' => {
+    const normalizedSpool = normalizeValue(spoolMaterial);
+    const normalizedTray = normalizeValue(trayMaterial);
+
+    if (!normalizedSpool || !normalizedTray) return 'none';
+    if (normalizedSpool === normalizedTray) return 'exact';
+    if (normalizedTray.includes(normalizedSpool) || normalizedSpool.includes(normalizedTray)) {
+      return 'partial';
+    }
+
+    return 'none';
+  };
+
+  const checkProfileMatch = (
+    spoolProfile: string | undefined | null,
+    trayProfile: string | undefined | null
+  ): boolean => {
+    const normalizedSpoolProfile = normalizeValue(spoolProfile);
+    const normalizedTrayProfile = normalizeValue(trayProfile);
+
+    if (!normalizedSpoolProfile || !normalizedTrayProfile) return false;
+
+    return normalizedSpoolProfile === normalizedTrayProfile;
+  };
+
   if (!isOpen) return null;
 
   // Filter out spools already assigned to other slots
@@ -87,19 +140,65 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
   });
 
   const handleAssign = () => {
-    if (selectedSpoolId) {
-      assignMutation.mutate(selectedSpoolId);
+    if (!selectedSpoolId) return;
+    const selectedSpool = spools?.find((spool: InventorySpool) => spool.id === selectedSpoolId);
+    if (!selectedSpool) {
+      showToast(t('inventory.assignFailed'), 'error');
+      return;
+    }
+
+    if (!settings?.disable_filament_warnings && trayInfo) {
+      const trayMaterial = trayInfo.material || trayInfo.type;
+      const materialMatchResult = checkMaterialMatch(selectedSpool.material, trayMaterial);
+      const spoolProfile = selectedSpool.slicer_filament_name || selectedSpool.slicer_filament;
+      const trayProfile = trayInfo.profile || trayInfo.type;
+      const profileMatches = checkProfileMatch(spoolProfile, trayProfile);
+
+      // Always evaluate both checks; if both fail, show a combined warning.
+      if (materialMatchResult !== 'exact' || !profileMatches) {
+        let mismatchType: 'material' | 'partial' | 'profile' | 'material_profile' | 'partial_profile' = 'profile';
+
+        if (materialMatchResult === 'none' && !profileMatches) {
+          mismatchType = 'material_profile';
+        } else if (materialMatchResult === 'partial' && !profileMatches) {
+          mismatchType = 'partial_profile';
+        } else if (materialMatchResult === 'none') {
+          mismatchType = 'material';
+        } else if (materialMatchResult === 'partial') {
+          mismatchType = 'partial';
+        }
+
+        setPendingAssignId(selectedSpoolId);
+        setMismatchDetails({
+          type: mismatchType,
+          spoolMaterial: selectedSpool.material || '',
+          trayMaterial: trayMaterial || '',
+          spoolProfile: spoolProfile || undefined,
+          trayProfile: trayProfile || undefined,
+        });
+        setShowMismatchConfirm(true);
+        return;
+      }
     }
+    assignMutation.mutate(selectedSpoolId);
+  };
+
+  const handleConfirmMismatch = () => {
+    if (!pendingAssignId) return;
+    assignMutation.mutate(pendingAssignId);
+    setShowMismatchConfirm(false);
+    setPendingAssignId(null);
   };
 
   return (
-    <div className="fixed inset-0 z-50 flex items-center justify-center">
-      <div
-        className="absolute inset-0 bg-black/60 backdrop-blur-sm"
-        onClick={onClose}
-      />
+    <>
+      <div className="fixed inset-0 z-50 flex items-center justify-center">
+        <div
+          className="absolute inset-0 bg-black/60 backdrop-blur-sm"
+          onClick={onClose}
+        />
 
-      <div className="relative w-full max-w-md mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl">
+      <div className="relative w-full max-w-2xl mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl">
         {/* Header */}
         <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
           <div className="flex items-center gap-2">
@@ -123,7 +222,7 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
               <div className="flex items-center gap-2">
                 {trayInfo.color && (
                   <span
-                    className="w-4 h-4 rounded-full border border-white/20"
+                    className="w-4 h-4 rounded-full border border-black/20"
                     style={{ backgroundColor: `#${trayInfo.color}` }}
                   />
                 )}
@@ -152,38 +251,34 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
                 <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
               </div>
             ) : filteredSpools && filteredSpools.length > 0 ? (
-              <div className="max-h-64 overflow-y-auto space-y-2">
+              <div className="max-h-96 overflow-y-auto grid grid-cols-2 sm:grid-cols-3 gap-2">
                 {filteredSpools.map((spool: InventorySpool) => (
                   <button
                     key={spool.id}
                     onClick={() => setSelectedSpoolId(spool.id)}
-                    className={`w-full p-3 rounded-lg border text-left transition-colors ${
+                    className={`p-2.5 rounded-lg border text-left transition-colors ${
                       selectedSpoolId === spool.id
                         ? 'bg-bambu-green/20 border-bambu-green'
                         : 'bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray'
                     }`}
                   >
-                    <div className="flex items-center gap-2">
+                    <p className="text-white text-sm font-medium truncate">
+                      {spool.brand ? `${spool.brand} ` : ''}{spool.material}{spool.subtype ? ` ${spool.subtype}` : ''}
+                    </p>
+                    <div className="flex items-center gap-1.5 mt-1">
                       {spool.rgba && (
                         <span
-                          className="w-4 h-4 rounded-full border border-white/20 flex-shrink-0"
+                          className="w-3 h-3 rounded-full border border-black/20 flex-shrink-0"
                           style={{ backgroundColor: `#${spool.rgba.substring(0, 6)}` }}
                         />
                       )}
-                      <div className="flex-1 min-w-0">
-                        <p className="text-white font-medium truncate">
-                          {spool.brand ? `${spool.brand} ` : ''}{spool.material}{spool.subtype ? ` ${spool.subtype}` : ''}
-                        </p>
-                        <p className="text-xs text-bambu-gray">
-                          {spool.color_name || ''}
-                          {spool.label_weight ? ` - ${spool.label_weight}g` : ''}
-                          {spool.label_weight ? ` (${Math.max(0, Math.round(spool.label_weight - spool.weight_used))}g ${t('ams.remainingUnit')})` : ''}
-                        </p>
-                      </div>
-                      {selectedSpoolId === spool.id && (
-                        <Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
-                      )}
+                      <span className="text-xs text-bambu-gray truncate">{spool.color_name || ''}</span>
                     </div>
+                    {spool.label_weight && (
+                      <p className="text-xs text-bambu-gray mt-1">
+                        {Math.max(0, Math.round(spool.label_weight - spool.weight_used))} / {spool.label_weight}g
+                      </p>
+                    )}
                   </button>
                 ))}
               </div>
@@ -222,12 +317,77 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
           </Button>
         </div>
 
+
         {assignMutation.isError && (
           <div className="mx-4 mb-4 p-2 bg-red-500/20 border border-red-500/50 rounded text-sm text-red-400">
             {(assignMutation.error as Error).message}
           </div>
         )}
+
       </div>
-    </div>
+      </div>
+
+      {showMismatchConfirm && trayInfo && selectedSpoolId && mismatchDetails && (() => {
+        let message = '';
+
+        if (mismatchDetails.type === 'material') {
+          message = t('inventory.assignMismatchMessage', {
+            spoolMaterial: mismatchDetails.spoolMaterial,
+            trayMaterial: mismatchDetails.trayMaterial,
+            location: trayInfo.location,
+          });
+        } else if (mismatchDetails.type === 'partial') {
+          message = t('inventory.assignPartialMismatchMessage', {
+            spoolMaterial: mismatchDetails.spoolMaterial,
+            trayMaterial: mismatchDetails.trayMaterial,
+            location: trayInfo.location,
+          });
+        } else if (mismatchDetails.type === 'material_profile') {
+          message = `${t('inventory.assignMismatchMessage', {
+            spoolMaterial: mismatchDetails.spoolMaterial,
+            trayMaterial: mismatchDetails.trayMaterial,
+            location: trayInfo.location,
+          })}\n\n${t('inventory.assignProfileMismatchMessage', {
+            spoolProfile: mismatchDetails.spoolProfile || t('common.unknown'),
+            trayProfile: mismatchDetails.trayProfile || t('common.unknown'),
+            location: trayInfo.location,
+          })}`;
+        } else if (mismatchDetails.type === 'partial_profile') {
+          message = `${t('inventory.assignPartialMismatchMessage', {
+            spoolMaterial: mismatchDetails.spoolMaterial,
+            trayMaterial: mismatchDetails.trayMaterial,
+            location: trayInfo.location,
+          })}\n\n${t('inventory.assignProfileMismatchMessage', {
+            spoolProfile: mismatchDetails.spoolProfile || t('common.unknown'),
+            trayProfile: mismatchDetails.trayProfile || t('common.unknown'),
+            location: trayInfo.location,
+          })}`;
+        } else if (mismatchDetails.type === 'profile') {
+          message = t('inventory.assignProfileMismatchMessage', {
+            spoolProfile: mismatchDetails.spoolProfile || t('common.unknown'),
+            trayProfile: mismatchDetails.trayProfile || t('common.unknown'),
+            location: trayInfo.location,
+          });
+        }
+
+        return (
+          <ConfirmModal
+            title={t('inventory.assignMismatchTitle')}
+            message={message}
+            confirmText={t('inventory.assignMismatchConfirm')}
+            variant="warning"
+            isLoading={assignMutation.isPending}
+            onConfirm={handleConfirmMismatch}
+            onCancel={() => {
+              if (!assignMutation.isPending) {
+                setShowMismatchConfirm(false);
+                setPendingAssignId(null);
+                setMismatchDetails(null);
+              }
+            }}
+          />
+        );
+      })()}
+    </>
   );
 }

+ 95 - 28
frontend/src/components/BugReportBubble.tsx

@@ -1,14 +1,13 @@
 import { useState, useRef, useCallback, useEffect } from 'react';
-import { Bug, X, Loader2, CheckCircle, AlertCircle, Trash2, Upload } from 'lucide-react';
+import { Bug, X, Loader2, CheckCircle, AlertCircle, Trash2, Upload, Circle, CheckCircle2 } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { bugReportApi } from '../api/client';
 
-type ViewState = 'form' | 'collecting' | 'submitting' | 'success' | 'error';
-
-const LOG_COLLECTION_SECONDS = 30;
+type ViewState = 'form' | 'logging' | 'stopping' | 'submitting' | 'success' | 'error';
 
 const MAX_DIMENSION = 1920;
 const JPEG_QUALITY = 0.7;
+const MAX_LOG_SECONDS = 300; // 5 minutes
 
 function compressImage(file: File): Promise<string> {
   return new Promise((resolve, reject) => {
@@ -34,6 +33,12 @@ function compressImage(file: File): Promise<string> {
   });
 }
 
+function formatElapsed(seconds: number): string {
+  const m = Math.floor(seconds / 60);
+  const s = seconds % 60;
+  return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
+}
+
 export function BugReportBubble() {
   const { t } = useTranslation();
   const [isOpen, setIsOpen] = useState(false);
@@ -45,20 +50,22 @@ export function BugReportBubble() {
   const [issueUrl, setIssueUrl] = useState<string | null>(null);
   const [issueNumber, setIssueNumber] = useState<number | null>(null);
   const [errorMessage, setErrorMessage] = useState('');
-  const [countdown, setCountdown] = useState(0);
+  const [elapsedSeconds, setElapsedSeconds] = useState(0);
+  const [wasDebug, setWasDebug] = useState(false);
   const modalRef = useRef<HTMLDivElement>(null);
   const fileInputRef = useRef<HTMLInputElement>(null);
+  const handleStopLoggingRef = useRef<() => void>(() => {});
 
-  // Countdown timer for log collection phase
+  // Elapsed timer for logging phase — auto-stop at 5 minutes
   useEffect(() => {
-    if (viewState !== 'collecting') return;
-    if (countdown <= 0) {
-      setViewState('submitting');
+    if (viewState !== 'logging') return;
+    if (elapsedSeconds >= MAX_LOG_SECONDS) {
+      handleStopLoggingRef.current();
       return;
     }
-    const timer = setTimeout(() => setCountdown((c) => c - 1), 1000);
+    const timer = setTimeout(() => setElapsedSeconds((s) => s + 1), 1000);
     return () => clearTimeout(timer);
-  }, [viewState, countdown]);
+  }, [viewState, elapsedSeconds]);
 
   const handleOpen = () => {
     setIsOpen(true);
@@ -69,6 +76,8 @@ export function BugReportBubble() {
     setIssueUrl(null);
     setIssueNumber(null);
     setErrorMessage('');
+    setElapsedSeconds(0);
+    setWasDebug(false);
   };
 
   const handleClose = () => {
@@ -114,16 +123,40 @@ export function BugReportBubble() {
     if (file) handleFile(file);
   }, [handleFile]);
 
-  const handleSubmit = async () => {
+  const handleStartLogging = async () => {
     if (!description.trim()) return;
-    setCountdown(LOG_COLLECTION_SECONDS);
-    setViewState('collecting');
+    try {
+      const result = await bugReportApi.startLogging();
+      setWasDebug(result.was_debug);
+      setElapsedSeconds(0);
+      setViewState('logging');
+    } catch (err) {
+      setErrorMessage(err instanceof Error ? err.message : t('bugReport.unexpectedError'));
+      setViewState('error');
+    }
+  };
+
+  const handleStopLogging = async () => {
+    setViewState('stopping');
+    try {
+      const stopResult = await bugReportApi.stopLogging(wasDebug);
+      await handleSubmitReport(stopResult.logs);
+    } catch (err) {
+      setErrorMessage(err instanceof Error ? err.message : t('bugReport.unexpectedError'));
+      setViewState('error');
+    }
+  };
+  handleStopLoggingRef.current = handleStopLogging;
+
+  const handleSubmitReport = async (debugLogs: string) => {
+    setViewState('submitting');
     try {
       const result = await bugReportApi.submit({
         description: description.trim(),
         email: email.trim() || undefined,
         screenshot_base64: screenshot || undefined,
         include_support_info: true,
+        debug_logs: debugLogs || undefined,
       });
       if (result.success) {
         setIssueUrl(result.issue_url || null);
@@ -281,30 +314,64 @@ export function BugReportBubble() {
                       {t('common.cancel')}
                     </button>
                     <button
-                      onClick={handleSubmit}
+                      onClick={handleStartLogging}
                       disabled={!description.trim()}
                       className="px-4 py-2 text-sm font-medium text-white bg-red-500 hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg transition-colors"
                     >
-                      {t('bugReport.submit')}
+                      {t('bugReport.startLogging')}
                     </button>
                   </div>
                 </>
               )}
 
-              {(viewState === 'collecting' || viewState === 'submitting') && (
+              {viewState === 'logging' && (
+                <div className="py-6 space-y-6">
+                  {/* 3-step progress indicator */}
+                  <div className="space-y-3 px-2">
+                    {/* Step 1: Completed */}
+                    <div className="flex items-center gap-3">
+                      <CheckCircle2 className="w-5 h-5 text-green-500 flex-shrink-0" />
+                      <span className="text-sm text-green-700 dark:text-green-400">{t('bugReport.stepEnableLogging')}</span>
+                    </div>
+                    {/* Step 2: Active */}
+                    <div className="flex items-center gap-3">
+                      <span className="relative flex h-5 w-5 flex-shrink-0 items-center justify-center">
+                        <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75"></span>
+                        <span className="relative inline-flex rounded-full h-3 w-3 bg-blue-500"></span>
+                      </span>
+                      <span className="text-sm font-medium text-blue-700 dark:text-blue-300">{t('bugReport.stepReproduce')}</span>
+                    </div>
+                    {/* Step 3: Upcoming */}
+                    <div className="flex items-center gap-3">
+                      <Circle className="w-5 h-5 text-gray-300 dark:text-gray-600 flex-shrink-0" />
+                      <span className="text-sm text-gray-400 dark:text-gray-500">{t('bugReport.stepStopLogging')}</span>
+                    </div>
+                  </div>
+
+                  {/* Elapsed timer */}
+                  <div className="text-center">
+                    <p className="text-3xl font-mono text-blue-500">{formatElapsed(elapsedSeconds)}</p>
+                    <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{t('bugReport.maxDuration', { minutes: 5 })}</p>
+                  </div>
+
+                  {/* Stop & Submit button */}
+                  <div className="flex justify-center">
+                    <button
+                      onClick={handleStopLogging}
+                      className="px-6 py-2.5 text-sm font-medium text-white bg-red-500 hover:bg-red-600 rounded-lg transition-colors"
+                    >
+                      {t('bugReport.stopAndSubmit')}
+                    </button>
+                  </div>
+                </div>
+              )}
+
+              {(viewState === 'stopping' || viewState === 'submitting') && (
                 <div className="flex flex-col items-center justify-center py-8 gap-3">
                   <Loader2 className="w-8 h-8 animate-spin text-blue-500" />
-                  {viewState === 'collecting' ? (
-                    <>
-                      <p className="text-sm font-medium text-gray-900 dark:text-white">{t('bugReport.collectingLogs')}</p>
-                      <p className="text-xs text-gray-500 dark:text-gray-400">{t('bugReport.collectingLogsHint')}</p>
-                      {countdown > 0 && (
-                        <p className="text-lg font-mono text-blue-500">{t('bugReport.countdownSeconds', { seconds: countdown })}</p>
-                      )}
-                    </>
-                  ) : (
-                    <p className="text-sm text-gray-600 dark:text-gray-400">{t('bugReport.submitting')}</p>
-                  )}
+                  <p className="text-sm text-gray-600 dark:text-gray-400">
+                    {viewState === 'stopping' ? t('bugReport.stoppingLogs') : t('bugReport.submitting')}
+                  </p>
                 </div>
               )}
 

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

@@ -265,7 +265,7 @@ export function CalendarView({ archives, onArchiveClick, highlightedArchiveId }:
                             {archive.filament_color.split(',').map((color, i) => (
                               <div
                                 key={i}
-                                className="w-3 h-3 rounded-full border border-white/20"
+                                className="w-3 h-3 rounded-full border border-black/20"
                                 style={{ backgroundColor: color }}
                               />
                             ))}

+ 9 - 8
frontend/src/components/ConfigureAmsSlotModal.tsx

@@ -512,9 +512,11 @@ export function ConfigureAmsSlotModal({
       for (const cp of cloudSettings.filament) {
         coveredIds.add(cp.setting_id);
         // Keep preset if it matches the slot's saved mapping or current tray_info_idx
-        const isCurrentPreset = savedId === cp.setting_id
+        const isSavedPreset = savedId === cp.setting_id;
+        const isCurrentPreset = isSavedPreset
           || (trayIdx && (cp.setting_id === trayIdx || convertToTrayInfoIdx(cp.setting_id) === trayIdx));
-        if (!isCurrentPreset && query && !cp.name.toLowerCase().includes(query)) continue;
+        // Search filter applies to ALL presets (including saved) — no bypass
+        if (query && !cp.name.toLowerCase().includes(query)) continue;
         // Filter by printer model if set (skip for current preset)
         if (!isCurrentPreset && printerModel) {
           const presetModel = extractPresetModel(cp.name);
@@ -528,8 +530,7 @@ export function ConfigureAmsSlotModal({
     if (localPresets?.filament) {
       for (const lp of localPresets.filament) {
         const localId = `local_${lp.id}`;
-        const isSaved = savedId === localId;
-        if (!isSaved && query && !lp.name.toLowerCase().includes(query)) continue;
+        if (query && !lp.name.toLowerCase().includes(query)) continue;
         items.push({ id: localId, name: lp.name, source: 'local', isUser: false });
       }
     }
@@ -841,7 +842,7 @@ export function ConfigureAmsSlotModal({
                 <span className="text-white/30">|</span>
                 {slotInfo.trayColor && (
                   <span
-                    className="w-4 h-4 rounded-full border border-white/20"
+                    className="w-4 h-4 rounded-full border border-black/20"
                     style={{ backgroundColor: `#${slotInfo.trayColor.slice(0, 6)}` }}
                   />
                 )}
@@ -882,7 +883,7 @@ export function ConfigureAmsSlotModal({
               <div className="flex items-center gap-2">
                 {slotInfo.trayColor && (
                   <span
-                    className="w-4 h-4 rounded-full border border-white/20"
+                    className="w-4 h-4 rounded-full border border-black/20"
                     style={{ backgroundColor: `#${slotInfo.trayColor.slice(0, 6)}` }}
                   />
                 )}
@@ -1034,7 +1035,7 @@ export function ConfigureAmsSlotModal({
                             title={entry.color_name}
                           >
                             <span
-                              className="w-4 h-4 rounded-full border border-white/30 flex-shrink-0"
+                              className="w-4 h-4 rounded-full border border-black/20 flex-shrink-0"
                               style={{ backgroundColor: entry.hex_color }}
                             />
                             <span className="text-xs text-white/80 whitespace-nowrap">{entry.color_name}</span>
@@ -1269,7 +1270,7 @@ export function ConfigureAmsSlotModal({
                           title={entry.color_name}
                         >
                           <span
-                            className="w-4 h-4 rounded-full border border-white/30 flex-shrink-0"
+                            className="w-4 h-4 rounded-full border border-black/20 flex-shrink-0"
                             style={{ backgroundColor: entry.hex_color }}
                           />
                           <span className="text-xs text-white/80 whitespace-nowrap">{entry.color_name}</span>

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

@@ -51,7 +51,7 @@ export function ConfirmModal({
     },
     warning: {
       icon: 'text-yellow-400',
-      button: 'bg-yellow-500 hover:bg-yellow-600',
+      button: 'bg-yellow-500 hover:bg-yellow-600 text-black',
     },
     default: {
       icon: 'text-bambu-green',
@@ -77,7 +77,7 @@ export function ConfirmModal({
             </div>
             <div className="flex-1">
               <h3 className="text-lg font-semibold text-white mb-2">{title}</h3>
-              <p className="text-bambu-gray text-sm">{message}</p>
+              <p className="text-bambu-gray text-sm whitespace-pre-line">{message}</p>
             </div>
           </div>
           <div className="flex gap-3 mt-6">

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

@@ -555,7 +555,7 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
   return (
     <div
       ref={containerRef}
-      className={`${isFullscreen ? 'fixed inset-0 z-[100]' : 'fixed z-50 rounded-lg shadow-2xl border border-bambu-dark-tertiary'} bg-bambu-dark-secondary overflow-hidden`}
+      className={`${isFullscreen ? 'fixed inset-0 z-[100]' : 'fixed z-40 rounded-lg shadow-2xl border border-bambu-dark-tertiary'} bg-bambu-dark-secondary overflow-hidden`}
       style={isFullscreen ? undefined : {
         left: state.x,
         top: state.y,
@@ -686,7 +686,8 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
             alt="Camera stream"
             className="max-w-full max-h-full object-contain select-none"
             style={{
-              transform: `scale(${zoomLevel}) translate(${panOffset.x / zoomLevel}px, ${panOffset.y / zoomLevel}px)`,
+              transform: `scale(${zoomLevel}) translate(${panOffset.x / zoomLevel}px, ${panOffset.y / zoomLevel}px) rotate(${printer?.camera_rotation || 0}deg)`,
+              ...(printer?.camera_rotation === 90 || printer?.camera_rotation === 270 ? { maxWidth: '100%', maxHeight: '100%' } : {}),
               cursor: zoomLevel > 1 ? (isPanning ? 'grabbing' : 'grab') : 'default',
             }}
             onError={handleStreamError}

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

@@ -21,6 +21,7 @@ interface SpoolmanConfig {
   onUnlinkSpool?: () => void;
   linkedSpoolId?: number | null; // Spoolman spool ID if this tray is already linked
   spoolmanUrl?: string | null; // Base URL for Spoolman (for "Open in Spoolman" link)
+  syncMode?: string | null; // If auto-sync is enabled, we may want to hide the unlink option for Bambu spools
 }
 
 interface InventoryConfig {
@@ -305,7 +306,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                         {t('spoolman.openInSpoolman')}
                       </a>
 
-                      {spoolman.onUnlinkSpool && data.vendor !== 'Bambu Lab' && (
+                      {spoolman.onUnlinkSpool && (data.vendor !== 'Bambu Lab' || spoolman.syncMode === 'manual') && (
                         <button
                           onClick={(e) => {
                             e.stopPropagation();

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

@@ -476,7 +476,7 @@ export function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: F
                     const percent = colorTotal > 0 ? ((entry.value / colorTotal) * 100).toFixed(0) : 0;
                     return (
                       <div key={entry.hex} className="flex items-center gap-1.5 text-xs min-w-0">
-                        <div className="w-2.5 h-2.5 rounded-full flex-shrink-0 border border-white/20"
+                        <div className="w-2.5 h-2.5 rounded-full flex-shrink-0 border border-black/20"
                           style={{ backgroundColor: entry.hex }} />
                         <span className="text-bambu-gray truncate">
                           {percent}%

+ 53 - 4
frontend/src/components/Layout.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
-import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, type LucideIcon } from 'lucide-react';
+import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Bell, type LucideIcon } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -25,6 +25,7 @@ interface NavItem {
 }
 
 export const defaultNavItems: NavItem[] = [
+  // Primary workflow items
   { id: 'printers', to: '/', icon: Printer, labelKey: 'nav.printers' },
   { id: 'archives', to: '/archives', icon: Archive, labelKey: 'nav.archives' },
   { id: 'queue', to: '/queue', icon: Calendar, labelKey: 'nav.queue' },
@@ -34,6 +35,8 @@ export const defaultNavItems: NavItem[] = [
   { id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
   { id: 'inventory', to: '/inventory', icon: Disc3, labelKey: 'nav.inventory' },
   { id: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' },
+  // User-account features: kept adjacent to Settings intentionally
+  { id: 'notifications', to: '/notifications', icon: Bell, labelKey: 'nav.notifications' },
   { id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' },
 ];
 
@@ -114,6 +117,47 @@ export function Layout() {
     staleTime: 5 * 60 * 1000, // 5 minutes
   });
 
+  // Fetch default sidebar order via a public endpoint (no settings:read needed)
+  const { data: defaultSidebarData } = useQuery({
+    queryKey: ['default-sidebar-order'],
+    queryFn: api.getDefaultSidebarOrder,
+    staleTime: 5 * 60 * 1000, // 5 minutes
+  });
+
+  // Apply admin default sidebar order once per user (skipped if already applied).
+  // Uses a per-user localStorage flag to prevent re-application.
+  useEffect(() => {
+    const defaultOrder = defaultSidebarData?.default_sidebar_order;
+    if (!defaultOrder) return;
+    // Wait for auth state to settle before applying to avoid double-execution
+    if (authEnabled && !user) return;
+    const appliedKey = user ? `sidebarDefaultApplied_${user.id}` : 'sidebarDefaultApplied';
+    if (localStorage.getItem(appliedKey)) return;
+    try {
+      const parsed = JSON.parse(defaultOrder);
+      const orderArr = Array.isArray(parsed) ? parsed : parsed.order;
+      if (!Array.isArray(orderArr) || orderArr.length === 0) return;
+      // Filter to valid sidebar item IDs only
+      const validIds = new Set(defaultNavItems.map(i => i.id));
+      const filtered = orderArr.filter((id: string) => typeof id === 'string' && (validIds.has(id) || isExternalLinkId(id)));
+      if (filtered.length > 0) {
+        setSidebarOrder(filtered);
+        saveSidebarOrder(filtered);
+        localStorage.setItem(appliedKey, '1');
+      }
+    } catch (e) {
+      console.error('Failed to apply default sidebar order:', e);
+    }
+  }, [defaultSidebarData?.default_sidebar_order, setSidebarOrder, user, authEnabled]);
+
+  // Check advanced auth status for conditional nav items
+  const { data: advancedAuthStatus } = useQuery({
+    queryKey: ['advancedAuthStatus'],
+    queryFn: api.getAdvancedAuthStatus,
+    staleTime: 5 * 60 * 1000, // 5 minutes
+    enabled: authEnabled,
+  });
+
   const { data: updateCheck } = useQuery({
     queryKey: ['updateCheck'],
     queryFn: api.checkForUpdates,
@@ -234,10 +278,15 @@ export function Layout() {
       inventory: 'inventory:read',
       files: 'library:read',
       settings: 'settings:read',
+      notifications: 'notifications:user_email',
     };
 
-    const isHidden = (id: string) =>
-      authEnabled && id in navPermissions && !hasPermission(navPermissions[id]);
+    const isHidden = (id: string) => {
+      if (authEnabled && id in navPermissions && !hasPermission(navPermissions[id])) return true;
+      // notifications nav item also requires advanced auth to be enabled and user_notifications_enabled setting
+      if (id === 'notifications' && (!authEnabled || !advancedAuthStatus?.advanced_auth_enabled || (settings?.user_notifications_enabled === false))) return true;
+      return false;
+    };
 
     // Add items in stored order
     for (const id of sidebarOrder) {
@@ -471,7 +520,7 @@ export function Layout() {
         </div>
 
         {/* Navigation */}
-        <nav className="flex-1 p-2">
+        <nav className="flex-1 p-2 overflow-y-auto">
           <ul className="space-y-2">
             {orderedSidebarIds.map((id) => {
               const isExternal = isExternalLinkId(id);

+ 5 - 5
frontend/src/components/LinkSpoolModal.tsx

@@ -22,7 +22,7 @@ export function LinkSpoolModal({ isOpen, onClose, tagUid, trayUuid, printerId, a
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const [search, setSearch] = useState('');
-  const spoolTag = tagUid || trayUuid;
+  const spoolTag = trayUuid || tagUid;
 
   const { data: spools, isLoading } = useQuery({
     queryKey: ['unlinked-spools'],
@@ -97,9 +97,9 @@ export function LinkSpoolModal({ isOpen, onClose, tagUid, trayUuid, printerId, a
               className="w-full pl-9 pr-3 py-2 bg-bambu-dark rounded-lg border border-white/10 text-white text-sm placeholder:text-bambu-gray focus:outline-none focus:border-bambu-green"
             />
           </div>
-          {(tagUid || trayUuid) && (
-            <p className="text-xs text-bambu-gray mt-2 font-mono truncate" title={tagUid || trayUuid}>
-              Tag: {tagUid || trayUuid}
+          {(trayUuid || tagUid) && (
+            <p className="text-xs text-bambu-gray mt-2 font-mono truncate" title={trayUuid || tagUid}>
+              Tag: {trayUuid || tagUid}
             </p>
           )}
         </div>
@@ -123,7 +123,7 @@ export function LinkSpoolModal({ isOpen, onClose, tagUid, trayUuid, printerId, a
                 className="w-full flex items-center gap-3 p-3 rounded-lg hover:bg-white/5 transition-colors text-left"
               >
                 <span
-                  className="w-6 h-6 rounded-full border border-white/20 flex-shrink-0"
+                  className="w-6 h-6 rounded-full border border-black/20 flex-shrink-0"
                   style={{ backgroundColor: spool.filament_color_hex ? `#${spool.filament_color_hex}` : '#808080' }}
                 />
                 <div className="flex-1 min-w-0">

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

@@ -107,7 +107,7 @@ function PresetCard({
               {/* 1) Color dot — always shown for filament presets, dimmed if no explicit colour */}
               {preset.preset_type === 'filament' && (
                 <div
-                  className={`w-4 h-4 rounded-full border border-white/20 flex-shrink-0 ${
+                  className={`w-4 h-4 rounded-full border border-black/20 flex-shrink-0 ${
                     !hasExplicitColour && !colourHex ? 'opacity-25' : !hasExplicitColour ? 'opacity-50' : ''
                   }`}
                   style={{ backgroundColor: colourHex ? `#${colourHex}` : '#666' }}

+ 64 - 54
frontend/src/components/PrintModal/PlateSelector.tsx

@@ -1,4 +1,4 @@
-import { Layers, Check, AlertTriangle } from 'lucide-react';
+import { Layers, Check, AlertTriangle, Square, CheckSquare } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import type { PlateSelectorProps } from './types';
 import { formatDuration } from '../../utils/date';
@@ -6,14 +6,17 @@ import { formatDuration } from '../../utils/date';
 /**
  * Plate selection grid for multi-plate 3MF files.
  * Shows thumbnails, names, objects, and print times for each plate.
+ * In multi-select mode (add-to-queue), plates have checkboxes for selecting a subset.
+ * In single-select mode (reprint/edit), only one plate can be selected at a time.
  */
 export function PlateSelector({
   plates,
   isMultiPlate,
-  selectedPlate,
-  onSelect,
-  queueAll,
-  onQueueAllChange,
+  selectedPlates,
+  onToggle,
+  onSelectAll,
+  onDeselectAll,
+  multiSelect,
 }: PlateSelectorProps) {
   const { t } = useTranslation();
 
@@ -22,76 +25,83 @@ export function PlateSelector({
     return null;
   }
 
+  const allSelected = selectedPlates.size === plates.length;
+
   return (
     <div className="mb-4">
       <div className="flex items-center gap-2 mb-2">
         <Layers className="w-4 h-4 text-bambu-gray" />
-        <span className="text-sm text-bambu-gray">Select Plate to Print</span>
-        {!selectedPlate && !queueAll && (
+        <span className="text-sm text-bambu-gray">Select Plate{multiSelect ? 's' : ''} to Print</span>
+        {selectedPlates.size === 0 && (
           <span className="text-xs text-orange-400 flex items-center gap-1">
             <AlertTriangle className="w-3 h-3" />
             Selection required
           </span>
         )}
-        {onQueueAllChange && (
+        {multiSelect && onSelectAll && onDeselectAll && (
           <button
             type="button"
-            onClick={() => onQueueAllChange(!queueAll)}
+            onClick={allSelected ? onDeselectAll : onSelectAll}
             className={`ml-auto text-xs px-2 py-0.5 rounded-full border transition-colors ${
-              queueAll
+              allSelected
                 ? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
                 : 'border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-gray'
             }`}
           >
-            {t('queue.queueAllPlates', { count: plates.length })}
+            {allSelected
+              ? t('queue.deselectAll')
+              : t('queue.selectAllPlates', { count: plates.length })}
           </button>
         )}
       </div>
       <div className="grid grid-cols-2 gap-2">
-        {plates.map((plate) => (
-          <button
-            key={plate.index}
-            type="button"
-            onClick={() => {
-              if (queueAll && onQueueAllChange) {
-                onQueueAllChange(false);
-              }
-              onSelect(plate.index);
-            }}
-            className={`flex items-center gap-2 p-2 rounded-lg border transition-colors text-left ${
-              queueAll || selectedPlate === plate.index
-                ? 'border-bambu-green bg-bambu-green/10'
-                : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
-            }`}
-          >
-            {plate.has_thumbnail && plate.thumbnail_url != null ? (
-              <img
-                src={plate.thumbnail_url}
-                alt={`Plate ${plate.index}`}
-                className="w-10 h-10 rounded object-cover bg-bambu-dark-tertiary"
-              />
-            ) : (
-              <div className="w-10 h-10 rounded bg-bambu-dark-tertiary flex items-center justify-center">
-                <Layers className="w-5 h-5 text-bambu-gray" />
+        {plates.map((plate) => {
+          const isSelected = selectedPlates.has(plate.index);
+          return (
+            <button
+              key={plate.index}
+              type="button"
+              onClick={() => onToggle(plate.index)}
+              className={`flex items-center gap-2 p-2 rounded-lg border transition-colors text-left ${
+                isSelected
+                  ? 'border-bambu-green bg-bambu-green/10'
+                  : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
+              }`}
+            >
+              {multiSelect && (
+                isSelected
+                  ? <CheckSquare className="w-4 h-4 text-bambu-green flex-shrink-0" />
+                  : <Square className="w-4 h-4 text-bambu-gray flex-shrink-0" />
+              )}
+              {plate.has_thumbnail && plate.thumbnail_url != null ? (
+                <img
+                  src={plate.thumbnail_url}
+                  alt={`Plate ${plate.index}`}
+                  className="w-10 h-10 rounded object-cover bg-bambu-dark-tertiary"
+                />
+              ) : (
+                <div className="w-10 h-10 rounded bg-bambu-dark-tertiary flex items-center justify-center">
+                  <Layers className="w-5 h-5 text-bambu-gray" />
+                </div>
+              )}
+              <div className="min-w-0 flex-1">
+                <p className="text-sm text-white font-medium truncate">
+                  {plate.name || `Plate ${plate.index}`}
+                </p>
+                <p className="text-xs text-bambu-gray truncate">
+                  {plate.objects.length > 0
+                    ? plate.objects.slice(0, 3).join(', ') +
+                      (plate.objects.length > 3 ? '...' : '')
+                    : `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
+                  {plate.print_time_seconds != null ? ` • ${formatDuration(plate.print_time_seconds)}` : ''}
+                </p>
               </div>
-            )}
-            <div className="min-w-0 flex-1">
-              <p className="text-sm text-white font-medium truncate">
-                {plate.name || `Plate ${plate.index}`}
-              </p>
-              <p className="text-xs text-bambu-gray truncate">
-                {plate.objects.length > 0
-                  ? plate.objects.slice(0, 3).join(', ') +
-                    (plate.objects.length > 3 ? '...' : '')
-                  : `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
-                {plate.print_time_seconds != null ? ` • ${formatDuration(plate.print_time_seconds)}` : ''}
-              </p>
-            </div>
-            {(queueAll || selectedPlate === plate.index) && (
-              <Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
-            )}
-          </button>
-        ))}
+              {!multiSelect && isSelected && (
+                <Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
+              )}
+            </button>
+          );
+        })}
       </div>
     </div>
   );

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